Linux IO 原理

在上一篇文章中主要介绍了 Java NIO 相关基础和 Reactor 模式,NIO 的实现在不同的平台上依赖于操作系统所提供的系统调用。下面将介绍 NIO 在 Linux 平台上的底层实现原理。

操作系统基础

首先了解一下类 Unix 操作系统体系结构与基本概念。

类 Unix 的体系结构

操作系统内核(kernel)的本质是一个软件:可以控制计算机的硬件资源,并提供上层应用程序运行的环境。内核拥有可以访问受保护的空间和底层硬件设备的权限。

用户所编写的程序不能直接去访问内核代码,需要通过接口,这些接口被设计为系统调用(system call)。公用函数库建立在系统调用之上,应用程序即可以直接使用公用函数库,也可以使用系统调用。例如 fread 是 C 标准库函数,read 是系统调用。

Shell 是一个特殊的应用程序,为运行其他应用程序提供了一个接口。最外层的是应用程序。

用户态 & 内核态

类 Unix 的体系架构可以分为用户态和内核态。操作系统将虚拟空间划分成用户空间内核空间两个部分,并划分了特权级,Intel x86 架构的 cpu 一共有 0~3 四个特权级,数字越小,特权越高,在 Unix/Linux 中指使用了 0 和 3 两个特权级,分别表示内核态和用户态。

用户应用程序一般在用户态下运行,当需要操作系统介入时,就会切换到用户态。用户态到内核态的切换方式:

  • 系统调用:用户态进程主动通过系统调用申请操作系统提供的服务程序。(系统调用的本质其实也是中断,相对于外围设备的硬中断,这种中断称为软中断)
  • 异常:当CPU正在执行运行在用户态的程序时,突然发生某些预先不可知的异常事件,这个时候就会触发从当前用户态执行的进程转向内核态执行相关的异常事件,典型的如缺页异常。
  • 外设中断:当外围设备完成用户的请求操作后,会像CPU发出中断信号,此时,CPU就会暂停执行下一条即将要执行的指令,转而去执行中断信号对应的处理程序,如果先前执行的指令是在用户态下,则自然就发生从用户态到内核态的转换。

Linux 进程地址空间

下图是 Linux 进程的内存地址空间,以 32 位的操作系统为例。

地址空间分成了两部分,用户空间(0x00000000 ~ 0xBFFFFFFF)和内核空间(0xC0000000 ~ 0xFFFFFFFF)。不同的进程用户空间是私有的。内核空间是持续存在的,并且是共享的,所有进程中都映射到同样的物理内存,内核代码和数据总是可寻址的,随时准备处理中断和系统调用。

用户空间的由如下的内容组成:

  • Text Segmet(ELF,文本段):程序代码在内存中的映射,存放二进制代码。
  • Data Segmet(初始化过的数据段):存放程序运行时已经初始化赋值的静态变量数据(被程序员初始化)。
  • BSS Segment(未初始化的数据段):存放程序运行时未被初始化的静态变量数据。
  • Heap:存储动态内存分配,需要程序员手工分配,手工释放。与数据结构中的堆是两回事,分配方式类似于链表。
  • :当有方法的调用的时候会产生一个方法栈帧进行压栈。栈帧中存储局部、临时变量,函数调用,存储函数的返回指针,用于控制函数的调用和返回。栈帧在函数程序块开始时自动分配内存,结束时自动释放内存。

数据读写

Linux 的数据读写有两种方式,传统的读写与零拷贝技术。传统的读写数据需要复制两次,零拷贝技术只需要复制一次。下面以文件读写为例,简要说明一下传统的读写。

读数据

当应用程序需要读写磁盘上的数据时,会使用内核的系统调用。数据首先通过 DMA(Direct Memory Access)将磁盘上的数据复制到内核的缓冲区中,然后应用程序再从内核缓冲区中将数据复制到应用缓冲区。

写数据

当应用程序需要将数据传送给另外一个客户端时,也会使用内核的系统调用。应用程序首先将数据写入到内核的 socket 缓冲区中,然后再通过 DMA 将数据发给 socket 连接的另一端。

DMA copy。这是一种通过硬件实现的数据传输机制。简单的说,就是不在CPU的参与下完成数据的传输。

CPU copy 。 相比DMA而言,copy的过程需要用到cpu 寄存器等,速度较慢。

零拷贝技术

考虑一个应用场景,服务器程序经常需要将一些数据通过网络传输给另外一个程序。

传统方式

传统的处理方式是先将数据从磁盘上读出来,然后再将数据通过 socket 写出去。这会使用两个函数:

1
2
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);

流程图如下:

