63 其他 I/O 模型
概览
- I/O 多路复用,
select()
和poll()
,检查大量文件描述符时性能不好 - 信号驱动 I/O
- Linux 特有的
epoll()
- POSIX 异步 I/O(AIO),在本书不讲(应该也不是一种通知模型)
Tip
Libevent 库为包括 select()
、poll()
、epoll()
、信号驱动 I/O、Solaris 专有的 /dev/poll 和 BSD 专有的 kqueue 接口在内的很多 I/O 方式提供了抽象层。
通知文件描述符就绪的两种方式是水平触发和边缘触发。
- 水平触发:文件描述符上可以非阻塞执行 I/O 调用,则认为已经就绪。
select()
/poll()
和epoll()
可以支持水平触发模型。 - 边缘触发:文件描述符自上次检查以来有了新的 I/O 活动,则认为需要通知。信号驱动 I/O 和
epoll()
可以支持水平触发模型。
书上还提到边缘触发时一般需要尽可能读取完所有字节,以免很长时间没有接收到下一次通知。比如:在循环中每次检查是否有数据可以读,如果有,则最多读取 1024 个字节,然后进入下一个循环,这样的操作就只能在水平触发模型上正常工作。这是因为只要状态没有改变,边缘触发模式下的通知系统就不会再次发送通知,如果只最多读取 1024 个字节,后面即便有数据没有读取完成,也不会有新的通知了。
本章讲的 I/O 通知模型都要和非阻塞的 I/O 结合使用。
I/O 多路复用
select()
select()
最早出现于 BSD 中,历史上的应用更加广泛。(poll
出现在 System V 中。)
SYNOPSIS
#include <sys/select.h>
typedef /* ... */ fd_set;
int select(int nfds, fd_set *_Nullable restrict readfds,
fd_set *_Nullable restrict writefds,
fd_set *_Nullable restrict exceptfds,
struct timeval *_Nullable restrict timeout);
参数
其中 exceptfds 是指要监控哪些 fd 的异常状态。这里的异常状态指:
- 连接到处于信包模式下的伪终端主设备上的从设备状态发生了改变。
- 流式套接字上面接收到了带外数据。
这里的 fd_set 是以位掩码的形式实现的,在 Linux 上通常有 1024(FD_SETSIZE
)位可用。
Tip
在我的 wsl 中,根据 cat /proc/sys/fs/nr_open
文件的结果,每个进程最大可以使用 1048576 个文件描述符。根据 cat /proc/sys/fs/file-max
的结果,我的 wsl 中整个系统能同时打开 9223372036854775807 个文件描述符。根据 ulimit -n
的结果,从 shell 启动的进程最多只能打开 1024 个文件描述符或 1048576 个文件描述符。(有趣的是,从 vscode 连接到 wsl 后 shell 中 ulimit -n
结果是 1048576,直接从 bash 登录到 wsl 就是 1024!见
63.1 验证单个进程能使用的最大文件描述符个数。)
在 https://www.kernel.org/doc/Documentation/sysctl/fs.txt 中对 nr_open
有这样的描述:默认值是 1024 * 1024(1048576),但是实际值要看 RLIMIT_NOFILE 资源限制。所以 ulimit -n
显示出来的限制应该是一个更强的限制。
参数 timeout 可以为空,表示不会超时。非空时按照给定时间指定超时。如果两个字段都为 0,那么表示本次只做查询工作,完全不会阻塞。
其他
当 select()
返回时,fd_set 中的内容会被改写,保存的是已经就绪的文件描述符。所以(尤其是在循环中)每次调用 select()
前必须重新初始化 fd_set。
select()
将在以下事件之一发生时返回:
- 被信号处理例程中断。
- 至少有一个文件描述符就绪。
- timeout 参数中指定的事件超时。
Note
在有些缺乏 nanosleep()
实现的老 UNIX 系统中,可以使用 select()
来代为实现 nanosleep()
,这个时候指定描述符集合的长度为 0,且三个监控集合都设置为 NULL
,睡眠时间就能在参数 timeout 中指定。
在 Linux 上,如果 timeout 参数非空,select()
返回时还会修改 timeout 为剩余超时时间。但是这种行为是实现定义的,大多数 UNIX 实现并不会去修改 timeout。SUSv3 允许系统不去修改 timeout,而且规定只有在 select()
调用成功的时候才能修改 timeout,但是 Linux 在没有用专有的系统调用 personality()
设置 STICKY_TIMEOUTS 标志时,即便是 select()
被信号中断,也会修改 timeout。
系统调用 select()
被中断是不能自动恢复的。
poll()
SYNOPSIS
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
和 select() 功能差不多,只是参数的格式不同,select() 使用的是集合,而 poll() 使用的是数组。
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
其中 events 字段可以由多个标志组成。通过将 fd 设置为负或者 events 设置为 0 可以使得一个 pollfd 结构体失效。为什么我们想要让一个结构体失效?因为我们要传入进去的是数组,临时移除可以直接在原地修改,而不需要重构数组,也不需要交换数组中元素的位置。
参数 timeout 和 select() 中的 timeout 类型不同。这里是整数,没有空的概念,因此用 -1 来表示永远不会超时、用 0 表示轮询、用正数来表示超时的毫秒数。因此 poll() 的超时精度是比 select() 低的。
正确理解文件描述符就绪的概念
select()
和 poll()
只会告诉我们文件描述符上的 I/O 是否会阻塞,但不会告诉我们是否能成功传输数据。
书上对伪终端 / 终端、FIFO / 管道、套接字有讨论,还是有一点复杂的,这里略。
select()
和 poll()
的比较
这两个系统调用在 Linux 上面都是由 poll 例程实现的,只是对外的接口不一样。
- 超时时间的设置上,
select()
的精度更高,支持微秒级别,poll()
的精度只能达到毫秒。 select
的 fd_set 参数同时也是保存结果的地方,所以在循环中使用时,每次都要重新初始化参数。 但是poll()
把结果和参数字段分开了。- 如果受检查的一个文件描述符被关闭了,
select()
只会反馈一个 EBADF 错误,而poll()
会在具体的文件描述符的 revents 字段中设置 POLLNVAL 标记。 select()
能使用的 fd_set 的大小是有限的,如果想要扩大大小需要重新编译程序。(但是默认的 1024 大小也够用了。)
感觉除了超时时间精度不够高之外,其他都是
poll()
稍微好一点?
在文件描述符非常多的时候,这两个 API 的性能都会下降。因为每次调用它们时,都需要从在用户区域和内核区域之间复制大量数据,而且内核必须检查所有被指定的文件描述符,这个过程比较耗时。作为对比,信号驱动 I/O 和 epoll() 可以让内核记录下来文件描述符信息,从而避免大量拷贝和检查。
信号驱动 I/O
信号驱动 I/O 的介绍
信号驱动 I/O 没有单独的系统调用,而是要使用 fcntl()
和 sigaction()
。
SIGIO 在不同系统上的默认行为不同,在有的系统中 SIGIO 默认会导致进程终止,有的系统上 SIGIO 默认会被忽略。因此一定要先安装好信号处理程序。
在信号处理函数中很多函数不能被安全调用,因此书中的例子是设置一个 volatile sig_atomic_t
标记变量。
充分利用信号驱动 I/O 的性能优势
从上面的步骤来看,还看不出来信号驱动 I/O 的性能为什么比 select()
/ poll()
更好。书中的例子也是只设置了一个文件描述符的通知。要充分利用信号驱动 I/O 的性能优势,还需要做额外的操作。
- 使用 Linux 专有的
fcntl()
的 F_SETSIG 操作来指定一个实时信号代替 SIGIO。(如果指定的信号为 0,那么会恢复默认的 SIGIO 信号。)这使得信号可以排队。 - 在使用
sigaction()
时指定 SA_SIGINFO 标志。这样信号处理函数就是三参数的int sig, siginfo_t *info, void *ucontext
,从siginfo_t
结构体中,我们可以得到具体的文件描述符。这使得我们能够使用相同的信号来监听多个文件描述符,并通过参数中的信息将它们区分开。
进一步优化信号获取
现在我们是在用异步的方式获取信号,我们还能进一步使用 sigwaitinfo()
或 sigtimedwait()
将异步操作转成同步操作。
虽然之前 select()
和 poll()
也是在循环中等待文件描述符就绪,而现在先将文件描述符就绪注册为信号通知、然后再去同步等待信号,多绕了个弯子,但是因为通知模型更高效,查询文件描述符就绪的过程也变得更高效了。
处理信号队列的溢出
一个设计良好的采用 F_SETSIG 来建立实时信号作为“I/O 就绪”通知的程序必须也要为信号 SIGIO 安装处理例程。如果发送了 SIGIO 信号,那么应用程序可以先通过
sigwaitinfo()
将队列中的实时信号全部获取,然后临时切换到select()
或poll()
,通过它们获取剩余的发生 I/O 事件的文件描述符列表。
在多线程程序中使用信号驱动 I/O
2.6.32 内核版本之后增加了 fcntl()
的 F_SETOWN_EX
操作,除了允许进程或进程组作为接收信号的对象之外,还允许指定一个具体的线程作为接收信号的对象!F_SETOWN_EX
操作的参数是以下结构体的指针:
struct f_owner_ex {
int type;
pid_t pid;
};
由于 F_SETOWN_EX
和 F_GETOWN_EX
使用正整数来表示进程组 ID(现在有了单独的 type
字段,不需要在返回值这样一个整数中包含多种信息),所以不会遇到 F_GETOWN
会遇到的、进程组 ID 小于 4096 时需要返回 -1 并用 errno
来包含进程组信息的问题。
信号驱动 I/O 效率会比 select()
和 poll()
高的原因
select()
和poll()
在每次调用时都在用户空间和内核空间传输大量数据。信号驱动 I/O 向内核注册信息,注册一次之后等通知就行了,不需要不停调用并传大量数据,因此节省了数据传输。select()
和poll()
每次指定参数之后,内核要对参数逐个做检查和登记,这个过程也很耗时。select()
和poll()
每次调用完成之后还需要遍历检查是哪些文件描述符就绪了(一个是扫描数组,一个是扫描三个集合)。而信号驱动 I/O 的数据直接从队列中拿,不需要遍历就能确定是哪一个文件描述符。(但是根据书上的说法,这个原因是次要原因,同系统调用监视 N 个文件描述符的时间相比微不足道。)
书中的实验表明:select()
和 poll()
的复杂度比线性还要差(这里的复杂度指相对于被监视文件描述符的数量 N)。
epoll()
在 Linux 2.6 新增,为 Linux 特有。它既支持水平触发又支持边缘触发,默认是水平触发。
内核为 epoll 实例维护了两个列表,一个是兴趣列表(interest list),一个是就绪列表(ready list)。由于会占用内核资源,每个用户能创建的 epoll 实例数量是有限的。/proc/sys/fs/epoll/max_user_watches 中记录了单个用户能创建的 epoll 实例个数(我这边 wsl 是 3599649),这个数是用 available low memory 的 4% 除以每个 epoll 实例内核项的开销计算出来的。
epoll()
API
Tip
查 epoll
只能查到 epoll(7),这是因为 epoll 系列 API 并不是直接以 epoll()
命名的,epoll
只是作前缀。要查函数接口需要查询具体的 epoll_create(2), epoll_create1(2), epoll_ctl(2), epoll_wait(2) 手册页面。
API 很简单,而且和 poll()
很相似:
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *_Nullable event);
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events,
int maxevents, int timeout,
const sigset_t *_Nullable sigmask);
int epoll_pwait2(int epfd, struct epoll_event *events,
int maxevents, const struct timespec *_Nullable timeout,
const sigset_t *_Nullable sigmask);
创建 epoll 实例的 API
epoll_create()
用来创建一个 epoll 实例,给定的参数 size 是初始化大小(运行中可以继续增长),但是新版本的 Linux 中已经不用(会忽略)这个参数了。而新系统调用 epoll_create1()
则是去掉了这个没用的参数,将参数换成了 flags,目前 flags 可以指定 EPOLL_CLOEXEC
。
当 epoll 实例不再被需要时,用 close(2) 关闭它。
修改监听的 API
epoll_ctl()
用来增加新的监控、修改已有的监控、删除已经存在的监控。其标志在 poll()
上基本都有对应。书中特别提到了两个 poll() 中没有的标志:
EPOLLONESHOT
表示只监控一次,就将文件描述符的监控禁用。注意不是移除,想要再次监控的时候需要使用EPOLL_CTL_MOD
操作而不是EPOLL_CTL_ADD
操作。EPOLLET
采用边缘触发,如果启用就和信号驱动 I/O 一样,状态没有改变就不会再次得到通知。
常用的监控标志包括 EPOLLIN
、EPOLLOUT
、EPOLLHUP
(对端 / 写入端关闭了,这个表示总是会被 epoll_wait()
启用,可以不用显式设置)、EPOLLERR
等。
epoll_ctl()
不能用于普通文件或者目录。
等待通知的 API
epoll_wait()
是书上介绍的 API。epoll_pwait()
的功能是等待某个 fd 就绪,或者一个不被屏蔽的信号出现,功能更加强大(类似于 pselect()
)。epoll_pwait2()
则是把以毫秒为单位的 timeout 改成了高精度的 timespec
类型,这个类型的精度是纳秒级别,比 select()
系统调用的 timeval
微秒级还要高。
和 select()
、poll()
一样,epoll_wait()
返回之后要确定是否被信号中断了。如果是被信号中断了,则可能需要重启。
epoll()
为什么性能好?
和信号驱动 I/O 的解释相似,见
信号驱动 I/O 效率会比 select()
和 poll()
高的原因。主要原因是它也将信息注册到内核,不用每次同步获取通知的时候都重新传输信息;次要原因是它将结果密集地写入到缓冲区(传出参数)中,并返回写入到缓冲区中的元素数量,不需要用户程序逐个检查找出就绪的文件描述符。
将 epoll()
和信号驱动 I/O 比较
epoll()
使用的是同步方式获取通知,比信号驱动 I/O 用信号通知再同步等待的过程要简单一些(当然信号驱动 I/O 也能用信号处理器异步处理通知,但是在信号处理器中能安全调用的函数非常少,而且异步处理信号的效率也不如同步处理高)。epoll()
的使用比信号驱动 I/O 简单一些,而且由于不需要信号参与,epoll()
也不会占用一个实时信号(也可以是 SIGIO,但是这样就没办法知道就绪的 fd 是哪一个了)。
不过,epoll()
API 在创建 epoll 实例(epoll instance)的时候,会占用一个 fd。系统范围内能创建的 epoll 实例数量也是有限的。
epoll()
的语义
epoll()
监控的是文件描述,而不是文件描述符。
例子:如果一个文件描述符被 dup()
或被子进程继承,旧文件描述符上注册有就绪通知,那么关闭旧的文件描述符并不会让 epoll()
停止发送关于它的通知——因为文件描述仍然是打开状态,在内核看来旧文件描述符关联的信息就是用户想要监听的文件描述。这很容易犯错!
边缘触发
epoll()
可以通过 EPOLLET
标志启用边缘触发。边缘触发通常和非阻塞 I/O 结合在一起使用,也就是设置所有被监听的文件描述符为非阻塞读写模式。
边缘触发可能会引发文件描述符饥饿,因为每次我们发现一个文件描述符就绪之后都需要尽可能读写。一种解决方式是收到边缘触发通知就将信息记录在自行维护的就绪列表 / 集合中,缺点是需要额外的代码。
感觉还是水平触发比较方便吧?信号驱动 I/O 使用边缘触发还是因为没有同步的机制,重复用信号发送就绪通知又不太可行。
同时等待信号和文件描述符就绪通知
pselect()
先用 sigaction()
再用 select()
,然后检查返回值查看是否被信号中断,这个方案有什么问题?如果在两个操作的间隙信号就已经到达,select()
就会阻塞而不会被信号中断,这样用户就不知道有信号到达了。
pselect()
则将两个操作原子化了,用一个信号掩码放行部分信号,还可以收到已经抵达的信号通知。此外 pselect()
的超时参数类型为 timespec
,有纳秒级别精度。SUSv3 还明确说明 pselect()
在返回时不能修改 timeout 参数。
epoll_pwait()
和epoll_pwait2()
同理。Linux 还增加了一个非标准的ppoll()
。
这些系统调用的 p 字母可能表示 POSIX。信号驱动 I/O 本身就是靠信号通知,所以不会有类似的问题。
Self-pipe 技巧:在没有 pselect()
的平台上做移植
用 pipe() 系统调用创建一个额外的管道,将读写两端都设置为非阻塞的。
不要用命名管道。用读写模式打开命名管道结果是未指定的。所以需要对同一个管道以只读、只写打开两次,而且打开的时候还要指定非阻塞,否则程序在打开管道的时候就会阻塞住。和匿名管道
pipe()
相比,这样的操作太过麻烦了。对感兴趣的信号注册信号处理函数,内容为向管道中非阻塞地写入一个字节(
write()
是异步信号安全的)。失败了也无所谓,因为失败就说明管道被写满了,而标准信号又是不排队的,一个和多个是一样的。在
select()
函数监听文件描述符时,增加之前创建的管道的读端文件描述符。如果在select()
调用之前信号就已经到达了,那么管道中一定有数据,select()
就会因为管道读端的就绪而返回,而不会阻塞。我们通过检查管道读端是否就绪,就知道信号是否产生。在进入下一轮之前,要把管道中的数据读干净。
这样是不是每个信号都要单独创建一个管道呢?就算每次可以往管道里面写入不同的字节表示不同信号,但是耐不住管道写满,这个时候还想写新的数据就只能往全局变量上面放了,想要做到异步信号安全维护起来就很麻烦。
总结:表格
select() 和 poll() | 信号驱动 I/O | epoll() | |
---|---|---|---|
是否同步 | 同步 | 异步(可以通过 sigtimedwait() 转同步) | 同步 |
触发方式 | 水平触发 | 边缘触发 | 水平触发 / 边缘触发 |
参数传递 | 每次都要传参 | 将信息注册到内核 | 将信息注册到内核 |
返回文件描述符的方式 | 数组 / 集合,需要遍历检查 | 通过实时信号的队列返回信息,只返回就绪的 fd | 通过传出参数(缓冲区)返回信息,只返回就绪的 fd |