「十年饮冰」

高性能IO模型浅析

服务器端编程经常需要构造高性能的 IO 模型,常见的 IO 模型有四种:

  • 同步阻塞 IO(Blocking IO):即传统的 IO 模型。
  • 同步非阻塞 IO(Non-blocking IO):默认创建的 socket 都是阻塞的,非阻塞 IO 要求 socket 被设置为 NONBLOCK。注意这里所说的 NIO 并非 Java 的 NIO(New IO)库。
  • IO 多路复用(IO Multiplexing):即经典的 Reactor 设计模式,有时也称为异步阻塞 IO,Java 中的 Selector 和 Linux 中的 epoll 都是这种模型。
  • 异步 IO(Asynchronous IO):即经典的 Proactor 设计模式,也称为异步非阻塞 IO。

同步(synchronous)和异步(asynchronous)的概念描述的是用户线程与内核的交互方式:同步是指用户线程发起 IO 请求后需要等待或者轮询内核 IO 操作完成后才能继续执行;而异步是指用户线程发起 IO 请求后仍继续执行,当内核 IO 操作完成后会通知用户线程,或者调用用户线程注册的回调函数。
阻塞(blocking)和非阻塞(non-blocking)的概念描述的是用户线程调用内核 IO 的操作方式:阻塞是指 IO 操作需要彻底完成后才返回到用户空间;而非阻塞是指 IO 操作被调用后立即返回给用户一个状态值,无需等到 IO 操作彻底完成。

再说一下 IO 发生时涉及的对象和步骤。对于一个 network IO(这里我们以 read 举例),它会涉及到两个系统对象,一个是调用这个 IO 的 process(or thread),另一个就是系统内核(kernel)。当一个 read 操作发生时,它会经历两个阶段:

  1. 等待数据准备(Waiting for the data to be ready)
  2. 将数据从内核拷贝到进程中(Copying the data from the kernel to the process)
    记住这两点很重要,因为这些 IO 模型的区别就是在两个阶段上各有不同的情况。

1. 同步阻塞 IO

在 linux 中,默认情况下所有的 socket 都是 blocking,一个典型的读操作流程大概是这样:
此处输入图片的描述
当用户进程调用了 recvfrom 这个系统调用,kernel 就开始了 IO 的第一个阶段:准备数据。对于 network io 来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的 UDP 包),这个时候 kernel 就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当 kernel 一直等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。
所以,blocking IO 的特点就是在 IO 执行的两个阶段(等待数据和拷贝数据两个阶段)都被 block 了。

几乎所有的程序员第一次接触到的网络编程都是从 listen()、send()、recv() 等接口开始的,这些接口都是阻塞型的。使用这些接口可以很方便的构建服务器 / 客户机的模型。下面是一个简单地”一问一答”的服务器。
此处输入图片的描述