由图中我们可以看出数据一共被复制了 4 次:

  • 磁盘复制到内核 Read Buffer
  • Read Buffer 复制到用户空间 Buffer
  • 用户空间 Buffer 复制到 Socket Buffer
  • Socket Buffer 复制到 NIC Buffer

注:NIC(network interface card)

在整个过程中数据一共复制了 4 次,用户态-内核态切换了 4 次。Read 两次,Send 两次。

零拷贝

零拷贝技术让整个数据复制的过程中不需要用户的参与,即数据传输直接在内核中完成,不需要复制到用户空间的缓存中。

1
2
// out_fd: socket 或者 file 都可以用 fd 表示
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

流程图如下:

数据从磁盘复制到内核 Read Buffer 中后,直接复制到 Socket Buffer 中。

所以整个过程中数据只需要复制 3 次,用户态-内核态切换了两次。

Linux IO 模式

对于一次 IO 访问(以 read 举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:

  1. 等待数据准备 (Waiting for the data to be ready),即数据复制到内核的缓冲区。
  2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process),内核缓冲区到用户空间的缓冲区。

linux 一共有下面五种 IO 模式:

  • 阻塞 IO(blocking IO)
  • 非阻塞 IO(nonblocking IO)
  • IO 多路复用( IO multiplexing)
  • 信号驱动 IO( signal driven IO)
  • 异步 IO(asynchronous IO)

在实际的使用中,信号驱动 IO 并不常用。

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于 Unix、Linux这样的操作系统。

阻塞 IO(Blocking IO)

在 Linux 中所有的 IO 默认都是阻塞模式,阻塞 IO 的流程如下:

recvfrom 是从 socket 接受数据的系统调用。

1
2
3
4
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

当使用这个系统调用的时候,内核会介入完成两部分操作:

  1. 准备数据,这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要时间的,不管是复制磁盘文件中的数据,还是接收 socket 传输的数据。而等待这一个过程,用户进程会将自己阻塞。
  2. 当内核中的缓冲区已经将数据复制好之后,就会将数据从内核中的缓冲区复制到用户空间中的缓冲区。复制完成之后返回结果,用户进程继续执行。

可以看到,用户进程在 IO 操作的两个阶段中都是被阻塞的。

非阻塞 IO(NonBlocking IO)

非阻塞 IO 的流程如下:

在 Linux 中,可以通过以下三种方式将 IO 设置为非阻塞 IO:

1
2
3
4
5
6
7
8
9
// 1.创建socket的时候,指定 socket 是异步的,在 type 的参数中设置 SOCK_NONBLOCK 标志即可。
int socket(int domain, int type, int protocol);
int s = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP);

// 2.使用fcntl函数。
fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFL, 0) | O_NONBLOCK);

// 3.使用ioctl函数。1:非阻塞 0:阻塞。
ioctl(sockfd, FIONBIO, 1);

非阻塞 IO 与阻塞 IO 的区别在于,在数据准备的阶段,用户进程不需要等待该阶段完成。用户进程会不断地去询问这个过程是否完成,若未完成用户进程也会收到一个回复,若已完成则开始第二阶段数据的复制。

多路复用 IO(IO Multiplexing)

多路复用 IO 也被称为事件驱动 IO,流程如下:

IO 多路复用通过 select、poll、epoll 实现。IO 多路复用优点在于单个 process 可以同时处理多个网络连接的IO。它的基本原理就是 select,poll,epoll 函数会不断的轮询所负责的所有连接的 socket,当某个 socket 有数据到达,就通知用户进程。

当用户调用了 select 后,用户进程需要等待 select 函数的返回,即有 socket 的数据就绪。在 Java 中的 Selector 提供了 select 方法和 selectNow 方法,前者需要等待有就绪的读写事件,而后者可以直接返回。

1
2
3
4
5
6
// allow a program to monitor multiple file descriptors, waiting until one or more of the file descriptors become "ready" for some class of I/O operation
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);

// wait for some event on a file descriptor
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

异步 IO(Asynchronous IO)

异步 IO 是真正的异步非阻塞 IO,流程如下:

用户进程发起 read 操作之后,就立刻返回开始去做其它的事。而另一方面,从 kernel 的角度,当它受到一个asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

对比

在 non-blocking IO 中,虽然进程大部分时间都不会被 block,但是它仍然要求进程去主动的 check,并且当数据准备完成以后,也需要进程主动的再次调用 recvfrom 来将数据拷贝到用户内存。而 asynchronous IO 则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

多路复用的原理

前面说到 Linux 实现多路复用是通过 select、poll、epoll 函数实现的。IO 多路复用是一种机制,这种机制提供了单个进程监听多个 IO 的能力。一个进程可以通过监听多个文件描述符,一旦某个描述符的某种事件就绪(读、写就绪),就通知程序进程读写操作。

