阻塞/非阻塞I/O以及Epoll

翻译自 Blocking I/O, Nonblocking I/O, And Epoll (eklitzke.org)

在这篇文章中,我想解释下当你使用非阻塞的IO(NO blocking I/O)的时候发生了什么,具体来说我会解释如下几点:

  • 使用系统调用fcntl将文件描述符设置为O_NONBLOCK的语义
  • 非阻塞I/O和异步I/O(asynchronous I/O)的区别
  • 为什么非阻塞I/O经常和I/O复用一起使用,如select,epoll,和kqueue

阻塞模式

在默认情况下,Unix系统的文件描述符都位于阻塞模式(Blocking Mode)中,这意味着相关的I/O调用如read(),write()或者connect()会产生阻塞。下面我举一个十分简单的模式来理解这一点,考虑以下场景:你想从一个常规的TTY程序(就是普通的没GUI的控制台程序)的标准输入流(stdin)中读数据,在这种场景下read()会一直阻塞直到有实际的数据达到(比如用户输入数据并按下回车以刷新缓冲区)。具体来说,内核会让当前进程处于睡眠状态直到stdin中有数据可读。这个行为对于其他的文件描述符也是成立的,举个例子,如果你试图从TCP socket中读取数据,那么read()函数会一直阻塞直到对面往这里发送的数据到达。

阻塞的问题在于需要并发处理的场景下,因为被阻塞的进程会被挂起(这时候就无法利用CPU的资源只能一直等待,很影响并发性)。下面有两种方法以解决这个问题:

  • 非阻塞模式
  • 使用IO端口复用的系统调用,如selectepoll

这两种是独立的解决方案,但是它们经常被放到一起使用。接下来我们来看一下这两种方法的区别以及为什么它们一般被放到一起用。

非阻塞模式

可以通过fcntl函数来给文件描述符添加O_NONBLOCK以使其进入非阻塞模式(nonblocking mode):

1
2
3
/* set O_NONBLOCK on fd */
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

在设置为非阻塞模式后,**I/O系统调用如readwrite(在没有数据时)会返回-1,且会把errno设置为EWOULDBLOCK**。

这样的方式虽然看起来有点意思,但是实际上没啥用。 仅使用这个原语没法有效地来对多个文件描述符进行 I/O操作。例如,假设我们有两个文件描述符并且想要一次读取它们。我们可以这样实现:在一个循环中检查每个文件描述符是否有数据,如果没有就让进程睡眠一段时间然后再次进行检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct timespec sleep_interval{.tv_sec = 0, .tv_nsec = 1000};
ssize_t nbytes;
for (;;) {
/* 尝试从fd1中读数据*/
if ((nbytes = read(fd1, buf, sizeof(buf))) < 0) {
//没有数据
if (errno != EWOULDBLOCK) {//等于就说明没数据,不等于就说明炸了
perror("read/fd1");
}
} else {
//有数据,开始处理
handle_data(buf, nbytes);
}

/* 尝试从fd2中读数据,同上*/
if ((nbytes = read(fd2, buf, sizeof(buf))) < 0) {
if (errno != EWOULDBLOCK) {
perror("read/fd2");
}
} else {
handle_data(buf, nbytes);
}
/*两个都没数据,睡一会儿再尝试*/
nanosleep(sleep_interval, NULL);
}

这样的方法确实能用,但是有一堆缺点:

  • 当数据来的非常慢的时候,程序会被频繁唤醒,这样很浪费CPU资源
  • 如果数据来的时候程序正好在睡眠,那么数据需要等一会才能被读到,这样会造成读延迟
  • 代码编写繁琐,扩展性差

为了解决这些问题,我们需要IO复用(I/O multiplexer)

I/O复用(select, epoll, kqueue等)

