IO 多路复用
IO 多路复用
I/O 多路复用让一个线程同时等待多个文件描述符的就绪事件。它解决的是“如何高效等待很多连接”,并不自动解决协议拆包、业务耗时、线程安全或发送缓冲区管理问题。
基本工作方式
一个事件循环通常执行以下步骤:
1 | 将监听 socket 和客户端 socket 加入关注集合 |
对于 TCP 服务端,监听 socket 可读通常表示有新连接等待 accept();客户端 socket 可读可能表示有正文、收到 FIN,或出现错误,必须调用 recv() 进一步确认。
就绪不是完成
多路复用接口通常给出的是就绪通知:
| 事件 | 通常表示 | 仍需做什么 |
|---|---|---|
| 可读 | 接收缓冲区有数据、收到 FIN 或有错误 | 调用 recv();根据返回值区分正文、关闭和错误 |
| 可写 | 发送缓冲区有可用空间 | 仅在有待发送数据时调用 send();处理部分发送 |
| 错误/挂起 | 连接异常或对端状态变化 | 检查接口错误标志,必要时 getsockopt(SO_ERROR) 或尝试读取 |
不要在所有连接上长期关注“可写”事件:多数正常 TCP socket 几乎一直可写,会导致事件循环不断被唤醒。只有发送缓冲区中有未发送的数据时才注册可写事件,数据发送完后立即取消关注。
select()
1 | int select(int nfds, fd_set* readfds, fd_set* writefds, |
| 参数 | 说明 |
|---|---|
nfds |
要监视的最大 fd 加一,不是 fd 的数量。 |
readfds |
输入时是希望监听可读的集合;返回时只保留本次就绪的 fd。可传 nullptr。 |
writefds |
输入时是希望监听可写的集合;返回时只保留本次就绪的 fd。可传 nullptr。 |
exceptfds |
监听异常条件,网络程序通常较少单独使用。可传 nullptr。 |
timeout |
最长等待时间;nullptr 表示无限等待,零值表示立即返回。调用后该对象的值可能被修改,应在每轮重新初始化。 |
返回值大于 0 表示就绪 fd 的数量,0 表示超时,-1 表示出错(EINTR 通常可重试)。
1 | fd_set read_set; |
FD_SET、FD_CLR、FD_ISSET 和 FD_ZERO 用于操作集合。每轮调用前必须从“主集合”复制一份到工作集合,因为 select() 会修改传入集合。
局限性:fd_set 容量受 FD_SETSIZE 限制(许多系统默认是 1024,但不要把这个数当作可移植保证),每轮都需要复制集合并扫描 fd 范围。它适合小规模连接和需要较好可移植性的场景。
poll()
1 | int poll(struct pollfd* fds, nfds_t nfds, int timeout); |
| 参数 / 字段 | 说明 |
|---|---|
fds |
pollfd 数组,数组中的每一项对应一个要监听的 fd。 |
nfds |
数组元素数量。 |
timeout |
等待毫秒数:-1 无限等待,0 立即返回,正数表示最长等待时间。 |
pollfd::fd |
需要监听的 fd;设为负数可临时忽略该项。 |
pollfd::events |
希望监听的事件,例如 POLLIN、POLLOUT。 |
pollfd::revents |
调用返回后由内核填写的实际事件,调用前无需设置。 |
1 |
|
poll() 没有 FD_SETSIZE 这样的固定集合上限,但每轮仍要遍历整个数组,连接数很大且大量空闲时成本会提高。POLLHUP 或 POLLERR 出现时也不应跳过读取:对端可能在关闭前仍留下可读取的数据。
Linux epoll
epoll 是 Linux 专用的事件通知机制,特别适合大量长连接。它把“注册关注的 fd”和“等待事件”分离,应用无需每轮把全部 fd 集合重新传给内核。
epoll_create1():创建实例
1 | int epoll_create1(int flags); |
通常传 0;若传 EPOLL_CLOEXEC,新 fd 会在 exec 时自动关闭。成功返回 epoll 实例 fd,失败返回 -1。使用结束后也要 close(epoll_fd)。
epoll_ctl():注册、修改或删除关注项
1 | int epoll_ctl(int epfd, int op, int fd, epoll_event* event); |
| 参数 | 说明 |
|---|---|
epfd |
epoll_create1() 返回的实例 fd。 |
op |
EPOLL_CTL_ADD 添加,EPOLL_CTL_MOD 修改,EPOLL_CTL_DEL 删除。 |
fd |
要监听的目标 fd,例如监听 socket 或客户端 socket。 |
event |
添加/修改时指定关注事件与用户数据;删除时通常传 nullptr。 |
epoll_event::events 常用标志:
| 标志 | 含义 |
|---|---|
EPOLLIN |
可读事件 |
EPOLLOUT |
可写事件 |
EPOLLERR |
错误事件,应检查 SO_ERROR 或读写结果 |
EPOLLHUP |
挂起事件,对端可能已经关闭 |
EPOLLRDHUP |
对端关闭写方向;适合 TCP 半关闭检测 |
EPOLLET |
使用边缘触发(ET) |
EPOLLONESHOT |
触发一次后暂停该 fd,需 MOD 重新激活 |
event.data 是联合体。简单示例可使用 event.data.fd = fd;实际项目常保存指向连接对象的指针,但必须确保对象在事件处理期间仍有效,避免悬空指针。
epoll_wait():等待就绪事件
1 | int epoll_wait(int epfd, epoll_event* events, int maxevents, int timeout); |
| 参数 | 说明 |
|---|---|
epfd |
epoll 实例 fd。 |
events |
输出数组,接收本次就绪事件。 |
maxevents |
输出数组容量,必须大于 0。 |
timeout |
最长等待毫秒数:-1 无限等待,0 立即返回,正数表示超时。 |
返回值大于 0 时表示 events[0..n-1] 有效;0 表示超时;-1 表示错误,EINTR 通常可重试。
最小事件循环骨架:
1 |
|
上例仅展示分发流程。真实服务器还需要连接对象、输入输出缓冲区、超时定时器、协议解析、错误路径和资源上限管理。
LT 与 ET
| 模式 | 含义 | 行为 |
|---|---|---|
| LT(默认) | 水平触发 | 只要 fd 仍处于就绪状态,就会持续得到通知 |
ET(EPOLLET) |
边缘触发 | 状态从未就绪变为就绪时通知;若没有读/写到尽头,可能不再收到提醒 |
LT 更容易写对,适合初学者。ET 必须搭配非阻塞 fd,并在收到事件后循环执行 accept()、recv() 或 send(),直到返回 EAGAIN / EWOULDBLOCK。
ET 读取伪代码:
1 | for (;;) { |
ET 并不自动“更快”。如果连接数不大或逻辑复杂,LT 往往更稳妥;性能优化前应先通过压测确认瓶颈。
EPOLLONESHOT 与多线程
当多个工作线程同时等待同一个 epoll 实例时,EPOLLONESHOT 可避免同一 fd 的同类事件被多个线程并发处理。它触发一次后需要使用 EPOLL_CTL_MOD 重新注册事件,否则该 fd 不会再次触发。它只解决部分并发分发问题,连接对象的生命周期和缓冲区同步仍必须自行设计。
关闭一个已注册 fd 会自动将它从 epoll 兴趣列表移除,但应用仍应同步清理自己的连接对象、定时器和待发送数据。
BSD/macOS kqueue
kqueue 是 BSD/macOS 的事件通知机制,角色与 Linux epoll 类似。常见过滤器包括:
| 过滤器 | 含义 |
|---|---|
EVFILT_READ |
可读事件 |
EVFILT_WRITE |
可写事件 |
EVFILT_TIMER |
定时器事件 |
EVFILT_SIGNAL |
信号事件 |
它通过 kqueue() 创建队列、kevent() 同时提交变更并等待事件。跨平台项目通常使用成熟的事件库(如 libevent、libuv、Boost.Asio)屏蔽 epoll / kqueue 等差异。
接口选择
| 接口 | 平台 | 适合场景 | 主要限制 |
|---|---|---|---|
select |
多数平台 | 小规模、教学、强调可移植性 | 集合容量限制,需每轮复制与扫描 |
poll |
Unix-like | 中小规模、接口简单 | 每轮扫描整个数组 |
epoll |
Linux | 大量长连接、高并发服务 | Linux 专用,ET/生命周期处理复杂 |
kqueue |
BSD/macOS | BSD/macOS 高并发服务 | 平台专用,接口与 epoll 不同 |
选择接口不是性能的全部。协议解析、业务逻辑、数据库、日志、TLS 和不受控的发送缓冲区都可能比事件通知机制更早成为瓶颈。



