本文的参考背景是linux环境的network IO, 主要分为四种IO Model:

  • blocking IO
  • nonblocking IO
  • IO mutiplexing
  • asynchronous IO(异步IO)

先说一下IO发生时涉及的对象和步骤,我们以read为例子,会经历两个阶段:

  1. 等待数据准备(waiting for data to be ready)
  2. 将数据从内核拷贝到进程中(Copying the data from the kernel to be the process)

阻塞IO

  1. ![image.png](https://cdn.nlark.com/yuque/0/2021/png/2332713/1622126867998-575af493-c7d7-4a62-ac65-351d2cfc7689.png#clientId=u714f0a68-523a-4&from=paste&height=331&id=u5ab0a3f5&margin=%5Bobject%20Object%5D&name=image.png&originHeight=331&originWidth=552&originalType=binary&ratio=1&size=60306&status=done&style=none&taskId=u0f70d6c8-1c93-492d-b566-fe117fb8e7d&width=552)<br />当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段,准备数据。对于network io来说,很多时间数据在一开始还没有到达(比如,还没有收到一个完整的UDP包), 这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞,当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存。然后kernel返回结果,用户进程才解除block状态。重新运行起来。

几乎所有的程序员第一次接触网络编程都是从listen(), send(), recv()等接口开始的,这些接口都是阻塞型的,使用这些接口可以很方便的构建服务器/客户机的模型。

  1. ![image.png](https://cdn.nlark.com/yuque/0/2021/png/2332713/1622127274292-bcaadb3a-621f-4e67-96c2-d09a75eeb872.png#clientId=u714f0a68-523a-4&from=paste&height=431&id=u4c2586a5&margin=%5Bobject%20Object%5D&name=image.png&originHeight=431&originWidth=411&originalType=binary&ratio=1&size=86900&status=done&style=none&taskId=u1ec167c4-87f2-4a44-8ef3-922028a7037&width=411)

大部分的socket接口都是阻塞的,所谓阻塞型接口是指系统调用(一般是IO接口)不返回调用调用结果并让当前线程一直阻塞。只有当系统调用获得结果或者超时出错时才返回。实际上,除非特别指定,几乎所有的IO接口(包括socket接口)都是阻塞型的,这给网络编程带来了一个很大的问题,如在调用send()的同时,线程将被阻塞。在此期间,线程将无法执行任何运算或者响应任何的网络请求。

一个简单的改进方案是在服务器端使用多线程(或者多进程)。 多线程或者多进程的目的是让每个连接都拥有独立的线程(或者进程)。这样任何一个连接的阻塞都不会影响其他都连接。

非阻塞IO(non-blocking IO)

linux下,可以通过设置socket使其变为non-blocking, 当对一个non-blocking socket执行读操作的时候,流程是这个样子的:
image.png
从图中可以看出,当用户进程发出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有数据到达了, 就通知用户进程,它的流程如图:

  1. ![image.png](https://cdn.nlark.com/yuque/0/2021/png/2332713/1622129389222-0321a300-a65d-4975-ad7b-7526d5b0b627.png#clientId=u714f0a68-523a-4&from=paste&height=268&id=u62ffaae8&margin=%5Bobject%20Object%5D&name=image.png&originHeight=326&originWidth=609&originalType=binary&ratio=1&size=73360&status=done&style=none&taskId=uab6f1b6e-5e78-49ea-86cc-733defcdf24&width=500)

当用户进程调用了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是真正非阻塞的,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要。

参考

网络IO模型