Linux下的五种IO模型

引言

本文我们主要了解一下Unix/Linux下5种网络IO模型:blocking IO, nonblocking IO, IO multiplexing, signal driven IO, asynchronous IO以及select/poll/epoll的基本原理,更好的理解在高级语言中的异步编程,但以理解概念为主,并不会涉及到具体的C语言代码编写,如果想要深入的朋友建议阅读Richard Stevens的Unix Network Programming。

写在前面

为了更好的理解下面提到的Linux下5种网络IO的概念,我们还是有必要先理清几个概念。

1.程序空间与内核空间

在Linux中,对于一次读取IO的操作,数据并不会直接拷贝到程序的程序缓冲区。它首先会被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的缓冲区。p.s: 最后一句话非常重要,重复一遍。

  1. Waiting for the data to be ready(等待数据到达内核缓冲区)
  2. Copying the data from the kernel to the process(从内核缓冲区拷贝数据到程序缓冲区)

2.阻塞与非阻塞

阻塞就是说我们某一个请求不能立即得到返回应答,否则就可以理解为非阻塞。

3.同步IO与异步IO

这里先直接引用Stevens(POSIX)的定义:

A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes. An asynchronous I/O operation does not cause the requesting process to be blocked.

对于同步与异步,我们可以用一个简单的生活场景来描述。当我们排队在实体店买东西可以视作同步,而网购则可以视作异步。实体店排队这种同步情形显然是非常的浪费时间,等待的这段时间我们被阻塞住了不能干其他的事情,而网购只要我们提交一下订单之后其他什么都不用管了,商品到了,快递员给我们发送一个信号(打电话)我们直接到门口去拿,等待的这段时间我们可以用来撸代码。

p.s: 等你阅读完文章的后面部分,回过头来看异步其实就是将等待的这段时间去处理IO操作,把CPU(我们的大脑)让出来做其他更有价值的事情(撸代码),而不是像同步那样去傻傻地排队。更加详细准确的定义可以在阅读完本文后面部分后参考维基百科

4.文件描述符

在Linux下面一切皆文件,文件描述符(file descriptor)是内核为文件所创建的索引,所有I/O操作都通过调用文件描述符(索引)来执行,包括下面我们要提到的socket。Linux刚启动的时候会自动设置0是标准输入,1是标准输出,2是标准错误。

5种网络IO模型

大家还是应该多结合Stevens的图片来理解,不要只看我枯燥的文字总结。

1.blocking IO(阻塞IO)

blocking IO

如图所示,进程调用一个recvfrom请求,但是它不能立刻收到回复,直到数据返回,然后将数据从内核空间复制到程序空间。这里我们再次回顾开篇提到的两个过程:

  1. Waiting for the data to be ready(等待数据到达内核缓冲区)
  2. Copying the data from the kernel to the process(从内核缓冲区拷贝数据到程序缓冲区)

注意到没有,在上面这两个过程中,进程都处于blocked(阻塞)状态,在等待数据返回的过程中不能空闲出来干其他的事情。

2.nonblocking IO(非阻塞IO)

当我们设置一个socket为nonblocking(非阻塞),相当于告诉内核当我们请求的IO操作不能立即得到返回结果,不要把进程设置为sleep状态,而是返回一个错误信息(下图中的EWOULDBLOCK)。

nonblocking IO

我们来分析一下图片中的整个流程。前三次我们调用recvfrom请求,但是并没有数据返回,所以内核只能返回一个错误信息(EWOULDBLOCK)。但是当我们第四次调用recvfrom,数据已经准备好了,然后将它从内核空间复制到程序空间。

在非阻塞状态下,我们的过程一(wait for data)并不是完全的阻塞的,但是过程二(copy data from kernel to user)依然处于一个阻塞状态。

3.IO multiplexing(IO复用)

IO复用的好处是我们可以通过(select/poll/epoll)一个时刻处理多个文件描述符,这里以select为例来分析一下。

IO multiplexing

IO复用实际上也是完全阻塞的,请仔细看图(图中我们有两个return,前面我们都只有一个return),Stevens在书中提到这里并没有阻塞在recfrom阶段而是阻塞在select阶段,其实这样说并不是非常的严谨,因为recform其实也是一个阻塞过程(图中也描述了),recvfrom过程中进程除了等待copy data from kernel to user以外,并不能空闲出来干其他事情。

两个过程的都是阻塞的,看起来IO复用和阻塞IO相比似乎并没有什么优势,而且还需要两个return,但是这里注意在IO复用中我们可以同时监听多个文件描述符。

4.signal driven IO(信号驱动IO)

我们也可以使用信号驱动IO,要求内核通知我们当文件描述符准备就绪以后发送相应的信号。

