I/O多路复用
介绍
I/O 多路复用允许我们同时检查多个文件描述符,看其中任意一个是否可执行 I/O 操作。其常用于并发编程。
常用的 I/O 多路复用操作有以下三种:
- select:早期的 I/O 多路复用
- poll:对 select 的改进版
- epoll:Linux 环境独有的,适合大规模并发的 I/O 多路复用机制
本人认为,小项目默认使用 poll,而在搭建于 Linux 系统的高并发的网络环境下,则推荐使用 epoll。
select
select() 系统调用允许程序监视多个文件描述符,等待其中一个或多个变为“就绪”状态(可读、可写或发生异常)。它在 I/O 多路复用中非常常用。
函数原型
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明
| 参数 | 描述 |
|---|---|
nfds |
三个文件描述符集合中最大的文件描述符数值 加 1。 这帮助内核提高效率,只检查小于该值的描述符。 |
readfds |
指向 fd_set 类型的集合,用于监视这些文件描述符是否可读。如果集合中有描述符可读, select() 返回。可设为 NULL 表示不关心可读事件。 |
writefds |
指向 fd_set 类型的集合,用于监视这些文件描述符是否可写。可设为 NULL。 |
exceptfds |
指向 fd_set 类型的集合,用于监视这些文件描述符是否发生异常。在 Linux 上,异常主要指: • 伪终端从设备状态改变(信包模式) • 流式套接字收到带外数据 可设为 NULL。 |
timeout |
指向 struct timeval 结构体,指定阻塞的超时时间。 • NULL:无限期阻塞。 • tv_sec 和 tv_usec 均为 0:非阻塞,立即返回(轮询)。 • 其他值:等待的最长时间。 |
文件描述符集合操作
三种描述符集合是用fd_set结构描述的,它是一个位掩码,通常通过以下宏操作:
// 清空集合
void FD_ZERO(fd_set *set);
// 将 fd 加入集合
void FD_SET(int fd, fd_set *set);
// 将 fd 从集合移除
void FD_CLR(int fd, fd_set *set);
// 测试 fd 是否在集合中(调用 select 后用于判断就绪)
int FD_ISSET(int fd, fd_set *set);
注意:
fd_set有大小限制,由常量FD_SETSIZE决定(Linux 上通常为 1024)。超出此范围的描述符无法通过标准fd_set处理。若需监视大量描述符,可考虑使用poll或epoll。
timeval 结构体
struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
};
select()的超时精度受系统软件时钟粒度限制,会向上取整。- 在 Linux 上,若
select()因就绪返回,且timeout非空,它会更新timeout为剩余时间。但该行为与系统实现相关,强调可移植的代码应每次调用前重新初始化timeout,并忽timeout返回值。
返回值
| 返回值 | 含义 |
|---|---|
-1 |
出错,常见错误码: • EBADF:集合中存在无效(比如未打开)的文件描述符。 • EINTR:被信号处理例程中断(不会自动重启)。 |
0 |
超时,没有任何文件描述符就绪。此时所有集合将被清空。 |
| >0 | 就绪的文件描述符总数。注意:如果一个描述符同时在多个集合中就绪,会被重复计数。之后应使用 FD_ISSET 遍历各集合,确定哪些描述符可操作。 |
使用要点
- 初始化集合:每次调用
select()前,必须用FD_ZERO和FD_SET重新设置感兴趣的描述符(因为select()会修改集合)。 nfds的设定:应设置为三个集合中最大描述符值 +1,而非FD_SETSIZE。- 信号中断:
select()可能被信号中断返回 -1,并设置errno为EINTR。某些系统可能不会自动重启,需手动处理。 - 可移植性:
- 不同 UNIX 实现可能不修改
timeout结构体,因此可移植代码不应依赖select()返回后timeout的值,并在每次使用select前将该结构体初始化。 - 在 Linux 上,若通过
personality()设置了STICKY_TIMEOUTS位,select()将不修改timeout。
- 不同 UNIX 实现可能不修改
poll
poll() 是另一种 I/O 多路复用系统调用,功能与 select() 类似,但在接口设计和文件描述符数量限制上有所改进。
函数原型
#include <poll.h>
int poll(struct pollfd[] fds, nfds_t nfds, int timeout);
参数:
| 参数 | 描述 |
|---|---|
fds |
指向 struct pollfd 数组的指针,每个元素描述一个要监视的文件描述符及其感兴趣的事件。 |
nfds |
数组 fds 中的元素个数(即要监视的文件描述符数量)。类型 nfds_t 通常为无符号整型。 |
timeout |
超时时间,单位为毫秒(ms)。 • -1:阻塞直到有文件描述符就绪或捕获到信号。 • 0:不阻塞,立即返回(轮询)。 • >0:最多阻塞 timeout 毫秒。 |
pollfd 结构体: |
struct pollfd {
int fd; /* 文件描述符,若为负值则忽略 events,revents 返回 0 */
short events; /* 感兴趣的事件掩码(由调用者设置) */
short revents; /* 实际发生的事件掩码(由内核返回) */
};
fd:要监视的文件描述符。如果fd为负值(通常取相反数),则对应的events被忽略,revents始终返回 0,可用于临时关闭对该描述符的监视。events:调用者设置,表示需要关注的事件类型(按位或组合)。revents:内核返回,表示实际发生的事件类型。当poll()返回后,可据此判断描述符上发生了什么事件。
事件掩码
下表列出了 events 和 revents 中可能出现的位掩码。其中:
- 前两组(输入/输出事件)可在
events中设置并返回在revents; - 第三组(错误/挂断/无效)只能出现在
revents中(在events中设置会被忽略)。
输入事件
| 掩码 | 可在 events 中设置 | 返回在 revents | 描述 |
|---|---|---|---|
POLLIN |
● | ● | 可读取普通数据(非高优先级)。 |
POLLRDNORM |
● | ● | 等同于 POLLIN(Linux 中)。 |
POLLRDBAND |
● | ● | 可读取优先级数据(Linux 中不使用)。 |
POLLPRI |
● | ● | 可读取高优先级数据(如带外数据)。 |
POLLRDHUP |
● | ● | 对端套接字关闭(Linux 专有,需定义 _GNU_SOURCE)。 |
输出事件
| 掩码 | 可在 events 中设置 | 返回在 revents | 描述 |
|---|---|---|---|
POLLOUT |
● | ● | 普通数据可写。 |
POLLWRNORM |
● | ● | 等同于 POLLOUT(Linux 中)。 |
POLLWRBAND |
● | ● | 优先级数据可写入(Linux 中不提供有效信息)。 |
返回信息
| 掩码 | 可在 events 中设置 | 返回在 revents | 描述 |
|---|---|---|---|
POLLERR |
● | 发生错误(如设备错误)。 | |
POLLHUP |
● | 出现挂断(如连接断开)。 | |
POLLNVAL |
● | 文件描述符未打开(无效)。 |
其余信息
| 掩码 | 可在 events 中设置 | 返回在 revents | 描述 |
|---|---|---|---|
POLLMSG |
Linux 中不使用(SUSv3 未指定)。 |
注意事项:
POLLIN和POLLRDNORM在 Linux 中实际是同义词;POLLOUT和POLLWRNORM也是同义词。POLLRDBAND和POLLWRBAND在 Linux 中基本无用(主要用于 STREAMS,Linux 未实现)。POLLRDHUP是 Linux 2.6.17 引入的专有标志,可用于检测对端关闭,需定义_GNU_SOURCE才能从<poll.h>中获得。- 要获得
POLLRDNORM、POLLRDBAND、POLLWRNORM、POLLWRBAND的定义,需定义_XOPEN_SOURCE测试宏。 - 若文件描述符在调用
poll()时已关闭,则revents中会返回POLLNVAL。
实际开发中,最常用的标志是:POLLIN、POLLOUT、POLLPRI、POLLRDHUP、POLLHUP 和 POLLERR。
返回值
| 返回值 | 含义 |
|---|---|
-1 |
出错,常见错误码 EINTR(被信号中断,不会自动重启)。 |
0 |
超时,没有任何文件描述符就绪。 |
>0 |
就绪的文件描述符数量。即数组 fds 中 revents 字段非 0 的 pollfd 元素个数。注意:与 select() 不同,poll() 对同一个描述符即使 revents 中有多个事件位,也只计一次。 |
与 select() 的主要区别
- 接口不同:
select()使用三个fd_set集合分别表示读、写、异常事件,且每次调用需重新设置;poll()使用pollfd数组,每个描述符独立指定事件,无需重新构造整个数组。 - 文件描述符数量限制:
select()受FD_SETSIZE(通常 1024)限制,无法直接监视更多描述符(除非重新编译内核或使用其他机制);poll()没有最大文件描述符数量的硬限制(仅受系统内存和进程资源限制)。 - 效率:在监视大量描述符时,两者都需要遍历整个集合,但
poll()不限制数量,且可避免select()中每次重新初始化集合的开销。 - 返回信息:
select()会修改传入的集合,返回后需用FD_ISSET逐个检查;poll()通过revents直接返回每个描述符上发生的事件,检查更直接。 - 超时精度:
select()使用struct timeval(秒/微秒),poll()使用毫秒。 - 事件种类:
poll()提供了更细粒度的事件标志(如POLLRDHUP),而select()只区分读、写、异常三类。
epoll
介绍
epoll(event poll)是 Linux 特有的 I/O 事件通知机制,自内核 2.6 起引入。它用于监视多个文件描述符上的 I/O 就绪状态,克服了传统 select() 和 poll() 在大量文件描述符下的性能瓶颈。
主要优点
- 高性能扩展性:监视大量文件描述符时,性能不会线性下降,而是仅与活跃事件数相关。
- 支持两种触发模式:水平触发(LT,默认)和边缘触发(ET)。
- 避免复杂信号处理:相比信号驱动 I/O,无需处理信号队列溢出等问题。
- 灵活的事件指定:可单独指定每个描述符感兴趣的事件类型(读、写等)。
核心数据结构
每个 epoll 实例在内核中对应一个打开的文件描述,并维护两个列表:
- 兴趣列表(interest list):注册的需要监视的文件描述符及其事件。
- 就绪列表(ready list):当前 I/O 就绪的文件描述符集。为兴趣列表子集。
epoll 系统调用
创建 epoll 实例
#include <sys/epoll.h>
int epoll_create(int size);
size:提示内核初始内部数据结构大小(Linux 2.6.8 后忽略)。- 返回值:代表 epoll 实例的文件描述符,需用
close()关闭。
Linux 2.6.27+ 新增 epoll_create1():
int epoll_create1(int flags);
flags:目前仅支持EPOLL_CLOEXEC(设置执行时关闭标志)。
修改兴趣列表
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev);
epfd:epoll 实例的文件描述符。op:操作类型EPOLL_CTL_ADD:添加fd到兴趣列表。EPOLL_CTL_MOD:修改fd上的事件。EPOLL_CTL_DEL:从兴趣列表移除fd(ev忽略)。
fd:要操作的文件描述符(不能是普通文件或目录,否则返回EPERM)。ev:指向epoll_event结构体的指针,定义事件和用户数据。
struct epoll_event {
uint32_t events; /* 感兴趣的事件掩码 */
epoll_data_t data; /* 用户数据联合体 */
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
events:位掩码,取值见表 63-8。data:用户自定义数据,通常设置data.fd = fd,或者包含 fd 的结构体的指针,以便在就绪时识别描述符。
等待事件
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
evlist:由调用者分配的数组,用于返回就绪事件。maxevents:evlist的大小。timeout:-1:无限阻塞。0:非阻塞检查。>0:阻塞最多timeout毫秒。
- 返回值:就绪事件个数;0 表示超时;-1 表示错误。
每个返回的 epoll_event 中:
events:实际发生的事件掩码。data:与注册时相同的用户数据。
事件掩码
| 掩码 | 输入 | 输出 | 描述 |
|---|---|---|---|
EPOLLIN |
● | ● | 可读(普通数据) |
EPOLLPRI |
● | ● | 高优先级数据可读 |
EPOLLRDHUP |
● | ● | 套接字对端关闭(Linux 2.6.17+) |
EPOLLOUT |
● | ● | 可写 |
EPOLLET |
● | 启用边缘触发 | |
EPOLLONESHOT |
● | 单次通知后禁用 | |
EPOLLERR |
● | 错误发生(自动监视) | |
EPOLLHUP |
● | 挂断(自动监视) |
EPOLLONESHOT:描述符在收到一次事件后从兴趣列表中暂时禁用,需用EPOLL_CTL_MOD重新激活。EPOLLERR和EPOLLHUP无需在events中指定,总是会被监视并返回。
深入 epoll 语义(兴趣列表到底关联什么)
- epoll 兴趣列表关联的是打开的文件描述(file description),也就是打开文件表的条目,而非文件描述符本身。
- 如果通过
dup()或fork()复制了 epoll 文件描述符,所有副本指向同一个 epoll 实例(共享兴趣列表和就绪列表)。 - 当通过
EPOLL_CTL_ADD注册一个文件描述符时,内核记录的是该描述符对应的打开文件描述。只有当所有指向该打开文件描述的文件描述符都关闭后(引用计数清零了),它才会从兴趣列表中移除。
int fd1, fd2;
fd1 = open(path, O_RDONLY);
fd2 = dup(fd1);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev);
close(fd1); // fd1 关闭,但打开文件描述仍被 fd2 引用
// 之后 epoll_wait() 仍可能通知 fd1(实际是同一个打开文件描述)
epoll 边缘触发
启用 ET:在 epoll_ctl 的 events 中添加 EPOLLET 标志。
使用 ET 的基本框架:
- 将所有监视的描述符设为非阻塞。
- 循环处理:
- 调用
epoll_wait获取就绪列表。 - 对每个就绪描述符,循环执行 I/O(如
read/write)直到返回EAGAIN或EWOULDBLOCK。
- 调用
注意事项
max_user_watches限制:每个用户注册到 epoll 实例的文件描述符总数受/proc/sys/fs/epoll/max_user_watches限制(默认根据内存计算)。- 线程安全:可在多线程程序中用
epoll_ctl向其他线程正在epoll_wait的实例添加描述符,修改立即生效。 - ET 与信号驱动 I/O:ET 语义类似于信号驱动 I/O,但 epoll 会将多个事件合并为一次通知。
I/O 信号驱动
介绍
信号驱动 I/O 是一种 I/O 事件通知机制:进程请求内核在文件描述符可执行 I/O 时发送信号,之后进程可执行其他任务,直到收到信号后再处理 I/O。这种方式与 select()/poll() 的主动检查不同,属于被动通知。
使用步骤
- 安装信号处理例程 为内核发送的通知信号安装一个信号处理例程。默认情况下,这个通知信号为 SIGIO。
- 设定文件描述符属主
使用fcntl(fd, F_SETOWN, pid)指定接收信号的进程或进程组。 通常pid设为调用进程的 PID。fcntl(fd,F_SETOWN, pid); - 启用非阻塞 I/O 通过设定 O_NONBLOCK 标志使能非阻塞 I/O。
- 启用信号驱动 I/O
通过打开 O_ASYNC 标志使能信号驱动 I/O。
这可以和上一步合并为一个操作,因为它们都需要用到 fcntl()的 F_SETFL 操作。
flags = fcntl( fd, F_GETFL);/* Get current flags */ fcntl( fd, F_SETFL, flags | O_ASYNC | O_NONBLOCK); - 进程继续执行其他任务 调用进程现在可以执行其他的任务了。 当 I/O 操作就绪时,内核为进程发送一个信号,然后调用在第 1 步中安装好的信号处理例程。
- 在信号处理例程中执行 I/O 信号驱动 I/O 提供边缘触发通知,因此应尽可能多地执行 I/O(例如循环读/写)。 假设文件描述符是非阻塞式的,这表示需要在循环中执行 I/O 系统调用直到失败为止,此时错误码为 EAGAIN 或 EWOULDBLOCK。
何时发送“I/O 就绪”信号
| 文件类型 | 发送信号的条件 |
|---|---|
| 终端/伪终端 | 有新输入时(即使之前数据未读完);终端上出现文件结尾(EOF)时;伪终端从设备写就绪(2.4.19+) |
| 管道/FIFO | 读端:数据写入管道;写端关闭 写端:读操作增加空闲空间(可写入 PIPE_BUF 字节);读端关闭 |
| 套接字 | 数据报:新数据报到达;异步错误 流式:监听套接字新连接; connect() 完成;新输入;对端关闭;输出就绪;异步错误 |
| inotify | 监视的文件上有事件发生,文件描述符变为可读 |
优化信号驱动 I/O
信号驱动 I/O 的性能优势在于事件数量扩展而非文件描述符数量扩展。要充分利用,需执行:
- 使用实时信号代替 SIGIO
通过fcntl(fd, F_SETSIG, signum)指定一个实时信号(如SIGRTMIN)作为通知信号,从而支持信号排队,避免SIGIO的丢失问题。(需定义_GNU_SOURCE) - 安装带
SA_SIGINFO标志的信号处理例程struct sigaction sa; sa.sa_flags = SA_SIGINFO; sa.sa_sigaction = handler; sigaction(signum, &sa, NULL);
这样处理函数可以接收 siginfo_t 结构体,其中包含事件发生的文件描述符和事件类型。
siginfo_t 中的关键字段
si_signo:触发的信号si_fd:发生 I/O 事件的文件描述符si_code:事件类型代码(见下表)si_band:与poll()的revents相同的位掩码
si_code 与 si_band 的对应关系
si_code |
si_band 掩码 |
描述 |
|---|---|---|
| POLL_IN | POLLIN | POLLRDNORM |
有输入或文件结尾 |
| POLL_OUT | POLLOUT | POLLWRNORM | POLLWRBAND |
可输出 |
| POLL_MSG | POLLIN | POLLRDNORM | POLLMSG |
有输出消息(未使用) |
| POLL_ERR | POLLERR |
I/O 错误 |
| POLL_PRI | POLLPRI | POLLRDNORM |
高优先级输入 |
| POLL_HUP | POLLHUP | POLLERR |
挂断 |
信号队列溢出处理
实时信号队列有上限(可通过 /proc/sys/kernel/rtsig-max 等调整)。若队列溢出,内核将回退到发送标准 SIGIO(不排队,不带 siginfo_t)。程序应同时为 SIGIO 安装处理例程,并在收到 SIGIO 时切换到 select()/poll() 以获取剩余事件。
同步接收信号(避免信号处理例程)
可阻塞实时信号,然后使用 sigwaitinfo() 或 sigtimedwait() 接收排队的信号,直接获得 siginfo_t 结构体。这种方式将事件处理变为同步,但效率仍高于 select()/poll()。
多线程程序中的信号驱动 I/O
Linux 2.6.32 引入了 fcntl() 的 F_SETOWN_EX 和 F_GETOWN_EX 操作,允许指定接收信号的线程(而非仅限于进程或进程组)。
struct f_owner_ex {
int type; /* 类型:F_OWNER_PID, F_OWNER_PGRP, F_OWNER_TID */
pid_t pid; /* 进程ID、进程组ID 或 线程ID */
};
F_OWNER_PID:指定进程 ID(正整数)F_OWNER_PGRP:指定进程组 ID(正整数)F_OWNER_TID:指定线程 ID(clone()或gettid()返回值)F_GETOWN_EX用于获取当前设置。
注意事项
- 信号安装时机:务必在启用信号驱动 I/O 前安装信号处理例程,否则默认
SIGIO可能终止进程。 - 历史命名:
O_ASYNC曾是“异步 I/O”的同义词,但现代 POSIX AIO 指代另一套接口。 - 兼容性:
F_SETSIG和F_GETSIG是 Linux 专有,需定义_GNU_SOURCE。 F_GETOWN限制:在旧版 glibc(<2.11)中,若进程组 ID < 4096,F_GETOWN可能错误返回 -1。新版通过F_GETOWN_EX解决。
设备驱动的支持
为了支持I/O多路复用与信号驱动,设备驱动代码中要有对应的支持。
多路复用
当应用程序调用 select 或 poll 函数来对驱动程序进行非阻塞访问的时候,驱动程序file_operations 结构体中的 poll 函数就会执行。所以驱动程序的编写者需要提供对应的 poll 函数,poll函数原型如下所示:
unsigned int (*poll) (struct file *filp, struct poll_table_struct *wait);
函数参数和返回值含义如下:
- filp:要打开的设备文件(文件描述符)。
- wait:结构体 poll_table_struct 类型指针,由应用程序传递进来的。 一般将此参数传递给 poll_wait 函数。
- 返回值:向应用程序返回设备或者资源状态,可以返回的资源状态如下: POLLIN 有数据可以读取。 POLLPRI 有紧急的数据需要读取。 POLLOUT 可以写数据。 POLLERR 指定的文件描述符发生错误。 POLLHUP 指定的文件描述符挂起。 POLLNVAL 无效的请求。 POLLRDNORM 等同于 POLLIN,普通数据可读
我们需要在驱动程序的 poll 函数中调用 poll_wait 函数,poll_wait 函数不会引起阻塞,只是将应用程序添加到 poll_table 中,poll_wait 函数原型如下:
void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p);
参数如下:
- wait_address 是要添加到 poll_table 中的等待队列头
- p 就是 poll_table,就是 file_operations 中 poll 函数的 wait 参数。
模板如下:
static unsigned int xxx_poll(struct file *filp, struct poll_table_struct *wait)
{
unsigned int mask = 0;
struct xxx_dev *dev = (struct xxx_dev *)filp->private_data;
/* 告诉内核,如果返回的 mask 表示没有准备好,那么就将进程加入 dev->r_wait 所指向的等待列表进行等待 */
poll_wait(filp, &dev->r_wait, wait); /* 将等待队列头添加到poll_table中 */
/* 这里假设有一个按键的读取逻辑 */*
if(atomic_read(&dev->releasekey)) { /* 按键按下 */
mask = POLLIN | POLLRDNORM; /* 返回PLLIN */
}
return mask;
}
事件驱动
要求在驱动代码的 file_operations 结构体中提供对 fasync 函数的相关支持。
模板如下
struct xxx_dev {
......
struct fasync_struct *async_queue; /* 异步相关结构体 */
};
static int xxx_fasync(int fd, struct file *filp, int on)
{
struct xxx_dev *dev = (xxx_dev)filp->private_data;
if (fasync_helper(fd, filp, on, &dev->async_queue) < 0)
return -EIO;
return 0;
}
static struct file_operations xxx_ops = {
......
.fasync = xxx_fasync,
......
};
当应用程序通过
fcntl(fd, F_SETFL, flags | FASYNC)改变 fasync 标记的时候,驱动程序 file_operations 操作集中的 fasync 函数就会执行。
此外,在驱动代码的 file_operations 结构体中的 release 函数中要释放资源。
static int xxx_release(struct inode *inode, struct file *filp)
{
return xxx_fasync(-1, filp, 0); /* 删除异步通知 */
}