【C++】IO多路复用(select、poll、epoll)
目录
- 1.select
- 1.1 介绍
- 1.2 API 接口
- 1.3 文件描述符集合(fd_set)的操作
- 1.4 select 的工作原理
- 1.5 select 的优缺点
- 2.poll
- 2.1 介绍
- 2.2 API
- 2.3 struct pollfd 结构体
- 2.4 事件类型(events 和 revents 的取值):
- 2.5 poll 的工作原理
- 2.6 poll 与 select 的对比
- 2.7 适用场景
- 3. epoll
- 3.1 介绍
- 3.2 epoll 的核心优势
- 3.3 struct epoll_event 结构体
- 3.4 epoll 的工作原理
- 3.5 触发模式

1.select
1.1 介绍
select 是早期 Unix 系统中经典的 I/O 多路复用技术,通过维护三个文件描述符集合(读、写、异常),调用时阻塞等待集合中任一文件描述符就绪。它的核心缺陷在于集合大小受系统宏定义限制(通常为 1024),且每次调用需将整个集合从用户态拷贝到内核态,同时就绪后需遍历整个集合才能找到活跃的文件描述符,在高并发场景下效率会显著下降,仅适用于连接数较少的简单场景。
1.2 API 接口
#include <sys/select.h>
#include <sys/time.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- 参数说明:
nfds:需要监控的文件描述符的范围,值为所有待监控文件描述符中的最大值 + 1(用于优化内核遍历范围)。readfds:读文件描述符集合,用于监控哪些文件描述符可读。函数返回时,会清除未就绪的描述符,仅保留就绪的。writefds:写文件描述符集合,用于监控哪些文件描述符可写。函数返回时,会清除未就绪的描述符,仅保留就绪的。exceptfds:异常文件描述符集合,用于监控文件描述符的异常事件。函数返回时,会清除未就绪的描述符,仅保留就绪的。timeout:超时时间(struct timeval 类型),控制 select 的阻塞行为:- NULL:select 一直阻塞,直到有文件描述符就绪。
- tv_sec=0 且 tv_usec=0:不阻塞,立即返回(轮询模式)。
- 其他值:阻塞指定的秒数(tv_sec)和微秒数(tv_usec),超时后返回 0。
- 返回值:
- 成功:返回就绪的文件描述符总数(读、写、异常集合中就绪的总和)。
- 失败:返回 -1,并设置 errno(如被信号中断则 errno=EINTR)。
- 超时:返回 0(没有文件描述符就绪)。
1.3 文件描述符集合(fd_set)的操作
fd_set 是一个位图结构(本质是整数数组),每个位代表一个文件描述符是否被监控。系统提供了以下宏来操作 fd_set:
| 宏 | 作用 |
|---|---|
| FD_ZERO(fd_set *) | 清空集合(所有位设为 0) |
| FD_SET(int fd, fd_set *) | 将文件描述符 fd 添加到集合(置位) |
| FD_CLR(int fd, fd_set *) | 从集合中移除 fd(清位) |
| FD_ISSET(int fd, fd_set *) | 检查 fd 是否在集合中(是否就绪) |
1.4 select 的工作原理
- 初始化集合:调用 FD_ZERO 清空读、写、异常集合,再用 FD_SET 将需要监控的文件描述符添加到对应集合中。
- 调用 select:内核会阻塞等待,直到以下事件发生:
- 某个监控的文件描述符就绪(读 / 写 / 异常)。
- 超时时间到达。
- 被信号中断。 - 处理就绪描述符:select 返回后,通过 FD_ISSET 检查每个文件描述符是否在就绪集合中,进而处理对应的 I/O 操作。
注意:select 会修改输入的 fd_set 集合(仅保留就绪的描述符),因此每次调用前需要重新初始化集合。
1.5 select 的优缺点
- 优点:
- 跨平台性好(支持 Unix/Linux、Windows 等)。
- 简单易用,适合监控少量文件描述符的场景。
- 缺点:
- 文件描述符数量限制:受限于
FD_SETSIZE(通常为 1024),默认最多监控 1024 个文件描述符(可通过修改内核参数调整,但不推荐)。 - 效率随描述符数量下降:每次调用
select时,内核需要遍历所有监控的描述符检查就绪状态,当描述符数量庞大时,效率显著降低。 - 集合需重复初始化:
select会修改输入的fd_set,因此每次调用前必须重新设置集合,增加了代码复杂度。 - 内核 / 用户空间拷贝开销:每次调用
select时,fd_set需从用户空间拷贝到内核空间,就绪后再拷贝回用户空间,存在性能开销。
- 文件描述符数量限制:受限于
2.poll
2.1 介绍
poll 是对 select 的改进,采用动态数组(pollfd 结构体数组)替代固定大小的文件描述符集合,从根本上突破了连接数限制,且无需区分读、写、异常三类集合,只需通过结构体中的 events 和 revents 字段标记关注事件与就绪事件。但 poll 未解决 select 的核心性能瓶颈 —— 每次调用仍需将整个数组拷贝到内核态,且就绪后仍需遍历所有元素查找活跃连接,因此在大量连接仅少数活跃的高并发场景中,效率依然较低。
2.2 API
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- 参数说明:
- fds:指向 struct pollfd 结构体数组的指针,每个结构体描述一个待监控的文件描述符及其关注的事件。
- nfds:数组 fds 中元素的数量(即需要监控的文件描述符总数)。
- timeout:超时时间(毫秒),控制 poll 的阻塞行为:
- > 0:阻塞 timeout 毫秒后返回(若期间无事件就绪)。
- = 0:不阻塞,立即返回(轮询模式)。
- = -1:一直阻塞,直到有文件描述符就绪或被信号中断。
- 返回值:
- 成功:返回就绪的文件描述符总数(所有事件类型中就绪的总和)。
- 失败:返回 -1,并设置 errno(如被信号中断则 errno=EINTR)。
- 超时:返回 0(无就绪文件描述符)。
2.3 struct pollfd 结构体
poll 通过 struct pollfd 结构体定义每个文件描述符的监控信息,结构如下:
struct pollfd {int fd; // 待监控的文件描述符(若为-1,poll会忽略该结构体)short events; // 关注的事件(输入参数,由用户设置)short revents; // 实际发生的事件(输出参数,由内核填充)
};
2.4 事件类型(events 和 revents 的取值):
| 事件宏 | 含义 |
|---|---|
| POLLIN | 可读事件(如数据到达、管道可读、套接字关闭) |
| POLLOUT | 可写事件(如缓冲区未满,可写入数据) |
| POLLERR | 错误事件(无需在 events 中设置,内核自动检测) |
| POLLHUP | 挂断事件(如管道另一端关闭、套接字连接关闭) |
| POLLNVAL | 文件描述符无效(如未打开) |
说明:
- events 由用户设置,指定需要监控的事件(如 POLLIN | POLLOUT 表示同时监控可读和可写)。
- revents 由内核在 poll 返回时填充,指示该文件描述符实际发生的事件(可能包含 events 中未设置的事件,如
POLLERR)。
2.5 poll 的工作原理
- 初始化 pollfd 数组:为每个需要监控的文件描述符创建
struct pollfd结构体,设置 fd 和关注的 events(如POLLIN)。 - 调用 poll:内核阻塞等待,直到以下情况发生:
- 某个文件描述符的 events 事件就绪。
- 超时时间到达。
- 被信号中断。
- 处理就绪事件:poll 返回后,遍历 pollfd 数组,通过检查 revents 确定哪些文件描述符就绪,并处理对应的 I/O 操作(如读 / 写数据)。
- 关键特点:poll 不会修改 fds 数组中的 fd 和 events,仅更新 revents,因此无需像 select 那样每次调用前重新初始化集合。
2.6 poll 与 select 的对比
poll 的优点:
- 无文件描述符数量限制:select 受限于 FD_SETSIZE(默认 1024),而 poll 仅受系统内存和内核参数限制,可监控更多文件描述符。
- 无需重复初始化监控集合:select 会修改输入的 fd_set,每次调用前需重新设置;poll 仅修改 revents,fd 和 events 保持不变,可重复使用。
- 事件分离更清晰:select 通过三个独立集合区分读、写、异常事件,而 poll 用一个结构体同时包含所有事件类型,逻辑更简洁。
poll 的缺点: - 效率仍随描述符数量下降:与 select 类似,poll 返回后需遍历所有监控的描述符(通过 revents 检查就绪状态),当数量庞大时(如上万),遍历开销显著。
- 内核 / 用户空间拷贝开销:每次调用 poll 时,pollfd 数组需从用户空间拷贝到内核空间,就绪后无需拷贝回(仅修改用户空间的 revents),但仍有一定开销。
- 跨平台支持不如 select:Windows 系统不原生支持 poll(需通过模拟实现),而 select 是跨平台的。
2.7 适用场景
poll 适用于需要监控的文件描述符数量超过 1024(突破 select 限制),但并发量又不极端(如几千级别)的场景。例如中小型服务器、多设备监控等。
若需处理更高并发(如几万到几十万连接),Linux 系统推荐使用 epoll,BSD 系(如 macOS)推荐使用 kqueue,它们采用事件驱动模式,无需遍历所有描述符,效率更高。
3. epoll
3.1 介绍
epoll 是 Linux 特有的高性能 I/O 多路复用技术,通过 “事件驱动” 模式彻底优化性能:它先通过 epoll_ctl 注册文件描述符及关注事件,内核维护一棵红黑树存储这些文件描述符,避免每次调用的拷贝与遍历;当文件描述符就绪时,内核会将其加入就绪链表,epoll_wait 只需直接读取该链表即可获取活跃连接,无需遍历全部注册项。此外,epoll 支持水平触发(LT)和边缘触发(ET)两种模式,ET 模式可进一步减少内核与用户态的交互次数,使其在高并发、高连接数的网络编程(如 Nginx、Redis)中成为首选方案。
3.2 epoll 的核心优势
与 select/poll 相比,epoll 的核心改进在于:
- 事件驱动机制:无需轮询所有文件描述符,仅返回就绪的描述符,效率随并发量增长影响小。
- 无文件描述符数量限制:仅受系统内存和内核参数(如 /proc/sys/fs/file-max)限制,可支持十万级以上连接。
- 减少内核 / 用户空间拷贝:通过内存映射(mmap)共享事件数据,避免频繁数据拷贝。
- 支持边缘触发(ET)和水平触发(LT):灵活适应不同 I/O 模式需求。
epoll 的核心函数
epoll 操作通过三个核心函数完成:epoll_create、epoll_ctl、epoll_wait。
- 创建 epoll 实例
#include <sys/epoll.h>int epoll_create(int size);
- 作用:创建一个 epoll 实例(内核数据结构),用于管理监控的文件描述符。
- 参数:size 是早期内核用于提示所需监控的文件描述符数量,现代内核已忽略此值(但需传入大于 0 的数)。
- 返回值:成功返回 epoll 实例的文件描述符(epfd),失败返回 -1 并设置 errno。
- 管理监控的文件描述符
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
作用:向 epoll 实例添加、修改或删除需要监控的文件描述符及其事件。
参数:- epfd:epoll_create 返回的实例描述符。- op:操作类型:- EPOLL_CTL_ADD:添加文件描述符 fd 到 epfd。- EPOLL_CTL_MOD:修改 fd 的监控事件。- EPOLL_CTL_DEL:从 epfd 中删除 fd(此时 event 可设为 NULL)。- fd:需要监控的文件描述符。- event:struct epoll_event 结构体,描述监控的事件和附加数据(见下文)。
返回值:成功返回 0,失败返回 -1 并设置 errno。
- 等待事件就绪
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
作用:阻塞等待 epoll 实例中监控的文件描述符就绪,返回就绪的事件。
参数:
- epfd:epoll 实例描述符。
- events:用户空间数组,用于存储内核返回的就绪事件(输出参数)。
- maxevents:events 数组的最大容量(必须大于 0)。
- timeout:超时时间(毫秒):
- > 0:阻塞 timeout 毫秒后返回。
- = 0:不阻塞,立即返回。
- = -1:一直阻塞,直到有事件就绪或被信号中断。
返回值:
- 成功:返回就绪的事件数量(>0)。
- 超时:返回 0。
- 失败:返回 -1 并设置 errno(如被信号中断则 errno=EINTR)。
3.3 struct epoll_event 结构体
struct epoll_event {uint32_t events; // 监控的事件(输入)或就绪的事件(输出)epoll_data_t data; // 附加数据(用户自定义,如文件描述符、指针等)
};// 附加数据的联合体
typedef union epoll_data {void *ptr; // 指向用户数据的指针int fd; // 文件描述符(最常用)uint32_t u32; // 32位整数uint64_t u64; // 64位整数
} epoll_data_t;
| 事件宏 | 含义 |
|---|---|
| EPOLLIN 可读事件 | (如数据到达、套接字关闭) |
| EPOLLOUT 可写事件 | (如缓冲区未满,可写入数据) |
| EPOLLERR 错误事件 | (无需手动设置,内核自动监控) |
| EPOLLHUP 挂断事件 | (如连接关闭) |
| EPOLLET 边缘触发模式 | (Edge Triggered) |
| EPOLLONESHOT 一次性监控 | (事件触发后自动移除监控) |
3.4 epoll 的工作原理
- 创建 epoll 实例:epoll_create 在内核中创建一个 eventpoll 结构体(包含红黑树和就绪链表):
- 红黑树:存储所有被监控的文件描述符及其事件(高效增删改查)。
- 就绪链表:存储就绪的文件描述符(避免轮询,直接返回就绪事件)。
- 添加 / 修改 / 删除监控对象:通过 epoll_ctl 操作红黑树,将文件描述符 fd 及其事件注册到内核。内核会为 fd 注册回调函数,当 fd 就绪时,回调函数将其加入就绪链表。
- 等待就绪事件:epoll_wait 检查就绪链表,若有就绪事件,直接将事件复制到用户空间的 events 数组并返回;若无,则阻塞等待(直到超时或被唤醒)。
epoll 的优缺点
优点: - 高性能:基于事件驱动,无需轮询所有描述符,效率随并发量增长稳定。
- 支持大量连接:无 select 的 1024 限制,可轻松支持十万级以上连接。
- 灵活的触发模式:LT 模式简单易用,ET 模式适合高性能场景。
- 低开销:通过红黑树管理描述符,增删改查效率高;通过 mmap 共享数据,减少拷贝。
缺点: - 平台依赖性:仅 Linux 系统支持,不跨平台(BSD 用 kqueue,Windows 用 IOCP)。
- ET 模式编程复杂:需配合非阻塞 I/O 并确保一次性处理完数据,否则可能遗漏事件。
3.5 触发模式
-
水平触发(Level Triggered,默认模式)
当 fd 上可读 / 可写时,epoll 会持续通知应用程序,直到数据被完全处理(读 / 写完成)。 -
边缘触发(Edge Triggered,需设置 EPOLLET)
仅在 fd 的状态发生变化瞬间触发事件(例如,从不可读变为可读、从不可写变为可写),之后即使数据未处理完,也不会再次通知,直到下次状态变化。