​ 类Unix系统提供了几个I/O复用的系统调用,如POSIX定义的select,Linux下的Epoll,BSD下的kqueue.这些I/O复用机制都在相同的模式下工作:

  1. 提供一个接口让内核知道当前程序感兴趣的一组文件描述符以及绑定在每种文件描述符上的I/O事件(通常是读取事件和写入事件)

  2. 提供一个接口,当程序调用这个接口时会一直阻塞直到它感兴趣的I/O事件发生,当IO事件发生后该接口能返回程序感兴趣的相关事件的相关数据(如类型,描述符等)

举个例子,例如,你可以告诉内核你只对文件描述符 X 上的读取事件、文件描述符 Y 上的读取和写入事件以及文件描述符 Z 上的写入事件感兴趣。这些I/O复用的接口并不关心文件描述符是处于阻塞模式还是非阻塞模式,处于阻塞模式的文件描述符在epoll或者select中照样能正常工作。如果**你只对 select 或 epoll 返回的文件描述符调用 read() write(),即使这些文件描述符处于阻塞模式,调用也不会阻塞(因为返回就意味着有数据)**。但是有一个例外,就是文件描述符的阻塞/非阻塞状态对polling的触发模式来说十分重要,下面我会进一步解释。

​ 并发的多路复用方法就是我所说的异步IO(asynchronous I/O),有时候人们也管这叫非阻塞IO,我认为这是不懂系统编程级别非阻塞这个概念所导致的。我建议保留术语“非阻塞”来指代文件描述符是否实际上处于非阻塞模式。

O_NONBLOCK 如何与 I/O 多路复用交互

假设我们正在使用带有阻塞文件描述符的 select 编写一个简单的套接字服务器。 为简单起见,在此示例中,我们只有要从中读取的文件描述符,它们位于 read_fds 中。 事件循环的核心部分将调用 select,然后为每个带有数据的文件描述符调用一次 read:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ssize_t nbytes;
for (;;) {
/*阻塞直到感兴趣的文件描述符有数据*/
if (select(FD_SETSIZE, &read_fds, NULL, NULL, NULL) < 0) {
perror("select");
exit(EXIT_FAILURE);
}
//数据到了
for (int i = 0; i < FD_SETSIZE; i++) {
if (FD_ISSET(i, &read_fds)) {
/*检测到读事件发生*/
if ((nbytes = read(i, buf, sizeof(buf))) >= 0) {
handle_read(nbytes, buf);
} else {
//阻塞模式下read返回值小于0说明read函数出错
perror("read");
exit(EXIT_FAILURE);
}
}
}
}

​ 这种方法不仅行得通而且还挺好的。 但是,如果 buf 很小,并且有大量的数据需要读,那么会发生什么呢? 具体来说,假设 buf 是一个 1024 字节大小的缓冲区,但同时有 64KB 的数据需要读。 为了处理这个请求,我们会在调用 select 后接着调用read()64 次。,这总共会产生128个系统调用。

​ 如果缓冲区大小太小,则必须多次调用读取,这是无法避免的(read的调用次数=数据量/缓冲区大小)。 但或许我们可以减少调用 select的次数? 在本例中的理想情况下,,我们想仅调用一次 select接口。

​ 事实上,我们可以通过将文件描述符置于非阻塞模式来实现这一想法。 基本思想是在循环中不断调用 read,直到它返回 EWOULDBLOCK。 就像下面的代码这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ssize_t nbytes;
for (;;) {
if (select(FD_SETSIZE, &read_fds, NULL, NULL, NULL) < 0) {
perror("select");
exit(EXIT_FAILURE);
}
for (int i = 0; i < FD_SETSIZE; i++) {
if (FD_ISSET(i, &read_fds)) {
for (;;) {//发现有数据直到读完位置
nbytes = read(i, buf, sizeof(buf));
if (nbytes >= 0) {
handle_read(nbytes, buf);
} else {
if (errno != EWOULDBLOCK) {//读完了
perror("read"); //出现问题
exit(EXIT_FAILURE);
}
break;
}
}
}
}
}