signal driven IO

我们根据图片来分析一下。阶段1: 我们首先设置socket为一个信号驱动IO,并且通过sigaction system call安装一个signal handler,注意这个过程是瞬时的,所以这个阶段是非阻塞的。阶段2: 当数据已经准备好了以后,一个SIGIO信号传送给我们的进程告诉我们数据准备好了,然后进程开始等待数据从内核空间复制到程序空间(copy data from kernel to user),这个过程是阻塞的,因为我们的进程只能等待数据复制完毕。

5.asynchronous IO(异步IO)

我们来看一下异步的概念,异步就是说对于上面两个步骤(wait for data 和copy of the data from the kernel to our buffer)当它们完成的时候会自动通知进程,在这段时间里面进程什么都不用操心,就像网购一样,下了单什么也不用管了等着快递员通知我们(即我们通常所说的callback)。相比前面的信号驱动IO,异步IO两个阶段都是非阻塞的。

asynchronous IO

6.小结

总结

阻塞式IO(默认),非阻塞式IO(nonblock),IO复用(select/poll/epoll),signal driven IO(信号驱动IO)都是属于同步型IO,因为在第二个阶段: 从内核空间拷贝数据到程序空间的时候不能干别的事。只有异步I/O模型(AIO)才是符合我们上面对于异步型IO操作的含义,在1.wait for data,2.copy data from kernel to user,这两个等待/接收数据的时间段内进程可以干其他的事情,只要等着被通知就可以了。

select/poll/epoll

即使现在的各个Linux版本普遍引入了copy on write和线程,但实际上进程/线程之间的切换依然还是一笔很大的开销,这个时候我们可以考虑使用上面提到到多路IO复用,回顾一下我们上面提到的多路IO复用模型的基本原理:一个进程可以监视多个文件描述符,一旦某个文件描述符就绪(读/写准备就绪),能够信号通知程序进行相应的读写操作。下面我们就来简单的看一下多路IO复用的三种方式。

select

int select (int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset,
const struct timeval *timeout);

如上面的方法声明所示, select监听三类描述符: readset(读), writeset(写), exceptset(异常), 我们编程的时候可以制定这三个参数监听对应的文件描述符。正如前面提到的,select调用后进程会阻塞, 当select返回后,可以通过遍历fdset,来找到就绪的描述符。

select优点在于它的跨平台,但是也有显著的缺点单个进程能够监视的文件描述符的数量存在最大限制,默认设置为1024/2048,虽然设置可以超过这一限制,但是这样也可能会造成效率的降低。而且select扫描的时候也是采用的轮循,算法复杂度为O(n),这在fdset很多时效率会较低。

下面总结一下select的三个缺点,在下面我们来看epool是如何解决这些缺点的:

每次调用select,都需要将fd_set从用户态拷贝到内核态。
每次调用select都要在内核遍历所有传递过来的fd_set看哪些描述已经准备就绪。
select有1024的容量限制。

poll

int poll (struct pollfd *fdarray, unsigned long nfds, int timeout);

poll和select并没有太大的区别,但是它是基于链表实现的所以并没有最大数量限制,它将用户传入的数据拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次的遍历。算法复杂度也是O(n)

epoll

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

select和poll都只提供了一个函数。而epoll提供了三个函数: epoll_create是创建一个epoll句柄, epoll_ctl是注册要监听的事件类型, epoll_wait则是等待事件的产生。与select相比,epoll几乎没有描述符限制(cat /proc/sys/fs/file-max可查看)。它采用一个文件描述符管理多个描述符,将用户的文件描述符的事件存放到kernel的一个事件表中,这样在程序空间和内核空间的只要做一次拷贝。它去掉了遍历文件描述符这一步骤,采用更加先进的回调(callback)机制,算法复杂度降到了O(1)。p.s: 虽然表面看起来epoll非常好,但是对于连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,因为epoll是建立在大量的函数回调的基础之上。

下面我们来总结一下epoll是如何解决select的三个缺点的:

  1. 在epoll_ctl每次注册事件到epoll句柄的时候,会将fd拷贝到内核中,保证了每个fd在整个过程中只会拷贝一次。
  2. 对于第二缺点,epool_ctl为每个fd指定一个回调函数,当设备就绪,就会调用这个回调函数,而这个回调函数会把准备就绪的fd加入一个就绪链表,而不用像select那样去重新遍历一次看有哪些准备就绪的文件描述符。
  3. 对于第三个缺点,我们上面已经提及到了,epoll几乎没有容量限制,可以通过cat /proc/sys/fs/file-max来查看。

References

UNIX NETWORK PROGRAMMING

Contact

GitHub: https://github.com/ziwenxie
Blog: https://www.ziwenxie.site