select

select 函数的定义如下:

1
2
// allow a program to monitor multiple file descriptors, waiting until one or more of the file descriptors become "ready" for some class of I/O operation
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

select 关注了三种描述符,readfds、writefds、exceptfds,分别是读、写和发生异常的。可以通过设置 timeout 来指定等待事件,当将 timeout 设置为 null 时,select 会立即返回。函数返回值是就绪文件描述符的数量,0 表示timeout,-1 表示出错。函数返回后要通过遍历的方式找到就绪的fd。

select 采用的是轮询的方式监听所有的文件描述符,单个进程能够监听的文件描述符的数量有最大限制,在Unix下通常为256,Linux 下通常为1024,可以通过修改 /sys/types.h 头文件中的 FD_SETSIZE 值然后重新编译内核进行修改。

缺点:

  1. 可以监听的文件描述符有限。
  2. 以轮询的方式进行检查是否就绪效率低。
  3. 每次检查是否就绪要将所有需要被检查的 fd 的数据结构复制到内核,完成后修改这个数据结构并返回给程序,从用户空间到内核空间的来回拷贝将占用大量 CPU。

优点:在所有的平台上都支持

poll

poll 函数的定义如下:

1
2
3
4
5
6
7
8
// wait for some event on a file descriptor
int poll (struct pollfd *fds, unsigned int nfds, int timeout);

struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};

poll 也是以轮询的方式进行监听,但与 select 不同的是 poll 抛弃了用位图存储文件描述符的方式,而使用 pollfd。需要监听什么事件,只需要初始化 events 即可,当 poll 函数返回时 revents 被设定为实际发生的事件。

缺点:

  1. 以论文的方式进行检查是否就绪效率低。
  2. 每次检查是否就绪要将所有需要被检查的 fd 的数据结构复制到内核,完成后修改这个数据结构并返回给程序,从用户空间到内核空间的来回拷贝将占用大量 CPU。

优点:

  1. 监听没有最大数量的限制。
  2. select 的函数返回值是三个集合中就绪态的 fd 的数量之和,因此如果同一个 fd 在不止一个集合中同时被指定,且对于多个 I/O 事件都处于就绪态的话就会被统计多次,而 poll 的返回值是不重复的。

select 和 poll 都是需要通过遍历所有的文件描述符来获取已经就绪的事件,但是同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

epoll

epoll 是在2.6内核中提出的,是之前的 select 和 poll 的增强版本。epoll更加灵活,没有描述符限制。epoll 使用一个文件描述符管理多个描述符,其他的文件描述符注册到这个描述符上,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次。

epoll 有三个函数:

1
2
3
4
5
6
7
8
9
10
11
// 创建一个 epoll 的句柄(实例),size 用来告诉内核这个监听的数目一共有多大
int epoll_create(int size)
// 修改 epfd 所代表的 epoll 实例的兴趣列表,即可以向 epoll 实例添加、删除、修改 fd,并设定感兴趣的事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
// 返回 epoll 实例中处于就绪态的 fd 信息
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

struct epoll_event {
uint32_t events; /*指定了待检查的fd所感兴趣的事件集合,用位掩码表示*/
epoll_data_t data; /*用来在就绪后给调用进程传递信息*/
};

步骤

epoll_create

调用该函数创建一个句柄(称为 epoll 句柄,epfd),指定的 size 用来告诉内核这个监听的数目的大小。size 不是限制 epoll 监听描述符的最大数量,而是内核对内存使用大小的一个建议。

epoll 句柄是一个文件描述符,它会占用一个 fd 值。在 Linux 中通过 /proc/pid/fd/ 可看到该值。所以在使用 epoll 的程序中,使用完之后应用 close 方法关闭 epoll,否则可能会导致 fd 被消耗殆尽。

epoll_ctl

首先将一下函数中各个参数的意义:

参数 含义
epfd 调用 epoll_create 函数产生的 epoll 句柄
op 用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对 fd 的监听事件。
fd 监听的文件描述符 fd
epoll_event 监听 fd 的事件

函数对指定的文件描述符 fd 执行指定的 op 操作。

event 集合如何:

1
2
3
4
5
6
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

epoll_wait

参数及意义如下:

参数 含义
epfd 调用 epoll_create 函数产生的 epoll 句柄
events 从内核得到的事件集合
maxevents 告诉内核这个 events 有多大,该值不能大于创建 epoll_create() 时的 size
timeout 超时时间

该函数等待 epfd 上的 IO 事件。

工作模式

epoll 对文件描述符的操作有两种模式:LT(level trigger)ET(edge trigger)

参考资料

[Linux IO模式及 select、poll、epoll详解]