​ 在这个例子中(1024 字节的缓冲区,有 64KB 的数据需要读取),我们将执行 66 次系统调用:一次select调用,64次没有错误的read调用以及一次返回数据已读完的read调用。这种方案下的总调用次数几乎是上一个示例的一半,能极大提高性能和可伸缩性。

​ 这种方法的缺点是由于循环的存在,至少会产生一次额外的读取,因为它会一直调用read直到返回 EWOULDBLOCK,如果缓冲区足够大,能够一次性将数据读完的话,这种方法会多产生一次read调用。

边缘触发模式下的Polling

​ 非阻塞 I/O 还有一个更重要的用途:配合Epoll中的ET(edge-trigged,边沿触发)模式。 Epoll有两种模式:电平触发(Level-trigged,LT)和边沿触发两种模式。 LT模式是一种更简单的编程模型,类似于经典的 select 系统调用。 为了解释LT和ET的差异,我们需要了解 epoll 在内核中是如何工作的。

​ 假设你告诉内核你要使用 epoll 来监视你感兴趣的文件描述符上的I/O事件, 内核就会为每个文件描述符维护这些感兴趣的I/O事件列表。 当数据进入文件描述符(即相关的I/O事件发生)时,内核遍历该列表并使用事件列表中的文件描述符唤醒每个在 epoll_wait 中阻塞的进程。

​ 无论 epoll 处于何种触发模式,我上面概述的情况都会发生。LT模式和ET模式之间的区别在于程序调用epoll_wait 时内核中发生的情况不同。 在LT模式下,内核将遍历兴趣列表中的每个文件描述符,以查看它是否已经匹配兴趣条件。 例如,如果你在文件描述符 8 上注册了一个读取事件,当调用 epoll_wait时,内核将首先检查文件描述符 8 上否已经有数据,只要有数据那么 epoll_wait 就会返回而不会阻塞。

​ 相比之下,在边ET发模式下,内核会跳过此检查,并在程序调用epoll_wait时立即使进程进入睡眠状态。 这把所有的责任都交给了你,程序员,做正确的事情,并在等待之前完全读取和写入每个文件描述符的所有数据。

这种边缘触发模式使 epoll 成为 O(1)级别的 I/O 多路复用器:程序在调用epoll_wait后调会立即被挂起,当新数据进入内核时,内核会在 O(1) 时间内唤醒那个进程。

个人理解:

LT模式下,程序调用epoll_wait就相当于询问内核自己关心的I/O事件是否有数据,这个询问会一直阻塞到自己关心的I/O事件到来(如果没有数据会阻塞)

ET模式下, 程序调用epoll_wait后就就会被内核挂起,当有IO事件发生后内核会主动唤醒该进程

个人理解

LT模式就是只要当前缓冲区状态下有数据就会触发内核的epoll_wait,类似数字逻辑中的高电平触发,ET模式是有新数据到来的时候才会触发内核事件,类似数字逻辑中的的始终上升沿触发。

有一个更有效的例子来说明ET和LT触发模式之间的区别。假设你的读取缓冲区是 100 字节,并且该文件描述符有 200 字节的数据进入。

在LT模式下你首先调用epoll_wait,进程会一直阻塞到有数据再返回,然后你读了100byte,接着你会再次调用epoll_wait,内核会发现还有100byte,然后该调用再次返回,你又读了100byte,读完后你继续调用epoll_wait,然后进程又开始阻塞,直到有新的数据。

​ 在ET模式下,内核会让你立即进入睡眠状态。当有200byte到来后,内核会马上唤醒你,叫你读数据。不管你有没有读完,因为没有新的数据到了,内核也不会再通知你,所以你就必须在内核通知的那一次内读完所有数据.由于阻塞模式下的IO无法判定数据有没有读完,且如果read()在阻塞的时候有新的数据来到,那么当前进程就会错过内核的这次有数据的通知,然后整个IO程序就死锁了,因ET模式需要配合非阻塞模式。