io_uring 替代 epoll:用 IORING_OP_POLL_ADD 重构多路复用事件循环的深度实战
epoll 统治 Linux 高性能网络编程已经超过十五年。从 Nginx 到 Redis,从 Node.js 到 Rust Tokio,几乎所有"百万并发"的神话都建立在 epoll_wait 之上。但 epoll 并非没有天花板:每次进入事件循环都要一次系统调用,每次新增监听都要 epoll_ctl,内核就绪队列与用户态之间反复横跳,水平触发下的惊群与重复通知更是老生常谈。io_uring 的出现,第一次给了我们在不改写业务逻辑的前提下,用统一的异步提交模型把 epoll 反应器(Reactor)整体替换掉的可能。本文聚焦一个被大量"io_uring vs epoll"对比文章忽略的实战角度:如何用 IORING_OP_POLL_ADD 把一套 epoll 事件循环逐行重写成 io_uring 多路复用事件循环,并讲清 multi-shot poll、SQE/CQE 模型与迁移陷阱。
一、先看清 epoll 反应器到底在做什么
一个典型的 epoll 反应器由三件事构成:
- 注册:
epoll_ctl(EPOLL_CTL_ADD)把一个 fd 及其关心的事件(EPOLLIN/EPOLLOUT)挂进内核红黑树。 - 等待:
epoll_wait阻塞,直到内核把"就绪"的 fd 搬到就绪队列并返回。 - 分发:用户态遍历返回的
epoll_event数组,对可读 fd 调用read、对可写 fd 调用write,并可能再次epoll_ctl(MOD)切换关心事件。
它的本质是一个内核托管的就绪通知器:内核替你盯着 fd,你只管在通知到来时做 I/O。问题也出在这里——"通知"和"做 I/O"之间隔着系统调用边界,且每次状态切换(比如从关心读变成关心写)都要再进一次内核。
/* 经典 epoll 反应器骨架 */
int ep = epoll_create1(0);
struct epoll_event ev = {.events = EPOLLIN, .data.fd = listen_fd};
epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &ev);
struct epoll_event events[1024];
for (;;) {
int n = epoll_wait(ep, events, 1024, -1); /* 每次循环一次系统调用 */
for (int i = 0; i < n; i++) {
if (events[i].data.fd == listen_fd) accept_and_add(ep, events[i].data.fd);
else if (events[i].events & EPOLLIN) do_read(ep, events[i].data.fd);
else if (events[i].events & EPOLLOUT) do_write(ep, events[i].data.fd);
}
}
注意 epoll_wait 的每次调用都是一次特权级切换;在高并发、短连接场景下,这一笔系统调用开销会显著吃掉 CPU。
二、io_uring 凭什么能替代 epoll
io_uring 用一个提交队列(SQ)和完成队列(CQ)的共享内存环形结构,把"请求"和"结果"都搬到用户态与内核共享的区域:
- 提交无 syscall:填好一个 SQE(Submission Queue Entry)并推进 SQ tail,就可以让内核看到请求;如果用
IORING_SETUP_SQPOLL,连提交都不需要系统调用。 - 统一接口:读、写、accept、connect,以及我们最关心的轮询监听,都是一种 opcode,走同一条异步管道。
- 注册文件:
io_uring_register_files把 fd 固定进内核,后续提交用"文件索引"而非 fd 编号,省去每次的文件描述符查表。
替代 epoll 的关键 opcode 就是 IORING_OP_POLL_ADD:它等价于 epoll_ctl(EPOLL_CTL_ADD)——告诉 io_uring"帮我盯着这个 fd 的读/写就绪"。当事件发生时,一个 CQE(Completion Queue Event)带着 POLL 结果回来,语义上完全对应 epoll_wait 返回的一条就绪事件。于是整个 epoll 反应器可以被"翻译"成 io_uring 版本。
三、IORING_OP_POLL_ADD 与 multi-shot:事件循环的心脏
IORING_OP_POLL_ADD 的基本用法是提交一条 poll 请求,内核在 fd 就绪时回填一个 CQE,cqe->res 里是触发的事件位(POLLIN/POLLOUT/POLLHUP 等)。但这里有个和 epoll 的关键区别:
- 默认一次性(one-shot):一次 POLL_ADD 只触发一次 CQE。触发后若还想继续监听,必须再次提交 POLL_ADD——这相当于 epoll 的
EPOLLONESHOT。 - multi-shot(多路持续):设置标志
IORING_POLL_ADD_MULTI后,一次 POLL_ADD 会在每次就绪时都产生 CQE,且不会自动失效。这正是替代"水平触发 epoll"的利器:你提交一次,内核反复在你关心的事件发生时通知你,直到你显式IORING_OP_POLL_REMOVE。
/* 提交一个监听可读事件的 multi-shot poll,等价于 epoll_ctl(ADD, EPOLLIN) 的水平触发语义 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_poll_add(sqe, fd, POLLIN);
sqe->flags |= IOSQE_BUFFER_SELECT; /* 可选:配合 buffer group */
sqe->poll_events = POLLIN;
/* 关键:开启 multi-shot,否则只触发一次 */
sqe->len = 0; /* len 字段在某些版本承载 flags */
io_uring_sqe_set_flags(sqe, IOSQE_MULTISHOT_POLL); /* 多路持续监听 */
io_uring_submit(&ring);
注意:
IOSQE_MULTISHOT_POLL与IORING_POLL_ADD_MULTI是不同内核版本下的同一能力的两种表述;实战中请以你目标内核(5.13+ 推荐)的头文件为准,并用io_uring_probe探测能力是否支持。
四、把 epoll 反应器逐行翻译成 io_uring
下面给出与第一节一一对应的 io_uring 事件循环骨架。核心变化是:不再有 epoll_wait,改为 io_uring_peek_batch_cqe 批量收割 CQE;不再有 epoll_ctl(ADD),改为提交 IORING_OP_POLL_ADD。
#include <liburing.h>
struct io_uring ring;
io_uring_queue_init(4096, &ring, 0);
/* 为监听 fd 注册,后续用文件索引提交,省去 fd 查表 */
io_uring_register_files(&ring, (int[]){listen_fd}, 1);
/* 初始:为 listen_fd 提交 POLL_ADD(等价于 epoll_ctl ADD EPOLLIN) */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_poll_add(sqe, listen_fd, POLLIN);
io_uring_sqe_set_flags(sqe, IOSQE_MULTISHOT_POLL);
io_uring_submit(&ring);
struct io_uring_cqe *cqes[256];
for (;;) {
/* 等价于 epoll_wait,但可一次批量收割,且配合 SQPOLL 可零 syscall 提交 */
int n = io_uring_peek_batch_cqe(&ring, cqes, 256);
for (int i = 0; i < n; i++) {
struct io_uring_cqe *cqe = cqes[i];
int fd = cqe->flags >> 16; /* 从 cqe 取回关联 fd(提交时塞入 user_data/flags) */
int res = cqe->res; /* 触发事件位,如 POLLIN/POLLOUT/POLLHUP */
if (fd == listen_fd) {
int cfd = accept(listen_fd, NULL, NULL);
/* 为新连接提交 POLL_ADD 监听可读 —— 等价于 epoll_ctl(ADD, cfd, EPOLLIN) */
struct io_uring_sqe *s = io_uring_get_sqe(&ring);
io_uring_prep_poll_add(s, cfd, POLLIN);
io_uring_sqe_set_flags(s, IOSQE_MULTISHOT_POLL);
io_uring_cqe_seen(&ring, cqe);
io_uring_submit(&ring);
continue;
}
if (res & POLLIN) do_read(&ring, fd); /* 可读:提交 IORING_OP_READ */
if (res & POLLOUT) do_write(&ring, fd); /* 可写:提交 IORING_OP_WRITE */
if (res & (POLLHUP | POLLERR)) close_and_remove(&ring, fd);
io_uring_cqe_seen(&ring, cqe);
}
}
几个迁移要点:
do_read/do_write本身也要改成异步:把read/write换成IORING_OP_READV/IORING_OP_WRITEV的 SQE 提交,才能真正消除每笔 I/O 的系统调用。这正是它与"io_uring 网络编程(SEND/RECV)"文章的区别——本文只谈"用 POLL_ADD 替代 epoll 的就绪通知层",而实际收发可以接 SEND/RECV 或 READ/WRITE。- 移除监听:需要取消关注某 fd 时,提交
IORING_OP_POLL_REMOVE(等价于epoll_ctl(DEL)),并带上当初 POLL_ADD 的user_data以便内核定位。 - 关联上下文:io_uring 通过
sqe->user_data回传上下文,建议把 fd 或连接对象指针塞进去,CQE 回来时即可直接定位,省去哈希查表。
五、epoll 与 io_uring-poll 对照表
| 维度 | epoll 反应器 | io_uring POLL_ADD 反应器 |
|---|---|---|
| 就绪等待 | epoll_wait(每次循环一次 syscall) |
io_uring_peek_batch_cqe(可零 syscall 配合 SQPOLL) |
| 注册监听 | epoll_ctl(ADD) |
IORING_OP_POLL_ADD |
| 取消监听 | epoll_ctl(DEL) |
IORING_OP_POLL_REMOVE |
| 持续监听 | EPOLLIN 水平触发天然持续 | IOSQE_MULTISHOT_POLL 多路持续 |
| 单次监听 | EPOLLONESHOT |
默认 one-shot(不置 multishot) |
| 收割粒度 | 每次返回一条就绪 | 批量 peek_batch_cqe 一次收 N 条 |
| 惊群 | LT 下重复通知、ET 需一次读净 | CQE 精确对应一次就绪事件 |
| I/O 本身 | read/write 仍 syscall |
可一并改 IORING_OP_READ/WRITE 彻底异步 |
| 学习/运维成本 | 极低、生态成熟 | 较高、需 liburing 与内核版本匹配 |
六、进阶:把 POLL 与收发链接成一条流水线
io_uring 真正的杀手锏是 SQE 链接(IOSQE_IO_LINK):你可以把"POLL_ADD 可读"和"READ"链接成原子链,内核在 poll 就绪后自动发起 read,省去你"收到 CQE→再提交读"的往返。这比 epoll 的"epoll_wait→read"两段式少了一次用户态决策与提交,是高性能反应器常用的优化。
/* 链接:先等可读,就绪后自动 READ,无需中间回用户态 */
struct io_uring_sqe *p = io_uring_get_sqe(&ring);
io_uring_prep_poll_add(p, fd, POLLIN);
io_uring_sqe_set_flags(p, IOSQE_IO_LINK); /* 链接到下一条 */
struct io_uring_sqe *r = io_uring_get_sqe(&ring);
io_uring_prep_read(r, fd, buf, len, 0); /* poll 成功后自动执行 */
io_uring_submit(&ring);
但要小心:链接链中任意一环失败会中断后续;且 multishot poll 与 link 的组合在内核版本间行为不一,生产环境务必先在目标内核上用 io_uring_probe 验证能力位。
七、何时该换,何时不该换
- 该换:你已在用 epoll 且追求极致吞吐/低尾延迟;连接数巨大、短连接比例高(系统调用开销占比显著);团队能接受 liburing 与内核版本约束;愿意把 read/write 也改成异步以吃满 io_uring 红利。
- 不该换:业务逻辑重度依赖 epoll 的成熟生态(如直接复用大量基于
epoll_fd的库);目标内核过旧(<5.10 建议先升级);只是"想用新 API"而无真实性能诉求——epoll 在绝大多数场景依然又快又稳。 - 折中:可以先用 POLL_ADD 替换"就绪通知层",保留同步 read/write,拿到批量收割与少 syscall 的收益,再逐步把收发异步化。这种渐进迁移风险最低。
八、总结
把 epoll 反应器换成 io_uring,并不是把 epoll_wait 换个名字,而是把就绪通知、I/O 提交、结果回收三件事统一进 SQ/CQ 的异步管道:epoll_ctl(ADD) 对应 POLL_ADD,epoll_wait 对应 peek_batch_cqe,EPOLLONESHOT 对应 one-shot、IOSQE_MULTISHOT_POLL 对应水平触发的持续监听。真正拉开差距的是 multishot 与 SQE 链接带来的"少一次往返、少一次 syscall"。对于已经跑在 epoll 上的高性能服务,这是一条收益明确、又能渐进落地的演进路径——而不是又一次推倒重来的范式革命。

发表评论 取消回复