I/O模型

一个输入操作通常包括两个阶段:

  • 等待数据准备好
  • 从内核(kernel)向进程复制数据

对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待数据到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。

正是因为这两个阶段,linux系统产生了下面五种网络模式的方案。

  • 阻塞式I/O
  • 非阻塞式I/O
  • I/O复用(select和poll)
  • 信号驱动式I/O(SIGIO)
  • 异步I/O(AIO)

阻塞式I/O(blocking IO)

应用进程被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回,一个典型的读操作流程大概是这样:

Socket01
Socket01

可以看到blocking IO的特点就是在IO执行的两个阶段都被block了。

非阻塞I/O(nonblocking IO)

应用进程执行系统调用之后,内核返回一个错误码。应用进程可以继续执行,但是需要不断的执行系统调用来获知I/O是否完成,这种方式称为轮询(polling)。

由于CPU要处理更多的系统调用,因此这种模型的CPU利用率比较低。

当对一个nonblocking socket执行读操作时,流程是这个样子:

Socket02
Socket02

当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。

I/O多路复用(IO multiplexing)

使用select或者poll等待数据,并且可以等待多个套接字中的任何一个变为可读。这一过程会被阻塞,当某一个套接字可读时返回,之后再使用recvfrom把数据从内核复制到进程中。

它可以让单个进程具有处理多个 I/O 事件的能力。又被称为Event Driven I/O,即事件驱动I/O。

如果一个Web服务器没有I/O复用,那么每一个Socket连接都需要创建一个线程去处理。如果同时有几万个连接,那么就需要创建相同数量的线程。相比于多进程和多线程技术,I/O复用不需要进程线程创建和切换的开销,系统开销更小。

Socket03
Socket03

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call(select和recvfrom),而blocking IO只调用了一个system call(recvfrom)。但是,用select的优势在于它可以同时处理多个connection。

信号驱动I/O(signal driven IO)

应用进程使用sigaction系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达时向应用进程发送SIGIO信号,应用进程收到之后在信号处理程序中调用recvfrom将数据从内核复制到应用进程中。

相比于非阻塞式I/O的轮询方式,信号驱动I/O的CPU利用率更高。

Socket04
Socket04

异步I/O(asynchronous IO)

应用进程执行aio_read系统调用会立即返回,应用进程可以继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。

异步I/O与信号驱动I/O的区别在于,异步I/O的信号是通知应用进程I/O完成,而信号驱动I/O的信号是通知应用进程可以开始I/O。

Socket05
Socket05

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

五大I/O模型比较

  • 同步I/O:将数据从内核缓冲区复制到应用进程缓冲区的阶段(第二阶段),应用进程会阻塞。
  • 异步I/O:第二阶段应用进程不会阻塞。

同步I/O包括阻塞式I/O、非阻塞式I/O、I/O复用和信号驱动I/O,它们的主要区别在第一个阶段。

非阻塞式I/O、信号驱动I/O和异步I/O在第一阶段不会阻塞。

Socket06
Socket06

I/O复用

select/poll/epoll都是I/O多路复用的具体实现,select出现的最早,之后是poll,再是epoll。

select

select允许应用程序监视一组文件描述符,等待一个或者多个描述符成为就绪状态,从而完成I/O操作。

  • fd_set使用数组实现,数组大小使用FD_SETSIZE定义,所以只能监听少于 FD_SETSIZE数量的描述符。有三种类型的描述符类型:readset、writeset、exceptset,分别对应读、写、异常条件的描述符集合。
  • timeout为超时参数,调用select会一直阻塞直到有描述符的事件到达或者等待的时间超过timeout。
  • 成功调用返回结果大于0,出错返回结果为-1,超时返回结果为0。

poll

poll的功能与select类似,也是等待一组描述符中的一个成为就绪状态。

select和poll的功能基本相同,不过在一些实现细节上有所不同。

  • select会修改描述符,而poll不会;
  • select的描述符类型使用数组实现,FD_SETSIZE大小默认为1024,因此默认只能监听少于1024个描述符。如果要监听更多描述符的话,需要修改FD_SETSIZE之后重新编译;而poll没有描述符数量的限制;
  • poll提供了更多的事件类型,并且对描述符的重复利用上比 select 高。
  • 如果一个线程对某个描述符调用了select或者poll,另一个线程关闭了该描述符,会导致调用结果不确定。

select和poll速度都比较慢,每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。

几乎所有的系统都支持select,但是只有比较新的系统支持poll。

epoll

epoll_ctl()用于向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上,通过回调函数内核会将I/O准备好的描述符加入到一个链表中管理,进程调用epoll_wait()便可以得到事件完成的描述符。

从上面的描述可以看出,epoll只需要将描述符从进程缓冲区向内核缓冲区拷贝一次,并且进程不需要通过轮询来获得事件完成的描述符。

epoll仅适用于Linux OS。

epoll比select和poll更加灵活而且没有描述符数量限制。

epoll对多线程编程更有友好,一个线程调用了epoll_wait()另一个线程关闭了同一个描述符也不会产生像select和poll的不确定情况。

应用场景

很容易产生一种错觉认为只要用epoll就可以了,select和poll都已经过时了,其实它们都有各自的使用场景。

  1. select应用场景

select的timeout参数精度为微秒,而poll和epoll为毫秒,因此select更加适用于实时性要求比较高的场景,比如核反应堆的控制。

select可移植性更好,几乎被所有主流平台所支持。

  1. poll 应用场景

poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。

  1. epoll 应用场景

只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。

需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。

需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试。

参考: