网上关于同步、异步,阻塞和非阻塞的文章可谓数不胜数,但是很可惜的是,很多文章没有说清楚这四个词之间到底有啥区别和联系,经常有人把epoll、select等IO复用当成是异步IO。这篇文章希望能做一下区分。
IO操作一般都要经过系统内核,一个完整的IO操作可以分为两个阶段:

  1. 等待内核将数据准备好
  2. 将数据从内核缓冲区拷贝到用户缓冲区

区分一个IO是同步还是异步只看一点:如果这两个阶段中任意一个阶段发生阻塞,我们就称之为同步IO;相应的,如果这两个阶段都不发生阻塞,那么我们称之为异步IO。下面分别看一下五种IO模型。

1. 阻塞式IO

以套接字的读事件为例,默认情况下所有套接字都是阻塞的,调用套接字的recv函数,就会通知内核准备数据,内核准备数据完成后会拷贝到用户缓冲区。这两个步骤完成之前,用户程序阻塞在recv()处等待返回,完成后系统调用返回。显然这种IO是同步阻塞的,因为在第一阶段,第二阶段都会发生阻塞。

2. 非阻塞式IO

注意,这里的非阻塞实际上指的是在第一个阶段不阻塞。还是以读套接字为例,假设有一个与客户端建立好连接的套接字s,我们把这个套接字设置为非阻塞式,调用s.recv()调用通知内核准备数据,与阻塞式IO不同的是,用户程序并不会在这里阻塞住,如果内核还没有准备好数据,那么s.recv()函数会马上返回一个错误码,告诉用户程序,数据还没准备好。应用这种IO,通常我们会写一个while循环,不断的调用s.recv(),等到内核准备好数据后,s.recv()会执行第二阶段,拷贝数据到用户空间,这个拷贝过程是会发生阻塞的。那么这种IO显然也是同步的。

3. I/O 复用

IO复用有多种,比如select、poll和epoll等。以select为例,select可以同时监听多个套接字是否可读。进程调用select函数会通知内核准备数据,此时进程发生阻塞,等待某一个套接字变为可读。等到数据准备好,select返回可读条件,某一个套接字变为可读,此时,用户程序调用s.recv()函数从内核拷贝数据至用户空间。同样这个数据拷贝过程也会发生阻塞。整个过程中会有两个地方发生阻塞,第一个是select调用,第二个是复制数据至用户空间,也就是第二个阶段,因此这种IO也是一种同步IO。其他的IO复用如epoll、poll等处理过程跟select是一样的,也属于同步IO。

4. 信号驱动式IO

使用信号驱动式IO首先要开启信号驱动IO功能,然后通过sigaction系统调用注册一个信号处理函数,并通知内核准备数据。这个系统调用将立即返回,用户进程继续运行。当内核将数据准备好之后,内核会为该进程产生一个SIGIO信号,随后我们可以在信号处理函数中调用recv()读取数据至用户空间,也可以立即通知主循环,在主循环中读取数据。同样的,这个读取数据的过程一样会阻塞。因此这种IO也是同步IO。这种IO的好处在于等待数据准备好的过程中不会发生阻塞,主循环可以继续运行,只要等待信号处理函数的通知即可:既可以是通知数据一准备好被读取,也可以是数据已经读取完成可以被处理。

5. 异步IO

异步IO的工作机制是我们调用异步IO如aio_read,通知内核启动某个IO操作(读或者写),并让内核在整个操作(即一阶段二阶段都完成)完成后通知我们。可能有人会把这种模型与信号驱动式IO搞混淆,这两种IO的区别在于第二阶段:信号驱动式IO的第二阶段是需要用户进程来操作的,需要用户去调用recv来读数据,这个过程会发生阻塞,因此是同步IO;异步IO的第二个阶段是内核完成的,完成之后才会通知用户进程,此时用户进程可以直接操作数据,在这个过程中没有发生阻塞,因此是异步IO。
总结一下五种IO如下图