select, poll, epoll, 信号驱动 IO细讲
63章 其他备选的 IO 模型
整体概览
实际上 I/O 多路复用,信号驱动 I/O 以及 epool 都是用来实现同一个目标的技术——同时检查多个文件描述符,看它们是否准备好了执行 I/O 操作(准确地说,是看 I/O 系统调用是否可以非阻塞地执行)。文件描述符就绪状态的转化是通过一些 I/O 事件来触发的,比如输入数据到达,套接字连接建立完成,或者是之前满载的套接字发送缓冲区在 TCP 将队列中的数据传送到对端之后由了剩余空间。同时检查多个文件描述符在类似网络服务器的应用中很有用处,或者是那么必须同事检查终端以及管道或套接字输入的应用程序。
需要注意的是这些技术都不会执行实际的 I/O 操作。它们只是告诉我们某个文件描述符已经处于就绪状态了,这时需要调用其他的系统调用来完成实际的 I/O 操作。
水平触发和边缘触发
- 水平触发通知:如果文件描述符上可以非阻塞地执行 I/O 系统调用,此时任务它已经就绪。
- 边缘触发通知:如果文件描述符自上次状态检查以来有了新的 I/O 活动(比如新的输入),此时需要触发通知。
I/O 模式 | 水平触发 | 边缘触发 |
---|---|---|
select(), pool() | √ | |
信号驱动 I/O | √ | |
epool | √ | √ |
当采用水平触发通知时,我们可以在任意时刻检查文件描述符的就绪状态。这表示当我们确定了文件描述符处于就绪态时(比如存在输入数据),就可以对其执行一些 I/O 操作,然后重复检查文件描述符,看看是否仍然处于就绪态(比如还有更多的输入数据),此时我们就能执行更多的 I/O,以此类推。换句话说,由于水平触发模式允许我们在任意时刻重复检查 I/O 状态,没有必要每次当文件描述符就绪后需要尽可能多地执行 I/O (也就是尽可能多地读取字节,亦或是根本不去执行任何 I/O)。
与此相反的是,当我们采用边缘触发时,只有当 I/O 事件发生时我们才会收到通知。在另一个 I/O 事件到来前我们不会收到任何新的通知。另外,当文件描述符收到 I/O 事件通知时,通常我们并不知道要处理多少 I/O(例如有多少字节可读)。因此,采用边缘触发通知的程序通常要按照如下规则来设计。
- 在接收到一个 I/O 事件通知后,程序在某个时刻应该在相应的文件描述符上尽可能多地执行 I/O(比如尽可能多地读取字节)。如果程序没有这么做,那么就可能失去执行 I/O 的机会。因为直到产生另一个 I/O 事件位置,在此之前程序都不会再接收到通知了,因此也就不知道此时应该执行 I/O 操作。这将导致数据丢失或者程序中出现阻塞。
- 如果程序采用循环来对文件描述符执行尽可能多的 I/O ,而文件描述符又被置为可阻塞的,那么最终当没有更多的 I/O 可执行时,I/O 系统调用就会被阻塞。基于这个原因,每个被检查的文件描述符通常都应该置为非阻塞模式,在得到 I/O 事件通知后重复执行 I/O 操作,直到相应的系统调用(比如 write(), read()) 以错误码 EAGAIN 或 EWOULDBLOCK 的形式失联。
I/O 多路复用
select() 系统调用
系统调用 select()
会一直阻塞,直到一个或多个文件描述符集合成为就绪态。
1 |
|
参数 readfds
, writefds
以及 exceptfds
都是指向文件描述符集合的指针,所指向的数据类型是 fd_set,。这些参数按照如下方式使用。
readfds
是用来检测输入是否就绪的文件描述符集合writefds
是用来检测输出是否就绪的文件描述符集合exceotfds
是用来检测异常情况是否发生的文件描述符集合在 Linux 上,一个异常情况只有下面两种情况下发生:
- 连接到处于信号模式下的伪终端设备上的从设备状态发生了改变
- 流式套接字上接收到了带外数据
通常,数据类型 fd_set
以位掩码的形式来实现。但是是由下面四个宏来完成
1 |
|
文件描述符集合有一个最大容量限制,由常量 FD_SETSIZE 来决定。在 Linux 上,该常量的值为1024。
参数readfds
、writefds
和 exceptfds
所指向的结构体都是保存结果值的地方。在调用 select()
之前,这些参数指向的结构体必须初始化(通过FD_ZERO() 和 FD_SET()
),以包含我们感兴趣的文件描述符集合。之后 select()
调用会修改这些结构体,当 select()
返回时,它们包含的就是已处于就绪态的文件描述符集合了(值-结果参数)。(由于这些结构体会在调用中被修改,如果要在循环中反复调用select()
,我们必须保证每次都要重新初始化它们。)之后这些结构体可以通过 FD_ISSET()
来检查。
timeout 参数
参数 timeout
控制着 select()
的阻塞行为。该参数可指定为 NULL
,此时 select()
会一直阻塞。又或者是指向一个 timeval
结构体。
1 | struct timeval { |
如果结构体 timeval
的两个域都为0的话,此时 select()
不会阻塞,它只是简单地轮询指定的文件描述符集合,看看其中是否有就绪的文件描述符并立即返回。否则,timeout
将为 select()
指定一个等待时间的上限值。
当 timeout
设为 NULL
,或其指向的结构体字段非零时, select()
将阻塞直到有下列事件发生:
readfds
、writefds
或exceptfds
中指定的文件描述符中至少有一个成为就绪态;- 该调用被信号处理例程中断
timeout
中指定的时间上限已超时
select()
返回所在3个集合中被标记为就绪态的文件描述符总数。如果返回 -1 则是错误发生,包括 EBADF
和 EINTR
。如果返回 0 则说明超时。
示例程序
1 |
|
poll()
系统调用
系统调用 poll()
执行的任务同 select()
很相似。两者间主要的区别在于我们要如何制定待检查的文件描述符。在 select()
中,我们提供三个集合,在每个集合中标明我们感兴趣的文件描述符。而在 poll()
中我们提供一列文件描述符,并在每个文件描述符上标明我们感兴趣的事件。
1 |
|
参数 fds
列出了我们需要 poll()
来检查的文件描述符。该参数为 pollfd
结构体数组,其定义如下。
1 | struct pollfd { |
pollfd
结构体中的 events
和 revents
字段都是位掩码。调用者初始化 events
来指定需要为描述符 fd
做检查的事件。当 poll()
返回时,revents
被设定以此来表示该文件描述符上实际发生的事件。
输入事件相关位掩码
位掩码 | events 中的输入 | 返回到 revents | 描述 |
---|---|---|---|
POLLIN | √ | √ | 可读取非高优先级的数据 |
POLLRDNORM | √ | √ | 等同于 POLLIN |
POLLRDBAND | √ | √ | 可读取优先级数据(Linux 中不使用) |
POLLPRI | √ | √ | 可读取高优先级数据 |
POLLRDHUP | √ | √ | 对端套接字关闭 |
输出事件相关位掩码
位掩码 | events 中的输入 | 返回到 revents | 描述 |
---|---|---|---|
POLLOUT | √ | √ | 普通数据可写 |
POLLWRNORM | √ | √ | 等同于 POLLOUT |
POLLWRBAND | √ | √ | 优先级数据可写入 |
返回有关文件描述符附加信息的位掩码
位掩码 | events 中的输入 | 返回到 revents | 描述 |
---|---|---|---|
POLLERR | √ | 有错误发生 | |
POLLHUP | √ | 出现挂断 | |
POLLNVAL | √ | 文件描述符未打开 |
timeout 参数
- -1:阻塞直到有一个文件描述符达到就绪态或者捕获到一个信号
- 0: 不会阻塞,只是执行一次检查看看哪个文件描述符处于就绪态
- 大于0:至多阻塞 timeout 毫秒,知道 fds 列出的文件描述符中有一个达到就绪态,或者知道捕获到一个信号位置。
返回值
- -1:有错误
- 0:超时
- 大于0:表示数组 fds 重有用非零 revents 字段的 pollfd 结构体数量
示例程序
1 |
|
文件描述符何时就绪
select()
和 poll()
只会告诉我们 I/O 操作是否会阻塞,而不是告诉我们到底能否成功传输数据。
普通文件
代表普通文件的文件描述符总是被 select()
标记为可读和可写。对于 poll()
来说,则会在 revents
字段返回 POLLIN 和 POLLOUT 标志。原因如下:
read()
总是会立刻返回数据、文件结尾符或者错误write()
总是会立刻传输数据或者因出现某些错误而失败
终端和伪终端
在终端和伪终端上 select()
和 poll()
所代表的含义
条件或事件 | select() |
poll() |
---|---|---|
有输入 | r | POLLIN |
可输出 | w | POLLOUT |
伪终端对端调用 close() 后 |
rw | POLLHUP |
处于信包模式下的伪终端主设备检测到从设备端状态改变 | x | POLLPRI |
管道和 FIFO
select()
和 poll()
在管道或 FIFO 读端上的通知
条件或事件 | select() |
poll() |
---|---|---|
管道无数据,写端没打开 | r | POLLHUP |
管道有数据,写端打开 | r | POLLIN |
管道有数据,写端没打开 | r | POLLIN|POLLHUP |
select()
和 poll()
在管道或 FIFO 写端上的通知
条件或事件 | select() |
poll() |
---|---|---|
没有 PIPE_BUF 个字节空间,读端没打开 | w | POLLERR |
有 PIPE_BUF 个字节空间,读端打开 | w | POLLOUT |
有 PIPE_BUF 个字节空间,读端没打开 | w | POLLOUT|POLLERR |
套接字
条件或事件 | select() |
poll() |
---|---|---|
有输入 | r | POLLIN |
可输出 | w | POLLOUT |
在监听套接字上建立连接 | r | POLLIN |
接收到带外数据(只限 TCP) | x | POLL |
流套接字的对端关闭连接或执行了 shutdown(SHUT_WR) |
rw | POLLIN|POLLOUT|POLLRDHUP |
比较 select()
和 poll()
实现细节
在 Linux 内核层面,select()
和 poll()
都使用了相同的内核 poll
例程集合。这些例程有别于系统调用 poll()
本身。每个例程都返回有关单个文件描述符就绪的信息。这个就绪信息以位掩码的形式返回,其值同 poll()
系统调用中返回的 revents
字段中的比特值相关。 poll()
系统调用的实现包括为每个文件描述符调用内核 poll
例程,并将结果信息填到对应的 revents
字段中去。
API 之间的区别
select()
有文件描述符上限- 由于
select()
的参数fd_set
同事也是保存调用结果的地方,如果要在循环中重复调用select()
的话,我们必须每次都要重新初始化fd_set
。而poll()
通过独立的两个字段events
(针对输入)和revents
(针对输出)来处理,从而避免每次都要重新初始化参数。 select()
提供的超时精度比较高- 如果其中一个被检查的文件描述符关闭了,通过在对应的
revents
字段中设定POLLNVAL
标记,poll()
会准确高数我们是哪一个文件描述符关闭了。与之相反,select()
只会返回 -1,并设错误码为EBADF
。通过在描述符上执行 I/O 系统调用并检查错误码,让我们自己来判断哪个文件描述符关闭了。
性能
当满足如下两条中任意一条时,poll()
和 select()
将具有相似的性能表现。
- 待检查的文件描述符范围较小
- 有大量的文件描述符待检查,但是它们分布得很密集。
然而,如果被检查的文件描述符集合很稀疏的话,select()
和 poll()
的性能差异将变得非常明显,在这种情况下,后者更优。
select()
和 poll()
存在的问题
当检查大量的文件描述符时,这两个 API 都会遇到一些问题
- 每次调用
select()
或poll()
,内核都必须检查所有被指定的文件描述符,看它们是否处于就绪态。当检查大量处于密集范围,该挫折耗费时间将大大超过接下来的操作。 - 每次调用
select()
或poll()
,程序都必须传递一个表示所有需要被检查的文件描述符的数据结构到内核,内核检查过描述符后,修改这个数据结构并返回给程序。(此外,对于select()
来说,我们还必须在每次调用前初始化这个数据结构。)对于poll()
来说,随着待检查的文件描述符数量的增加,传递给内核的数据结构大小也会随之增加。当检查大量文件描述符时,从用户控件到内核控件来回拷贝这个数据结构将占用大量的 CPU 时间。对于select()
来说,这个数据结构的大小固定为FD_SETSIZE
,与待检查的文件描述符数量无关。 select()
或poll()
调用完成后,程序必须检查返回的数据结构中的每个元素,以此查明哪个文件描述符处于就绪态了。
信号驱动 IO
在信号驱动 I/O 中,当文件描述符上可执行 I/O 操作时,进程请求内核为自己发送一个信号。之后进程就可以执行任何其他的任务知道 I/O 就绪为止,此时内核会发送信号给进程。要使用信号驱动 I/O,程序需要按照如下步骤来执行。
为内核发送的通知信号安装一个信号处理例程。默认情况下,这个通知信号为
SIGIO
。设定文件描述符的属主,也就是当文件描述符上可执行 I/O 时会接收到通知信号的进程或进程组。通常我们让调用进程成为属主。设定属主可通过
fcntl()
的F_SETOWN
操作来完成:fcntl(fd, F_SETOWN, pid);
通过设定
O_NONBLOCK
标志使能非阻塞 I/O。通过打开
O_ASYNC
标志使能信号驱动 I/O。这可以和上一步合并为一个操作,因为它们都需要用到fcntl()
的F_SETFL
操作。1
2flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_ASYNC | ONONBLOCK);调用进程现在可以执行其他的任务了。当 I/O 操作就绪时,内核为进程发送一个信号,然后调用在第 1 步中安装好的信号处理例程。
信号驱动 I/O 提供的是边缘触发通知。这表示一旦进程被通知 I/O 就绪,它就应该尽可能多地执行 I/O (例如尽可能多地读取字节)。假设文件描述符是非阻塞式的,这表示需要在循环中执行 I/O 系统调用直到失败为止,此时错误码
EAGAIN
或EWOULDBLOCK
。
示例程序
1 |
|
何时发送『I/O 就绪』信号
终端和伪终端
当产生新的输入时
管道和 FIFO
- 读端:
- 数据写入到管道中(即使已经有未读取的输入存在)
- 管道的写端关闭
- 写端:
- 对管道的读操作增加了管道中的空间大小,因此现在可以写入 PIPE_BUF 个字节而不被阻塞
- 管道的读端关闭
套接字
- Unix 和 Internet 域下的数据报套接字
- 一个输入数据报达到套接字(即使已经有未读取的数据报正等待读取)
- 套接字上发生了异步错误
- Unix 和 Internet 域下的流式套接字
- 监听套接字上接收到新的连接
- TCP
connect()
请求完成,也就是 TCP 连接的主动端进入ESTABLISHED
状态。 - 套接字上接收到了新的输入(即使已经有未读取的输入存在)
- 套接字对端使用
showdown()
关闭了写连接(半关闭),或者通过close()
完全关闭 - 套接字上输出就绪
- 套接字上发生了异步错误
inotify 文件描述符
当 inotify 文件描述符称为可读状态时会产生一个信号——也就是由 inotify 文件描述符监视的其中一个文件上有事件发生时。
epoll 编程接口
epoll
API 的主要优点如下:
- 当检查大量的文件描述符时,
epoll
的性能延展性比select()
和poll()
高很多。 epoll
API 既支持水平触发也支持边缘触发。与之相反,select()
和poll()
只支持水平触发,而信号驱动 I/O 只支持边缘触发。- 可以避免复杂的信号处理流程(比如信号队列溢出时的处理)。
- 灵活性高,可以指定我们希望检查的事件类型(例如,检查套接字文件描述符的读就绪,写就绪或者两者同时指定)。
epoll
API 的核心数据结构称作 epoll
实例,它和一个打开的文件描述符相关联。这个文件描述符不是用来做 I/O 操作的,相反,它是内核数据结构的句柄,这些内核数据结构实现了两个目的。
- 记录了在进程中声明过的感兴趣的文件描述符列表 —— interest list (兴趣列表)
- 维护了处于 I/O 就绪态的文件描述符列表 —— ready list (就绪列表)
ready list 中的成员是 interest list 的子集。
epoll
API 由以下 3 个系统调用组成:
- 系统调用
epoll_create()
创建一个epoll
实例,返回代表该实例的文件描述符。 - 系统调用
epoll_ctl()
操作同epoll
实例相关联的兴趣列表。通过epoll_ctl()
,我们可以增加新的描述符到列表中,将已有的文件描述符从该列表中移除,以及修改代表文件描述符上事件类型的位掩码。 - 系统调用
epoll_wait()
返回与epoll
实例相关联的就绪列表中的成员。
创建 epoll
实例: epoll_create()
系统调用 epoll_create()
创建了一个新的 epoll
实例,其对应的兴趣列表初始化为空。
1 |
|
作为函数返回值,epoll_create()
返回了代表新创建的 epoll()
实例的文件描述符。这个文件描述符在其他几个 epoll
系统调用中用来表示 epoll
实例。当这个文件描述符不再需要时,应该通过 close()
来关闭。当所有与 epoll
实例相关的文件描述符都背关闭时,实例被销毁,相关的资源都返还给系统。
修改 epoll
的兴趣列表: epoll_ctl()
1 |
|
fd
指定要修改的文件描述符,它甚至可以是另一个epoll
实例的文件描述符,但是,这里fd
不能作为普通文件或目录的文件描述符op
指定需要执行的操作:EPOLL_CTL_ADD
,加入兴趣列表中EPOLL_CTL_MOD
,修改 fd 上设定的时间,需要用到由 ev 所指向的结构体中的信息EPOLL_CTL_DEL
,移除兴趣列表
ev
是指向结构体epoll_even
的指针,结构体定义如下:1
2
3
4struct epoll_event {
uint32_t event; /* epoll events (bit mask) */
epoll_data_t data; /* User data */
};其中 data 的字段如下
1
2
3
4
5
6typedef union epoll_data {
void *ptr; /* Pointer to user-defined data */
int fd; /* File descriptor */
uint32_t u32; /* 32-bit integer */
uint64_t u64; /* 64-bit integer */
};参数
ev
为文件描述符fd
所做的设置如下:- 结构体
epoll_event
中的events
字段是一个位掩码,它指定了我们为待检查的描述符fd
上感兴趣的事件集合。 data
字段是一个联合体,当描述符fd
稍后称为就绪态时,联合体的成员可用来指定传回给调用进程的信息。
- 结构体
使用 epoll_create()
和 epoll_ctl()
1 | int epfd; |
事件等待:epoll_wait()
系统调用 epoll_wait()
返回 epoll
实例中处于就绪态的文件描述符信息。单个 epoll_wait()
调用能返回多个就绪态文件描述符的信息。
1 |
|
参数 evlist
所指向的结构体数组重返回的是有关就绪态文件描述符的信息。数组evlist
的空间由调用者负责申请,所包含的袁术个数在参数 maxevents
中指定。
在数组 evlist
中,每个元素返回的都是单个就绪态文件描述符的信息。events
字段返回了在该描述符上一届发生的事件掩码。data
字段返回的是我们在描述符下使用 epoll_ctl
注册感兴趣的事件时在 ev.data
中所指定的值。注意,data
字段是唯一一个可获知同这个事件相关的文件描述符号的途径。因此,当我面调用 epoll_ctl()
将文件描述符添加到兴趣列表中时,应该要么将 ev.data.fd
设为文件描述符号,要么将 ev.data.ptr
设为指定包含文件描述符号的结构体。
参数 timeout
用来确定 epoll_wait()
的阻塞行为,有如下几种:
- -1: 一直阻塞
- 0: 执行一次非阻塞式检查
- >0: 调用将阻塞至多 timeout 毫秒,直到文件描述符上有事件发送,或者直到捕捉到一个信号为止
epoll
事件
当我们调用 epoll_ctl()
时可以在 ev.events
中指定的位掩码以及由 epoll_wait()
返回的 evlist[].events
中的值在下表给出:
epoll
中 events
字段的位掩码值
位掩码 | 作为 epoll_ctl() 的输入? |
作为 epoll_wait() 返回 |
描述 |
---|---|---|---|
EPOLLIN | √ | √ | 可读取非高优先级的数据 |
EPOLLPRI | √ | √ | 可读取高优先级的数据 |
EPOLLRDHUP | √ | √ | 套接字对端关闭 |
EPOLLOUT | √ | √ | 普通数据可写 |
EPOLLET | √ | 采用边缘触发事件通知 | |
EPOLLONESHOT | √ | 在完成事件通知之后禁用检查 | |
EPOLLERR | √ | 有错误发生 | |
EPOLLHUP | √ | 出现挂断 |
EPOLLONESHOT
标志
默认情况下,一旦通过 epoll_ctl()
的 EPOLL_CTL_ADD
操作将文件描述符添加到 epoll
实例的兴趣列表中后,它会保持激活状态(即,之后对 epoll_wait()
的调用会在描述符处于就绪态时通知我们) 直到我们显示地通知 epoll_ctl()
的 EPOLL_CTL_DEL
操作将其从列表中移除。如果我们希望在某个特定的文件描述符上只得到一次通知,那么可以在传给 epoll_ctl()
的 ev.events
中指定 EPOLLONESHOT
标志。如果指定了这个标志,那么在下一个 epoll_wait()
调用通知我们对应的文件描述符处于就绪态之后,这个描述符就会在兴趣列表中被标记为非激活态,之后的 epoll_wait()
调用都不会再通知我们有关这个描述符的状态了。如果需要,我们可以稍后通过 epoll_ctl()
的 EPOLL_CTL_MOD
操作重新激活对这个文件描述符的检查。
示例程序
1 |
|
深入探究 epoll
的语义
当我面通过 epoll_create()
创建一个 epoll
实例时,内核在内存中创建了一个新的 i-node
并打开文件描述(文件描述符表示的是一个打开文件的上下文信息(大小、内容、编码等与文件有关的信息),这部分实际是由内核来维护的。),随后在调用进程中为打开的这个文件描述分配一个新的文件描述符。同 epoll
实例的兴趣列表相关联的是打开的文件描述,而不是 epoll
文件描述符。这将产生下列结果:
- 如果我们使用
dup()
(或类似的函数)复制一个epoll
文件描述符,那么被复制的描述符所指代的epoll
兴趣列表和就绪列表同原始的epoll
文件描述符相同。若要修改兴趣列表,在epoll_ctl()
的参数epfd
上设定文件描述符可以是原始的也可以是复制的。 - 上条观点同意也适用于
fork()
调用之后的情况。此时子进程通过继承复制了父进程的epoll
文件描述符,而这个复制的文件描述符所指向的epoll
数据结果同原始的描述符相同。
当我们执行 epoll_ctl()
的 EPOLL_CTL_ADD
操作时,内核在 epoll
兴趣列表中添加了一个元素,这个元素同时记录了需要检查的文件描述符数量以及对应的打开文件描述的引用。 epoll_wait()
调用的目的就是让内核负责监视打开的文件描述符。这表示我们必须对之前的观点做改进:如果一个文件描述符是 epoll
兴趣列表中的成员,当关闭它后会自动从列表中删除。改进版应该是这样的:一旦所有指向打开文件描述的文件描述符都被关闭后,这个打开的文件描述符将从epoll
的兴趣列表中移除。这表示如果我们通过 dup()
(或类似的函数)或者 fork()
为打开的文件创建了描述符副本,那么这个打开的文件只会在原始的描述符以及所有其他的副本都被关闭时才会移除。
epoll
同 I/O 多路复用的性能对比
poll()
、select()
以及 epoll
进行 100000 次监视操作所花费的时间
被监视的文件描述符数量(N) | poll() 所占用的 CPU 时间(秒) |
select() 所占用的 CPU 时间(秒) |
epoll 所占用的 CPU 时间(秒) |
---|---|---|---|
10 | 0.61 | 0.73 | 0.41 |
100 | 2.9 | 3.0 | 0.42 |
1000 | 35 | 35 | 0.53 |
10000 | 990 | 930 | 0.66 |
为什么:
- 每次调用
select()
和poll()
时,内核必须检查所有在调用中指定的文件描述符。与之相反,当通过epoll_ctl()
指定了需要监视的文件描述符时,内核会在与打开的文件描述上下文相关联的列表中记录该描述符。之后每当执行 I/O 操作使得文件描述符成为就绪态时,内核就在epoll
描述符的就绪列表中添加一个元素。(单个打开的文件描述上下文中一次 I/O 事件可能导致与之相关的多个文件描述符成为就绪态。)之后的epoll_wait()
调用从就绪列表中简单地取出这些元素。 - 每次调用
select()
或poll()
时,我传递一个标记了所有待监视的文件描述符的数据结构给内核,调用返回时,内核将所有标记为就绪态的文件描述符的数据结构再传回给我们。与之相反,在epoll
中我们使用epoll_ctl()
在内核控件中建立一个数据结构,该数据结构会将待监视的文件描述符都记录下来。一旦这个数据结构建立完成,稍后每次调用epoll_wait()
时就不需要再传递任何与文件描述符有关的信息给内核了,而调用返回的信息中只包含那些已经处于就绪态的描述符。
边缘触发通知
epoll
API 还能以边缘触发方式进行通知——也就是说,会告诉我们自从上一次调用 epoll_wait()
以来文件描述符上是否已经有 I/O 活动了(或者由于描述符被打开了,如果之前没有调用的话)。使用 epoll
的边缘触发通知在语义上类似于信号驱动 I/O ,只是如果有多个 I/O 事件发生的话,epoll
会将它们合并成一次单独的通知,通过 epoll_wait()
返回,而再信号驱动 I/O 中则可能会产生多个信号。
采用 epoll
的边缘触发通知机制的程序基本框架如下:
- 让所有监视的文件描述符都称为非阻塞的。
- 通过
epoll_ctl()
构建epoll
的兴趣列表。 - 通过如下的循环处理 I/O 事件:
- 通过
epoll_wait()
取得处于就绪态的描述符列表 - 针对每一个处于就绪态的文件描述符,不断进行 I/O 处理知道相关的系统调用(例如
read(), write(), recv(), send()
或accept()
)返回EAGAIN
或EWOULDBLOCK
错误。
- 通过
当采用边缘触发通知时避免出现文件描述符饥饿现象
假设其中一个就绪态文件描述符又大量输入,如果使用非阻塞式读操作将所有输入都读取,那么此时就会有使其他的文件描述符处于饥饿状态的风险存在(即,在我们再次检查这些文件描述符是否处于就绪态并执行 I/O 操作前会有很长的一段处理时间)。该问题的一个解决方案是让应用程序维护一个列表,列表中存放着已经被通知为就绪态的文件描述符。通过一个循环按照如下方式不断处理。
- 调用
epoll_wait()
监视文件描述符,并将处于就绪态的描述符添加到应用程序维护的列表中。如果这个文件描述符已经注册到应用程序维护的列表中了,那么这次监视操作的超时时间应该设为较小的值或者是0。这样如果没有新的文件描述符成为就绪态,应用程序就可以迅速进行到下一步,去处理那些已经处于就绪态的文件描述符了。 - 在应用程序维护的列表中,只在那些已经注册为就绪态的文件描述符上进行一定限度的 I/O 操作(可能是以轮转调度(round-robin)方式循环处理,而不是每次
epoll_wait()
调用后从列表头开始处理 )。当相关的非阻塞 I/O 系统调用出现EAGAIN
或EWOULDBLOCK
错误时,文件描述符就可以在应用程序维护的列表中移除了。
参考资料
《Linux/Unix 系统编程手册》