在这篇文章中,我想准确解释当您使用非阻塞 I/O 时会发生什么。特别是,我想解释一下:
- 使用 fcntl 在文件描述符上设置 O_NONBLOCK 的语义
- 非阻塞 I/O 与异步 I/O 有何不同
- 为什么非阻塞 I/O 经常与 select、epoll 和 kqueue 等 I/O 多路复用器结合使用
- 非阻塞模式如何与 epoll 的边缘触发轮询交互
阻塞模式
默认情况下,Unix 系统上的所有文件描述符都以“阻塞模式”开始。这意味着像 read
、write
或 connect
这样的 I/O 系统调用可以阻塞。理解这一点的一个真正简单的方法是考虑当您从一个常规的基于 TTY 的程序中读取 stdin 上的数据时会发生什么。如果您在 stdin 上调用 read
,那么您的程序将阻塞,直到数据实际可用为止,例如当用户实际在键盘上键入字符时。具体来说,内核会将进程置于“睡眠”状态,直到标准输入上的数据可用。其他类型的文件描述符也是如此。例如,如果您尝试从 TCP 套接字读取,那么读取调用将阻塞,直到连接的另一端实际发送数据。
对于应该并发运行的程序来说,阻塞是一个问题,因为被阻塞的进程被挂起。有两种不同的、互补的方法可以解决这个问题:
- 非阻塞IO
- IO多路复用
这两种解决方案经常一起使用,但它们是解决此问题的独立策略只是经常同时使用。稍后我们将看到它们的区别以及为什么它们都被普遍使用。
非阻塞模式
通过将 O_NONBLOCK
添加到文件描述符上的 fcntl 标志集,将文件描述符置于“非阻塞模式”
:
1 | /* set O_NONBLOCK on fd */ |
从此时开始,该文件描述符被认为是非阻塞的。当发生这种情况时,本会阻塞的 I/O 系统调用(如 read
和 write
)将返回 -1,并且 errno 将设置为 EWOULDBLOCK。
这很有趣,但实际上它本身并不是很有用。仅仅使用这个原语,就没有有效的方法来对多个文件描述符进行 I/O。例如,假设我们有两个文件描述符并且想同时读取它们。这可以通过一个循环来完成,该循环检查每个文件描述符的数据,然后在再次检查之前暂时休眠:
1 | struct timespec sleep_interval{.tv_sec = 0, .tv_nsec = 1000}; |
上面的代码可行,但有很多缺点:
- 当数据输入速度非常慢时,程序会频繁且不必要地唤醒,很浪费 CPU 资源。
- 当数据确实进来时,如果程序处于休眠状态,则可能不会立即读取它,因此程序会存在延迟。
- 使用此模式处理大量文件描述符会变得很麻烦。
为了解决这些问题,我们需要一个 I/O 多路复用器。
I/O多路复用 (select, epoll, kqueue, etc.)
有一些 I/O 多路复用系统调用。I/O 多路复用调用的示例包括 select
(由 POSIX 定义)、Linux 上的 epoll
系列和 kqueue
系列。在 BSD 上。它们的工作方式基本相同:它们让内核知道对一组文件描述符感兴趣的事件(通常是读取事件和写入事件),然后它们会阻塞,直到发生感兴趣的事情。例如,您可能会告诉内核您只对文件描述符 X 上的读取事件、文件描述符 Y 上的读取和写入事件以及文件描述符 Z 上的写入事件感兴趣。
这些 I/O 多路复用系统调用通常不关心文件描述符是处于阻塞模式还是非阻塞模式。您可以将所有文件描述符保留在阻塞模式下,它们将与 select
或 epoll
一起正常工作。如果您只对 select
或 epoll
返回的文件描述符调用 read
和 write
即使这些文件描述符处于阻塞模式,调用也不会阻塞。但是有一个重要的例外! 文件描述符的阻塞或非阻塞状态对于边沿触发的epoll
很重要,下面会进一步解释的。
并发的多路复用方法就是我所说的“异步 I/O”(asynchronous I/O)。有时人们会称这种相同的方法为“非阻塞 I/O”(nonblocking I/O),我认为这是由于对“非阻塞”在系统编程级别的含义产生了混淆。我建议术语“非阻塞”(nonblocking I/O)来指代文件描述符实际上是否处于非阻塞模式。
非阻塞是如何和多路复用交互的?
假设我们正在使用带有阻塞文件描述符的 select
编写一个简单的套接字服务器。为简单起见,在此示例中,我们只有要读取的文件描述符,它们位于 read_fds
中。事件循环的核心部分将调用 select
,然后为每个包含数据的文件描述符调用一次 read
:
1 | ssize_t nbytes; |
这行得通,而且非常好。但是如果 buf 很小,数据很大时会发生什么?具体来说,假设 buf 是一个 1024 字节的缓冲区,但同时有 64KB 的数据进来。为了处理这个请求,我们将调用 select
和 read
64 次。总共有 128 次系统调用,损耗较大。
如果缓冲区大小太小,则必须多次调用 read
,这是无法避免的。但我们是否可以减少调用 select
的次数?理想情况下,在此示例中,我们可以调用 select
一次。
事实上,这是可行的,它是通过将文件描述符置于非阻塞模式
来实现的。基本思想是在循环中不断调用 read
,直到它返回 EWOULDBLOCK
。看起来像这样:
1 | ssize_t nbytes; |
在本例中(1024 字节缓冲区,传入 64KB 数据)我们将进行 66 次系统调用:
- select 将被调用一次
- read 将被调用 65次,其中 64 次且没有错误,最后一次返回
EWOULDBLOCK
。
现在好多了。这几乎是前一个示例的一半,这将大大提高性能和可扩展性。
这种方法的缺点是,由于新的循环,至少会发生一次额外的读取,因为它被调用直到返回 EWOULDBLOCK
。
假设读取缓冲区通常足够大,可以在一次读取调用中读取所有传入数据。然后在通常情况下通过循环将有三个系统调用而不是两个:select等待数据,read到真正读取到数据,然后再read得到EWOULDBLOCK
。
边缘触发Polling
非阻塞 I/O 还有一个更重要的用途:在 epoll
系统调用中使用边沿触发轮询。该系统调用有两种模式:水平触发和边缘触发。
假设您告诉内核您有兴趣使用 epoll
来监视某个文件描述符上的读取事件。内核为每个文件描述符维护一个这些兴趣的列表。当数据进入文件描述符时,内核遍历兴趣列表并唤醒在 epoll_wait
中阻塞的每个进程,事件列表中有该文件描述符。
无论 epoll
处于何种触发模式,我上面概述的都会发生。水平触发
和边缘触发
之间的区别在于调用 epoll_wait
时内核中发生的情况。在水平触发模式下,内核会遍历兴趣列表中的每个文件描述符,看它是否已经匹配兴趣条件。例如,如果你在文件描述符 8 上注册了一个读取事件,当调用 epoll_wait 时,内核将首先检查:文件描述符 8 是否已经有数据准备好读取?如果任何文件描述符与兴趣匹配,则 epoll_wait
可以无阻塞地返回。
相比之下,在边缘触发模式下,内核会跳过此检查并在调用 epoll_wait
时立即让进程进入睡眠状态。这把所有的责任都放在了你身上,程序员,做正确的事并在等待之前为每个文件描述符完全读取和写入所有数据。
这种边缘触发模式使 epoll
成为 O(1) 复杂度IO多路复用器:epoll_wait 调用将立即挂起,并且由于提前为每个文件描述符维护了一个列表,当新数据进入内核时,内核立即知道必须在 O(1) 时间内唤醒哪些进程。
这是水平触发和边缘触发之间差异的更具体的示例。假设您的读取缓冲区为 100 字节,并且该文件描述符有 200 字节的数据。假设你只调用 read 一次,然后再次调用 epoll_wait。还有 100 字节的数据已经可以被读取。在水平触发下,内核会注意到这一点并通知进程它应该再次调用 read。相比之下,在边缘触发下,内核会立即进入睡眠状态。如果另一方期待响应(例如,它发送的数据是某种 RPC),那么双方将“死锁”,因为服务器将等待客户端发送更多数据,而客户端将等待服务器发送响应。
要使用 边缘触发,您必须将文件描述符置于非阻塞模式。然后你必须调用 read
或 write
直到它们每次都返回 EWOULDBLOCK
。如果您不满足这些条件,您将错过来自内核的通知。但是这样做有一个很大的好处:每次调用 epoll_wait
都会更有效率,这对于并发级别极高的程序来说非常重要。如果您想了解更多细节,我强烈建议您阅读 epoll(7) 手册页