我们注意到,大部分的 socket 接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是 IO 接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。
实际上,除非特别指定,几乎所有的 IO 接口 ( 包括 socket 接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用 send() 的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。
一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的CPU资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。通常,使用 pthread_create() 创建新线程,fork() 创建新进程。
我们假设对上述的服务器 / 客户机模型,提出更高的要求,即让服务器同时为多个客户机提供一问一答的服务。于是有了如下的模型。

此处输入图片的描述

在上述的线程 / 时间图例中,主线程持续等待客户端的连接请求,如果有连接,则创建新线程,并在新线程中提供为前例同样的问答服务。
很多初学者可能不明白为何一个 socket 可以 accept 多次。实际上 socket 的设计者可能特意为多客户机的情况留下了伏笔,让 accept() 能够返回一个新的 socket。下面是 accept 接口的原型:

1
int accept(ints,struct sockaddr *addr, socklen_t *addrlen);

输入参数 s 是从 socket(),bind() 和 listen() 中沿用下来的 socket 句柄值。执行完 bind() 和 listen() 后,操作系统已经开始在指定的端口处监听所有的连接请求,如果有请求,则将该连接请求加入请求队列。调用 accept() 接口正是从 socket s 的请求队列抽取第一个连接信息,创建一个与 s 同类的新的 socket 返回句柄。新的 socket 句柄即是后续 read() 和 recv() 的输入参数。如果请求队列当前没有请求,则 accept() 将进入阻塞状态直到有请求进入队列。
上述多线程的服务器模型似乎完美的解决了为多个客户机提供问答服务的要求,但其实并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。
很多程序员可能会考虑使用”线程池”或”连接池”。”线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。”连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如 websphere、tomcat 和各种数据库等。但是,”线程池”和”连接池”技术也只是在一定程度上缓解了频繁调用 IO 接口带来的资源占用。而且,所谓”池”始终有其上限,当请求大大超过上限时,”池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用”池”必须考虑其面临的响应规模,并根据响应规模调整”池”的大小
对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,”线程池”或”连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。

2. 同步非阻塞 IO

Linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读操作时,流程是这个样子:
此处输入图片的描述

从图中可以看出,当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。从用户进程角度讲 ,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,在非阻塞式 IO 中,用户进程其实是需要不断的主动询问 kernel 数据准备好了没有
非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。使用如下的函数可以将某句柄 fd 设为非阻塞状态。

1
int fcntl(fd, F_SETFL, O_NONBLOCK );

下面将给出只用一个线程,但能够同时从多个连接中检测数据是否送达,并且接受数据的模型。
此处输入图片的描述

在非阻塞状态下,recv() 接口在被调用后立即返回,返回值代表了不同的含义。如在本例中,

  • recv() 返回值大于 0,表示接受数据完毕,返回值即是接受到的字节数;
  • recv() 返回 0,表示连接已经正常断开;
  • recv() 返回 -1,且 errno 等于 EAGAIN,表示 recv 操作还没执行完成;
  • recv() 返回 -1,且 errno 不等于 EAGAIN,表示 recv 操作遇到系统错误 errno。

可以看到服务器线程可以通过循环调用 recv() 接口,可以在单个线程内实现对所有连接的数据接收工作。但是上述模型绝不被推荐。因为,循环调用 recv() 将大幅度推高 CPU 占用率;此外,在这个方案中 recv() 更多的是起到检测”操作是否完成”的作用,实际操作系统提供了更为高效的检测”操作是否完成”作用的接口,例如 select() 多路复用模式,可以一次检测多个连接是否活跃。

3. IO 多路复用

同步阻塞IO在等待数据就绪上花去太多时间,而传统的同步非阻塞IO虽然不会阻塞进程,但是结合轮询来判断数据是否就绪仍然会耗费大量的CPU时间。
多路IO复用提供了对大量文件描述符进行就绪检查的高性能方案。
select
select诞生于4.2BSD,在几乎所有平台上都支持,其良好的跨平台支持是它的主要的也是为数不多的优点之一。
select的缺点(1)单个进程能够监视的文件描述符的数量存在最大限制(2)select需要复制大量的句柄数据结构,产生巨大的开销 (3)select返回的是含有整个句柄的列表,应用程序需要遍历整个列表才能发现哪些句柄发生了事件(4)select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。相对应方式的是边缘触发。
poll
poll 诞生于UNIX System V Release 3,那时AT&T已经停止了UNIX的源代码授权,所以显然也不会直接使用BSD的select,所以AT&T自己实现了一个和select没有多大差别的poll。
poll和select是名字不同的孪生兄弟,除了没有监视文件数量的限制,select后面3条缺点同样适用于poll。
面对select和poll的缺陷,不同的OS做出了不同的解决方案,可谓百花齐放。不过他们至少完成了下面两点超越,一是内核长期维护一个事件关注列表,我们只需要修改这个列表,而不需要将句柄数据结构复制到内核中;二是直接返回事件列表,而不是所有句柄列表。
/dev/poll
Sun在Solaris中提出了新的实现方案,它使用了虚拟的/dev/poll设备,开发者可以将要监视的文件描述符加入这个设备,然后通过ioctl()来等待事件通知。
/dev/epoll
名为/dev/epoll的设备以补丁的方式出现在Linux2.4中,它提供了类似/dev/poll的功能,并且在一定程度上使用mmap提高了性能。
kqueue
FreeBSD实现了kqueue,可以支持水平触发和边缘触发,性能和下面要提到的epoll非常接近。
epoll
epoll诞生于Linux 2.6内核,被公认为是Linux2.6下性能最好的多路IO复用方法。
?

IO multiplexing 这个词可能有点陌生,但是如果我说 select / epoll,大概就都能明白了。有些地方也称这种 IO 方式为事件驱动 IO(event driven IO)。我们都知道,select / epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 IO。它的基本原理就是 select / epoll 这个 function 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。它的流程如图:
此处输入图片的描述

当用户进程调用了 select,那么整个进程会被 block,而同时,kernel 会”监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。
这个图和 blocking IO 的图其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select 和 recvfrom),而 blocking IO 只调用了一个系统调用(recvfrom)。但是,用 select 的优势在于它可以同时处理多个 connection。(多说一句:所以,如果处理的连接数不是很高的话,使用 select / epoll 的 web server 不一定比使用 multi-threading + blocking IO 的 web server 性能更好,可能延迟还更大。select / epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在多路复用模型中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket IO 给 block。因此 select() 与非阻塞 IO 类似。
用户线程使用 select 函数的伪代码描述为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{

select(socket);

while(1){
sockets= select();
for(socket in sockets) {
if(can_read(socket)){
read(socket,buffer);
process(buffer);
}
}
}
}

其中 while 循环前将 socket 添加到 select 监视中,然后在 while 内一直调用 select 获取被激活的 socket,一旦 socket 可读,便调用 read 函数将 socket 中的数据读取出来。
IO 多路复用模型使用了 Reactor 设计模式实现了这一机制。
此处输入图片的描述

如图所示,EventHandler 抽象类表示 IO 事件处理器,它拥有 IO 文件句柄 Handle(通过 get_handle 获取),以及对 Handle 的操作 handle_event(读/写等)。继承于 EventHandler 的子类可以对事件处理器的行为进行定制。Reactor 类用于管理 EventHandler(注册、删除等),并使用 handle_events 实现事件循环,不断调用同步事件多路分离器(一般是内核)的多路分离函数 select,只要某个文件句柄被激活(可读/写等),select 就返回(阻塞),handle_events 就会调用与文件句柄关联的事件处理器的 handle_event 进行相关操作
此处输入图片的描述

如图所示,通过 Reactor的方式,可以将用户线程轮询 IO 操作状态的工作统一交给 handle_events 事件循环进行处理。用户线程注册事件处理器之后可以继续执行做其他的工作(异步),而 Reactor 线程负责调用内核的 select 函数检查 socket 状态。当有 socket 被激活时,则通知相应的用户线程(或执行用户线程的回调函数),执行 handle_event 进行数据读取、处理的工作。由于 select 函数是阻塞的,因此多路 IO 复用模型也被称为异步阻塞 IO 模型。注意,这里的所说的阻塞是指 select 函数执行时线程被阻塞,而不是指 socket。一般在使用 IO 多路复用模型时,socket 都是设置为 NONBLOCK 的,不过这并不会产生影响,因为用户发起 IO 请求时,数据已经到达了,用户线程一定不会被阻塞。
用户线程使用 IO 多路复用模型的伪代码描述为:

1
2
3
4
5
6
7
8
9
void UserEventHandler::handle_event(){
if(can_read(socket)){
read(socket,buffer);
process(buffer);
}
}
{
Reactor.register(newUserEventHandler(socket));
}

用户需要重写 EventHandler 的 handle_event 函数进行读取数据、处理数据的工作,用户线程只需要将自己的 EventHandler 注册到 Reactor 即可。Reactor 中 handle_events 事件循环的伪代码大致如下。

1
2
3
4
5
6
7
8
9
10
Reactor::handle_events()
{
while(1)
{
sockets= select();
for(socket in sockets) {
get_event_handler(socket).handle_event();
}
}
}

事件循环不断地调用 select 获取被激活的 socket,然后根据获取 socket 对应的 EventHandler,执行器 handle_event 函数即可。
大部分 Unix/Linux 都支持 select 函数,该函数用于探测多个文件句柄的状态变化。下面给出 select 接口的原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**

*
nfds:select()函数监视的描述符数的最大值,一般取监视的描述符数的最大值+1,其上限设置在sys/types.h中有定义

*
readfds:select()函数监视的可读描述符集合

*
wtitefds:select()函数监视的可写描述符集合

*
errnofds:select()函数监视的异常描述符集合

*
timeout:select()函数监视超时结束时间,取NULL表示永久等待

*/


int select(int nfds,
fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)

这里,fd_set 类型可以简单的理解为按 bit 位标记句柄的队列,例如要在某 fd_set 中标记一个值为16的句柄,则该 fd_set 的第16个 bit 位被标记为1。具体的置位、验证可使用 FD_SET、FD_ISSET 等宏实现。在 select() 函数中,readfds、writefds 和 exceptfds 同时作为输入参数和输出参数。如果输入的 readfds 标记了16号句柄,则 select() 将检测16号句柄是否可读。在 select() 返回后,可以通过检查 readfds 有否标记16号句柄,来判断该”可读”事件是否发生。另外,用户可以设置 timeout 时间。
返回值:返回总的位数这些位对应已准备好的描述符,否则返回-1。相关宏操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FD_ZERO(int fd,
fd_set* fds) //
清空 fdset 与所有描述符的关系

FD_SET(int fd,
fd_set* fds) //
建立描述符 fd 与 fdset 的关系

FD_ISSET(int fd,
fd_set* fds) //
撤销描述符 fd 与 fdset 的关系

FD_CLR(int fd,
fd_set* fds) //
检查与 fdset 联系的描述符 fd 是否可以读写,返回非零表示可以读写

下面将重新模拟上例中从多个客户端接收数据的模型。
此处输入图片的描述

上述模型只是描述了使用 select() 接口同时从多个客户端接收数据的过程;由于 select() 接口可以同时对多个句柄进行读状态、写状态和错误状态的探测,所以可以很容易构建为多个客户端提供独立问答服务的服务器系统。如下图。

此处输入图片的描述

这里需要指出的是,客户端的一个 connect() 操作,将在服务器端激发一个”可读事件”,所以 select() 也能探测来自客户端的 connect() 行为。
上述模型中,最关键的地方是如何动态维护 select() 的三个参数 readfds、writefds 和 exceptfds。作为输入参数,readfds 应该标记所有的需要探测的”可读事件”的句柄,其中永远包括那个探测 connect() 的那个”母”句柄;同时,writefds 和 exceptfds 应该标记所有需要探测的”可写事件”和”错误事件”的句柄 ( 使用 FD_SET() 标记 )。
作为输出参数,readfds、writefds 和 exceptfds 中的保存了 select() 捕捉到的所有事件的句柄值。程序员需要检查的所有的标记位 ( 使用 FD_ISSET() 检查 ),以确定到底哪些句柄发生了事件。
上述模型主要模拟的是”一问一答”的服务流程,所以如果 select() 发现某句柄捕捉到了”可读事件”,服务器程序应及时做 recv() 操作,并根据接收到的数据准备好待发送数据,并将对应的句柄值加入 writefds,准备下一次的”可写事件”的 select() 探测。同样,如果 select() 发现某句柄捕捉到”可写事件”,则程序应及时做 send() 操作,并准备好下一次的”可读事件”探测准备。下图描述的是上述模型中的一个执行周期。
此处输入图片的描述

这种模型的特征在于每一个执行周期都会探测一次或一组事件,一个特定的事件会触发某个特定的响应。我们可以将这种模型归类为”事件驱动模型”。
相比其他模型,使用 select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。
但这个模型依旧有着很多问题。首先 select() 接口并不是实现”事件驱动”的最好选择。因为当需要探测的句柄值较大时,select() 接口本身需要消耗大量时间去轮询各个句柄。很多操作系统提供了更为高效的接口,如 linux 提供了 epoll,BSD 提供了 kqueue,Solaris 提供了 /dev/poll … 如果需要实现更高效的服务器程序,类似 epoll 这样的接口更被推荐。遗憾的是不同的操作系统特供的 epoll 接口有很大差异,所以使用类似于 epoll 的接口实现具有较好跨平台能力的服务器会比较困难。
其次,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。如下例,庞大的执行体1的将直接导致响应事件2的执行体迟迟得不到执行,并在很大程度上降低了事件探测的及时性。
此处输入图片的描述

幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动库有 libevent 库,还有作为 libevent 替代者的 libev 库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号(signal)等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。

4. 异步IO

Linux 下的 asynchronous IO 其实用得不多,从内核2.6版本才开始引入。先看一下它的流程:
此处输入图片的描述

用户进程发起 read 操作之后,立刻就可以开始去做其它的事。而另一方面,从 kernel 的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。
“真正”的异步 IO 需要操作系统更强的支持。在 IO 多路复用模型中,事件循环将文件句柄的状态事件通知给用户线程,由用户线程自行读取数据、处理数据。而在异步 IO 模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在 IO 完成后通知用户线程直接使用即可。
异步 IO 模型使用了 Proactor 设计模式实现了这一机制。
此处输入图片的描述
如图,Proactor 模式和 Reactor
模式在结构上比较相似,不过在用户(Client)使用方式上差别较大。Reactor 模式中,用户线程通过向 Reactor 对象注册感兴趣的事件监听,然后事件触发时调用事件处理函数。而 Proactor 模式中,用户线程将 AsynchronousOperation(读/写等)、Proactor 以及操作完成时的 CompletionHandler 注册到 AsynchronousOperationProcessor。AsynchronousOperationProcessor 使用 Facade 模式提供了一组异步操作 API(读/写等)供用户使用,当用户线程调用异步 API 后,便继续执行自己的任务。AsynchronousOperationProcessor 会开启独立的内核线程执行异步操作,实现真正的异步。当异步IO操作完成时,AsynchronousOperationProcessor 将用户线程与 AsynchronousOperation 一起注册的 Proactor 和 CompletionHandler 取出,然后将 CompletionHandler 与 IO 操作的结果数据一起转发给 Proactor,Proactor 负责回调每一个异步操作的事件完成处理函数 handle_event。虽然 Proactor 模式中每个异步操作都可以绑定一个 Proactor 对象,但是一般在操作系统中,Proactor 被实现为 Singleton 模式,以便于集中化分发操作完成事件。
此处输入图片的描述

如图所示,异步 IO 模型中,用户线程直接使用内核提供的异步 IO API 发起 read 请求,且发起后立即返回,继续执行用户线程代码。不过此时用户线程已经将调用的 AsynchronousOperation 和 CompletionHandler 注册到内核,然后操作系统开启独立的内核线程去处理 IO 操作。当 read 请求的数据到达时,由内核负责读取 socket 中的数据,并写入用户指定的缓冲区中。最后内核将 read 的数据和用户线程注册的 CompletionHandler 分发给内部 Proactor,Proactor 将 IO 完成的信息通知给用户线程(一般通过调用用户线程注册的完成事件处理函数),完成异步 IO。
用户线程使用异步 IO 模型的伪代码描述为:

1
2
3
4
5
6
7
8
9
10
11
12
void UserCompletionHandler::handle_event(buffer)
{

process(buffer);

}

{

aio_read(socket,newUserCompletionHandler);

}

用户需要重写 CompletionHandler 的 handle_event 函数进行处理数据的工作,参数 buffer 表示 Proactor 已经准备好的数据,用户线程直接调用内核提供的异步 IO API,并将重写的 CompletionHandler 注册即可。
到目前为止,已经将四个 IO 模型都介绍完了。现在回过头来回答最初的那几个问题:blocking 和 non-blocking 的区别在哪,synchronous IO 和 asynchronous IO 的区别在哪。
blocking 与 non-blocking。调用 blocking IO 会一直 block 住对应的进程直到操作完成,而 non-blocking IO 在 kernel 还在准备数据的情况下会立刻返回。
在说明 synchronous IO 和 asynchronous 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;
      两者的区别就在于 synchronous IO 做”IO operation”的时候会将 process 阻塞。按照这个定义,之前所述的 blocking IO,non-blocking IO,IO multiplexing 都属于 synchronous IO。有人可能会说,non-blocking IO 并没有被block啊。这里有个非常”狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的 recvfrom 这个系统调用。non-blocking IO 在执行 recvfrom 这个系统调用的时候,如果 kernel 的数据没有准备好,这时候不会 block 进程。但是当 kernel 中数据准备好的时候,recvfrom 会将数据从 kernel 拷贝到用户内存中,这个时候进程是被 block 了,在这段时间内进程是被 block 的。而 asynchronous IO 则不一样,当进程发起 IO 操作之后,就直接返回再也不理睬了,直到 kernel 发送一个信号,告诉进程说 IO 完成。在这整个过程中,进程完全没有被 block。
      各个 IO Model 的比较如图所示:
      此处输入图片的描述

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

谢照东 wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
坚持原创技术分享,您的支持将鼓励我继续创作!