I/O 模型(同步/异步与阻塞/非阻塞区别)

前言

在进行网络编程的时候,听的最多的词汇就是同步、异步,阻塞与非阻塞,在进行多线程编程的时候,也会出现同步的概念,基于B/S架构的服务中也会有同步,异步调用,这些词汇都具体在指代什么?这个问题可能一般的都很难回答的清楚。

I/O 模型

在《Unix网络编程:卷一》中将网络I/O 模型分为如下五种:

  • 阻塞式I/O(blocking I/O)
  • 非阻塞式I/O (non-blocking I/O)
  • I/O多路复用 (I/O multiplexing)
  • 信号驱动式I/O (signal-driven I/O)
  • 异步I/O (asychronous I/O)

网络I/O的本质是socket的读取,socket在linux系统被抽象为流,I/O可以理解为对流的操作(读取或者写入),以读取为例,这个操作又分为两个阶段:

  1. 等待流数据准备(wating for the data to be ready)
  2. 从内核向进程复制数据(copying the data from the kernel to the process)

对于socket流而言:

  • 第一步通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区。
  • 第二步把数据从内核缓冲区复制到应用进程缓冲区。

因此对于一个网络I/O (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个I/O的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历上面两个阶段,记住这两点很重要,因为这些I/O模型的区别就是在两个阶段上各有不同的情况。

阻塞式I/O(blocking I/O)

阻塞I/O是最流行的I/O模型,在linux中,默认情况下所有的socket都是blocking。其大致的流程如图所示:

当用户进程调用了recvfrom这个系统调用,kernel就开始了I/O的第一个阶段:准备数据。对于网络I/O来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞,知道数据完成拷贝或者发生错误才返回,最常见的错误是系统调用被信号中断。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。所以,阻塞式I/O的特点就是在IO执行的两个阶段都被block了。

非阻塞式I/O (non-blocking I/O)

Linux下,可以通过设置socket使其变为non-blocking。在网络I/O时候,非阻塞I/O也会进行recvform系统调用,检查数据是否准备好,与阻塞I/O不一样,”非阻塞将大的整片时间的阻塞分成N多的小的阻塞, 所以进程不断地有机会’被’CPU光顾”,其大致流程如图所示:

从图中可以看出,当用户进程发出recvfrom系统调用时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个EWOULDBLOCK错误。从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送recvfrom系统调用。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。所以,用户进程其实是需要不断的主动询问kernel数据是否准备好。

I/O多路复用 (I/O multiplexing)

由于非阻塞的调用,轮询占据了很大一部分过程,轮询会消耗大量的CPU时间。结合前面两种模式。如果轮询不是进程的用户态,而是有人帮忙就好了,多路复用正好处理这样的问题。我们可以调用select、poll或者epoll,阻塞在这三个系统调用中的某一个之上,而不是阻塞在真正的I/O系统调用上。select调用是内核级别的,select轮询相对非阻塞的轮询的区别在于—前者会不断的轮询所负责的所有socket,当其中任何一个socket的数据准好了,就能返回进行可读,然后进程再进行recvform系统调用,将数据由内核拷贝到用户进程,当然这个过程是阻塞的。其大致的流程如图所示:

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。这个图和阻塞式I/O的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而阻塞式I/O只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。(所以,如果处理的连接数不是很高的话,使用select等的web server不一定比使用多线程加阻塞式I/O的web server性能更好,可能延迟还更大。select/poll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

在I/O多路复用模型中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket I/O给block。

信号驱动式I/O

首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用立即返回,进程将继续工作而不会阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。

注:信号驱动式I/O在实际中并不常用

异步I/O

异步I/O的流程大致如图所示:

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

各种I/O模型比较

上面介绍了5种I/O模型,现在回过头来回答最初的那几个问题:阻塞和非阻塞的区别在哪,同步I/O和异步I/O的区别在哪?还记得最开始提到的socket操作的两个阶段么:

  1. 等待流数据准备(wating for the data to be ready)
  2. 从内核向进程复制数据(copying the data from the kernel to the process)

在第一种阻塞式I/O模型中,第一阶段如果数据还没有准备好时,会导致进程的挂起,什么都干不了,因此它是阻塞的,而其他四种模型中,在第一阶段都不会导致进程挂起,因此是非阻塞的;而在第二阶段中,前四种I/O模型,都是阻塞的读取数据,直到数据从内核拷贝到用户进程完毕,在这个阶段因为进程一直在进行数据的I/O读取操作,因此它们都是同步的,而异步I/O,是两个阶段都在处理,并没有进程的参与,因此是异步的。

举个例子,比如一个用户去银行办理业务,他可以自己去排队办理,也可以叫人代办,办完之后再告知用户结果。对于要办理这个银行业务的人而言,自己去办理是同步方式,而别人代办完毕再告知则是异步方式。这两个概念与消息的通知机制有关。

再比如,当真正执行办理业务的人去办理银行业务时,前面可能已经有人排队等候。如果这个人一直从排队到办理完毕,中间都没有做过其他的事情,那么这个过程就是阻塞的,这个人当前只在做这么一件事情,而如果他选择出去逛逛再回来看看,还没完又继续逛,那这个过程就是非阻塞的。这两个概念与程序处理事务的状态有关。

而且在POSIX中将同步I/O与异步I/O给予了如下定义:

  • 同步I/O操作,导致请求进程阻塞,直到I/O完成
  • 异步I/O操作,不导致请求进程阻塞

两者的区别就在于同步IO做”I/O operation”的时候会将进程阻塞。按照这个定义,之前所述的前4种模型都属于同步I/O。有人可能会说,非阻塞式I/O并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”I/O operation”是指真实的I/O操作,就是例子中的recvfrom这个system call。非阻塞式I/O在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。而异步I/O则不一样,当进程发起I/O 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说I/O完成。在这整个过程中,进程完全没有被block。五种模型比较如图所示: