本文迁移并整理自个人之前在博客园的博客。
在学习网络编程过程中,经常会把同步、异步、阻塞和非阻塞概念这几个概念搞混淆。本文就是要理清这几个概念,以便进一步的学习。
本文主要参考了《UNIX 网络编程卷 1:套接字联网API》(第 3 版),以后都将该书简称为 UNP。
我们先来看下同步 I/O 和异步 I/O 的区别。
同步 I/O 和异步 I/O 的区别
我们先来看一下操作 I/O 时所涉及的对象和阶段(这里我们以读操作为例)。涉及到的对象有两个,一个是调用这个 I/O 的进程(或线程)
,另一个是内核
。当一个读操作发生时,它会经历两个阶段:
- 等待数据就绪(可读)
- 将数据从内核拷贝到进程空间
这两个阶段很重要,因为各种I/O模型的区别就是在这两个阶段上各有不同的情况。
POSIX 对同步 I/O 和异步 I/O 操作的定义如下:
- 同步 I/O 操作(Synchronous I/O operation)导致请求进程阻塞,直到I/O操作完成。UNP 第 6 章中提到的 I/O 模型 —— 阻塞式 I/O 模型、非阻塞式 I/O 模型、I/O 复用模型和信号驱动式 I/O 模型都是同步 I/O 模型,因为其中
真正的 I/O 操作将阻塞进程
。 - 异步 I/O 操作(asynchronous I/O operation)不导致请求进程阻塞。
从以上定义可以看出,同步 I/O 和异步 I/O 操作的核心区别在于真正的 I/O 操作会不会阻塞进程。具体来说,同步 I/O 需要进程真正地去操作 I/O,而异步 I/O 则由内核在 I/O 操作完成后再通知应用进程结果。
我们来看一下 UNP 一书给出的对 5 种 I/O 模型的比较就更清楚了:
图片来源:UNP
由上图,我们可以知道,除了异步 I/O 模型,其他模型都会实际阻塞于真正的 I/O 操作。上图也说明了非阻塞式 I/O 虽然在检查阶段不会阻塞,但在文件描述符就绪(如可读)的时候是会阻塞的,这是它区别于异步 I/O 很重要的一点。
对于同步 I/O 操作,一个典型的例子就是 libevent 网络库。而对于异步 I/O 操作,比较有名的例子就是 Boost 库的 ASIO 库。ACE 库则包括了同步 I/O 及异步 I/O 两种方式。
同步 I/O
同步 I/O 操作包括了阻塞式 I/O 模型、非阻塞式 I/O 模型、I/O 复用模型和信号驱动式 I/O 模型。
阻塞式 I/O 模型
在 Unix/Linux 中,默认情况下所有的套接字都是阻塞的。
以数据报套接字为例,一个典型的读操作流程大概是这样:
图片来源:UNP
进程调用 recvfrom,其系统调用直到数据报到达且被复制到应用进程的缓冲区中或者发生错误才返回。最常见的错误是系统调用被信号中断。我们说进程在从调用 recvfrom 开始到它返回的整段时间内是被阻塞的。recvfrom 成功返回后,应用进程开始处理数据报。
非阻塞式 I/O 模型
进程把一个套接字设置成非阻塞是在通知内核:当所请求的 I/O 操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。
如下例:
图片来源:UNP
前三次调用 recvfrom 时没有数据可返回,因此内核转而立即返回一个 EWOULDBLOCK 错误。第四次调用 recvfrom 时已有一个数据报准备好,它被复制到应用进程缓冲区,于是 recvfrom 成功返回。我们接着处理数据。
当一个应用进程像这样对一个非阻塞描述符循环调用 recvfrom 时,我们称之为轮询(polling)。应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量 CPU 时间。
I/O 复用模型
关于 I/O 复用,知乎上有比较透彻的一个解释:“关于I/O多路复用(又被称为“事件驱动”),首先要理解的是,操作系统为你提供了一个功能,当你的某个 socket 可读或者可写的时候,它可以给你一个通知。这样当配合非阻塞的 socket 使用时,只有当系统通知我哪个描述符可读了,我才去执行 read 操作,可以保证每次 read 都能读到有效数据而不做纯返回 -1 和 EAGAIN 的无用功。写操作类似。操作系统的这个功能通过 select/poll/epoll/kqueue 之类的系统调用函数来使用,这些函数都可以同时监视多个描述符的读写就绪状况,这样,多个描述符的 I/O 操作都能在一个线程内并发交替地顺序完成,这就叫 I/O 多路复用,这里的‘复用’指的是复用同一个线程
。”
有了 I/O 复用(I/O multiplexing),我们就可以调用 select 或 poll,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的 I/O 系统调用上。下图概括展示了 I/O 复用模型:
图片来源:UNP
我们阻塞于 select 调用,等待数据报套接字变为可读。当 select 返回套接字可读这一条件时,我们调用 recvfrom 把所读数据报复制到应用进程缓冲区。
比较阻塞式 I/O 模型相比,I/O 复用并不显得有什么优势,事实上由于使用 select 需要两个而不是单个系统调用,I/O 复用还稍有劣势。不过 select 的优势在于可以等待多个描述符就绪
(与此相对应的方法是多线程 + 阻塞式I/O,即由每一个线程来调用阻塞式 I/O 系统调用)。
信号驱动式 I/O 模型
我们也可以用信号,让内核在描述符就绪时发送 SIGIO 信号给我们。这种模型称为信号驱动式 I/O(signal-driven I/O)。下图是其概要展示:
图片来源:UNP
我们首先开启套接字的信号驱动式 I/O 功能,并通过 sigaction 系统调用安装一个信号处理函数。该系统调用将立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好读写时,内核就为该进程产生一个 SIGIO 信号。我们随后既可以在信号处理函数中调用 recvfrom 读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。
无论如何处理 SIGIO 信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。
异步 I/O
异步 I/O(asynchronous I/O)由 POSIX 规范定义。一般来说,用于实现异步 I/O 的函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到进程的缓冲区)完成后通知我们。
这种模型与前面介绍的信号驱动模型的主要区别在于:信号驱动式 I/O 是由内核通知我们何时可以启动一个 I/O 操作,而异步 I/O 模型是由内核通知我们 I/O 操作何时完成。
下图给出了一个例子:
图片来源:UNP
我们调用 aio_read 函数(POSIX 异步 I/O 函数以 aio_ 或 lio_ 开头),给内核传递描述符、缓冲区指针、缓冲区大小(与 read 相同的三个参数)和文件偏移(与 lseek 类似),并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等待 I/O 完成期间,我们的进程不被阻塞。本例子中我们假设要求内核在操作完成时产生某个信号
。该信号直到数据已复制到应用进程缓冲区时才发生,这一点不同于信号驱动式 I/O 模型。