IO 多路复用

I/O 多路复用让一个线程同时等待多个文件描述符的就绪事件。它解决的是“如何高效等待很多连接”,并不自动解决协议拆包、业务耗时、线程安全或发送缓冲区管理问题。

基本工作方式

一个事件循环通常执行以下步骤:

1
2
3
4
5
6
7
8
9
将监听 socket 和客户端 socket 加入关注集合
↓
等待:任意 fd 可读、可写或发生错误
↓
遍历本次就绪事件
↓
accept / recv / send,并更新连接状态
↓
继续等待下一轮事件

对于 TCP 服务端,监听 socket 可读通常表示有新连接等待 accept();客户端 socket 可读可能表示有正文、收到 FIN,或出现错误,必须调用 recv() 进一步确认。

就绪不是完成

多路复用接口通常给出的是就绪通知:

事件 通常表示 仍需做什么
可读 接收缓冲区有数据、收到 FIN 或有错误 调用 recv();根据返回值区分正文、关闭和错误
可写 发送缓冲区有可用空间 仅在有待发送数据时调用 send();处理部分发送
错误/挂起 连接异常或对端状态变化 检查接口错误标志,必要时 getsockopt(SO_ERROR) 或尝试读取

不要在所有连接上长期关注“可写”事件:多数正常 TCP socket 几乎一直可写,会导致事件循环不断被唤醒。只有发送缓冲区中有未发送的数据时才注册可写事件,数据发送完后立即取消关注。

select()

1
2
int select(int nfds, fd_set* readfds, fd_set* writefds,
fd_set* exceptfds, timeval* timeout);
参数 说明
nfds 要监视的最大 fd 加一,不是 fd 的数量。
readfds 输入时是希望监听可读的集合;返回时只保留本次就绪的 fd。可传 nullptr。
writefds 输入时是希望监听可写的集合;返回时只保留本次就绪的 fd。可传 nullptr。
exceptfds 监听异常条件,网络程序通常较少单独使用。可传 nullptr。
timeout 最长等待时间;nullptr 表示无限等待,零值表示立即返回。调用后该对象的值可能被修改,应在每轮重新初始化。

返回值大于 0 表示就绪 fd 的数量,0 表示超时,-1 表示出错(EINTR 通常可重试)。

1
2
3
4
5
6
7
8
9
fd_set read_set;
FD_ZERO(&read_set);
FD_SET(listen_fd, &read_set);

const int ready = select(listen_fd + 1, &read_set, nullptr, nullptr, nullptr);
if (ready > 0 && FD_ISSET(listen_fd, &read_set)) {
const int client_fd = accept(listen_fd, nullptr, nullptr);
// 检查 client_fd,并把它加入下一轮关注集合
}

FD_SET、FD_CLR、FD_ISSET 和 FD_ZERO 用于操作集合。每轮调用前必须从“主集合”复制一份到工作集合,因为 select() 会修改传入集合。

局限性:fd_set 容量受 FD_SETSIZE 限制(许多系统默认是 1024,但不要把这个数当作可移植保证),每轮都需要复制集合并扫描 fd 范围。它适合小规模连接和需要较好可移植性的场景。

poll()

1
2
3
4
5
6
7
int poll(struct pollfd* fds, nfds_t nfds, int timeout);

struct pollfd {
int fd;
short events;
short revents;
};
参数 / 字段 说明
fds pollfd 数组,数组中的每一项对应一个要监听的 fd。
nfds 数组元素数量。
timeout 等待毫秒数:-1 无限等待,0 立即返回,正数表示最长等待时间。
pollfd::fd 需要监听的 fd;设为负数可临时忽略该项。
pollfd::events 希望监听的事件,例如 POLLIN、POLLOUT。
pollfd::revents 调用返回后由内核填写的实际事件,调用前无需设置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <poll.h>

pollfd item{};
item.fd = fd;
item.events = POLLIN;

const int ready = poll(&item, 1, 1000);
if (ready > 0) {
if (item.revents & POLLIN) {
// 调用 recv()
}
if (item.revents & (POLLERR | POLLHUP | POLLNVAL)) {
// 处理错误、挂起或无效 fd
}
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <cerrno>
#include <cstdint>
#include <sys/epoll.h>
#include <unistd.h>

constexpr int kMaxEvents = 128;
epoll_event events[kMaxEvents];

for (;;) {
const int count = epoll_wait(epoll_fd, events, kMaxEvents, -1);
if (count == -1) {
if (errno == EINTR) continue;
break;
}

for (int i = 0; i < count; ++i) {
const int fd = events[i].data.fd;
const std::uint32_t mask = events[i].events;

if (fd == listen_fd) {
// accept 新连接;ET 模式下应循环 accept 到 EAGAIN
} else {
if (mask & EPOLLIN) {
// 读取数据并进行协议解析;读到 0 表示对端关闭写方向
}
if (mask & EPOLLOUT) {
// 继续发送待发送缓冲区中的剩余数据
}
if (mask & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) {
// 根据读取结果、SO_ERROR 和协议状态完成清理
}
}
}
}

上例仅展示分发流程。真实服务器还需要连接对象、输入输出缓冲区、超时定时器、协议解析、错误路径和资源上限管理。

LT 与 ET

模式 含义 行为
LT(默认) 水平触发 只要 fd 仍处于就绪状态,就会持续得到通知
ET(EPOLLET) 边缘触发 状态从未就绪变为就绪时通知;若没有读/写到尽头,可能不再收到提醒

LT 更容易写对,适合初学者。ET 必须搭配非阻塞 fd,并在收到事件后循环执行 accept()、recv() 或 send(),直到返回 EAGAIN / EWOULDBLOCK。

ET 读取伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for (;;) {
char buffer[4096];
const ssize_t n = recv(fd, buffer, sizeof(buffer), 0);

if (n > 0) {
// 追加到连接的输入缓冲区,解析可能完整的协议帧
} else if (n == 0) {
// 对端关闭
break;
} else if (errno == EINTR) {
continue;
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 已读空:本轮处理完成
} else {
// 真正错误,关闭连接
break;
}
}

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 和不受控的发送缓冲区都可能比事件通知机制更早成为瓶颈。