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 上的高性能服务,这是一条收益明确、又能渐进落地的演进路径——而不是又一次推倒重来的范式革命。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部