本文的参考背景是linux环境的network IO, 主要分为四种IO Model:
- blocking IO
- nonblocking IO
- IO mutiplexing
- asynchronous IO(异步IO)
先说一下IO发生时涉及的对象和步骤,我们以read为例子,会经历两个阶段:
- 等待数据准备(waiting for data to be ready)
- 将数据从内核拷贝到进程中(Copying the data from the kernel to be the process)
阻塞IO
<br />当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段,准备数据。对于network io来说,很多时间数据在一开始还没有到达(比如,还没有收到一个完整的UDP包), 这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞,当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存。然后kernel返回结果,用户进程才解除block状态。重新运行起来。
几乎所有的程序员第一次接触网络编程都是从listen(), send(), recv()等接口开始的,这些接口都是阻塞型的,使用这些接口可以很方便的构建服务器/客户机的模型。

大部分的socket接口都是阻塞的,所谓阻塞型接口是指系统调用(一般是IO接口)不返回调用调用结果并让当前线程一直阻塞。只有当系统调用获得结果或者超时出错时才返回。实际上,除非特别指定,几乎所有的IO接口(包括socket接口)都是阻塞型的,这给网络编程带来了一个很大的问题,如在调用send()的同时,线程将被阻塞。在此期间,线程将无法执行任何运算或者响应任何的网络请求。
一个简单的改进方案是在服务器端使用多线程(或者多进程)。 多线程或者多进程的目的是让每个连接都拥有独立的线程(或者进程)。这样任何一个连接的阻塞都不会影响其他都连接。
非阻塞IO(non-blocking IO)
linux下,可以通过设置socket使其变为non-blocking, 当对一个non-blocking socket执行读操作的时候,流程是这个样子的:

从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它不会block用户进程,而是立刻就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它就可以再次发送read操作。一旦kernel中的数据准备好了,而且又再次收到了用户进程的system call, 那么它马上就将数据拷贝到了用户内存,然后返回。
所以,在非阻塞式IO中,用户进程其实是需要不断的主动询问kernel数据。在非阻塞状态下,recv()接口在被调用后立即返回,返回值代表了不同的含义:
- recv() 返回值大于0,表示接受数据完毕,返回值即是接受到的字节数
- recv() 返回0,表示连接已经正常断开
- recv() 返回-1,且error 等于EAGAIN, 表示recv操作还没雨执行完成
- recv() 返回-1. 且errno不等于EAGAIN,表示recv操作遇到系统错误errno
服务器的线程可以通过循环调用recv()接口,实现对所有连接的数据接受工作。但是上述模型是不被推荐的,因为循环调用recv()将大幅度推高CPU占有率。
多路复用IO(IO multiplexing)
IO multiplexing, 其实也就是我们常说的select/epoll。 select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断地轮询所负责的所有socket。当某个socket有数据到达了, 就通知用户进程,它的流程如图:

当用户进程调用了select, 那么整个进程会被block。 而同时,kernel会监听所有select负责的socket。当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
IO多路复用和blocking IO的图其实并没有太多区别,事实上还更差一些。因为这里需要使用两个系统调用(select和recvform)。而blocking IO调用了一个系统调用(recvform)。但是,用select的优势在于它可以同时处理多个connection。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
在多路复用模型中,对于每一个socket, 一般都设置为non-blocking。但是,如上图所示,整个用户的process其实是一直被阻塞的。只不过process是被select这个函数block, 而不是被socket IO给block。因此select()与非阻塞IO类似。
作为输出参数,readfds,writefds和exceptfds中保存了select()捕捉到的所有事件的句柄值。这种模型的特征在于每一个执行周期都会探测一次或者一组事件。一个特定事件会触发某个特定的响应。我们可以将这种模型归类为“事件驱动模型”。
但是这个模型依旧有很多问题,首先select()接口并不是实现事件驱动的最好选择。因为当需要探测的句柄值较大时,select()接口本身需要消耗大量的时间去轮询各个描述符。很多系统提供了更为高效的接口,类似epoll这样的接口更是被推荐,只返回发生IO事件的关联描述符。
异步IO
用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
用异步IO实现的服务器这里就不举例了,以后有时间另开文章来讲述。异步IO是真正非阻塞的,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要。
