epoll vs io_uring:Linux 异步 I/O 的过去与现在

epoll vs io_uring:Linux 异步 I/O 的过去与现在

linuxio_uringepollasync-iokernel

数据源:Sibexico blog + HN discussion

epoll vs io_uring:Linux 异步 I/O 的过去与现在

去年,作者 Sibexico 带着学生做了一个叫 TinyGate 的反向代理。第一版是简单的 worker 模型,能跑,但学生不满意——他们想造一个真正能跟 nginx、haproxy 掰手腕的东西。于是逼着老师一起研究这些工业级工具底层到底怎么处理异步 I/O 的。

结果就是 TinyGate v2,基于 epoll。benchmark 还是输给了 nginx,但相比 v1 已经是质的飞跃。然后他们又发现了 io_uring,再次从头重写。

这个过程恰好穿过了 Linux 异步 I/O 的两代核心技术。Sibexico 写了一篇梳理,HN 上展开了 50+ 条讨论。

epoll:统治了十七年的就绪模型

epoll 2002 年进入 Linux 内核。很长一段时间里,它是 Linux 上做异步 I/O 的唯一正经选择。nginx、haproxy、libuv、tokio(Linux 下),无一例外都在用它。

它的工作方式是就绪通知:内核告诉你”这个 fd 现在可读了/可写了”,然后你自己去调 read() / write()。这意味着每次 I/O 事件至少触发两个系统调用——epoll_wait 通知你,然后 read/write 做实际的数据搬运。

在高并发场景下,这个 syscall 开销会累积成可观的代价。每个 syscall 都是一次用户态↔内核态上下文切换,当连接数上到万级、十万级,CPU 花在”进出内核”上的时间就不再可忽略。

// epoll 典型流程:epoll_wait + read,两次 syscall 每事件
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
    char buf[256];
    ssize_t count = read(events[i].data.fd, buf, sizeof(buf));
}

io_uring:2019 年登场的完成模型

io_uring 改变了游戏规则——它从就绪模型切换到了完成模型。你不再问内核”可以操作了吗?“,而是直接提交操作请求,内核做完后通知你”搞定了”。

核心数据结构是两个共享内存环形缓冲区(ring buffer):

  • Submission Queue (SQ):用户态往里写操作请求,内核消费
  • Completion Queue (CQ):内核往里写完成事件,用户态消费

这个设计把大部分工作移到了内核侧。你不再需要为每个 I/O 事件分别调用 read()/write()——一次 io_uring_enter() 调用可以批量提交和收割一整批操作。默认模式下仍然有一次 syscall,但它是按批次计费而非按操作计费。如果启用 IORING_SETUP_SQPOLL,内核会启动一个专用线程持续轮询 SQ,连这个 syscall 都可以省掉——代价是这个线程即使队列空了也在烧 CPU。

// io_uring 流程:一次提交 + 等待完成,无额外 read()
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, STDIN_FILENO, buf, sizeof(buf), 0);
io_uring_submit(&ring);                   // 批量提交

struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);           // 收割完成事件

io_uring 还支持零拷贝(io_uring_register_buffers)、操作链(linked operations)、多射模式(multishot)等高级特性。对网络发送场景,IORING_OP_SEND_ZC(需要内核 6.0+)可以跳过内核缓冲区的拷贝。

HN 上的争论:快是快了,代价呢?

文章在 HN 登顶后,评论区迅速变成了一个多维度的大讨论。以下是几个最有信息量的方向。

性能提升真实吗?

Uptrenda 实测了约 20% 的 req/s 提升Cloudef 指出在大缓冲区零拷贝场景下 io_uring 是王者,但在某些场景下他甚至用 poll 模拟 io_uring 跑得更快。thomashabets2 分享了用 Rust + io_uring + kTLS 构建 Web 服务器的经验,最卡的地方是 sendfile 在 io_uring 中还不原生支持,只能通过 splice + pipe 模拟,每个连接多耗 5 个 SQ slot。

eatonphil 则写了一篇三方案对比(epoll / io_uring / thread-per-connection)的实践文章,进一步补充了基准数据。

CPU 占用反而高了?

MathMonkeyMan 报告了一个有趣的现象:把 asio 的 epoll 后端换成 io_uring 后,CPU 使用率反而上去了。焦虑吗?vlovich123 的解释非常到位——这不是 bug,是 Jevons 悖论在系统层的体现。io_uring 降低了 I/O 操作的成本(更少的上下文切换、更少的内存拷贝),CPU 不再花时间”等”,转而去做更多实际工作。TinyGate 的 bench 目标应该是吞吐量和延迟,而不是盯着 CPU 百分比。

toast0 补充了一个更实操的建议:测结果,别看仪表盘。相同负载下 A/B 方案的延迟分布、最大吞吐、以及是否有其他可观测指标反映剩余容量,这些比 CPU 占用率更有意义。

安全:绕不开的话题

这是评论里火药味最浓的一块。Uptrenda 直言 io_uring “kernel opt-in and disabled just about everywhere for security reasons”,过去几年它一直是内核提权漏洞的头号或二号攻击面。insanitybit 补了一刀:即使不考虑 io_uring 本身的漏洞历史,它也绕过了 seccomp 和 audit 子系统,所以在容器/沙箱环境里几乎一定被禁。

但转折来了——Asmod4n 指出最新的内核 RC 已经加入了 cBPF 支持,可以像 seccomp 过滤普通 syscall 一样,精细控制 io_uring 允许哪些操作。mort96 则持保留态度:功能有了,但得先撑过一段没有新漏洞的时期,才能谈默认启用的可能性。

csdreamer7 提到 RHEL 9/10 已正式默认支持 io_uring,这覆盖了大量企业级部署。Go 社区长期对 io_uring 持谨慎态度,这个信号可能会改变一些团队的评估。

CPU pinning:另一维度的优化

toast0 看了 TinyGate 的源码后指出了一个 epoll vs io_uring 讨论之外的关键优化点:CPU pinning。把线程绑定到核心,把监听 socket 绑定到 CPU(SO_INCOMING_CPU),让数据包从网卡到应用全程不跨核心传输。这个优化跟用 epoll 还是 io_uring 无关,但对反向代理的性能提升可能比切换 I/O 模型本身更大。

作者 Sibexico 回复说 v0 和 v1 是几乎完全不同的实现,目前正在做第三个版本。

buybackoff 则提到了另一个方向——epoll_wait 的 busy poll 模式,Fastly 在用,可以在不切换到 DPDK/io_uring 的情况下实现用户态忙轮询。附带了 netdev 会议 slides 和 LWN 文章的链接。

其他声音

  • gafferongames 丢了一句”Just use AF_XDP”,干脆利落
  • muststopmyths 指出 Windows 上对应的技术是 Registered I/O (RIO)
  • Asmod4n 认为现有 async I/O 框架都还没真正吃透 io_uring 的能力(操作链、零 syscall 模式),所以目前 io_uring 的实际收益被框架层打了折扣

小结

epoll 是经过 17 年验证的成熟方案,简单、稳定、到处都有。但如果你的系统运行在 5.1+ 内核上,且安全策略允许(RHEL 9/10 已默认放行),io_uring 提供了更高效的完成模型、批量提交和零拷贝能力。

社区讨论表明,选择 I/O 模型只是高性能服务端优化链条上的一环。CPU pinning、内存分配器选择、NIC 流量定向、忙轮询策略——这些”旁边”的优化,有时比换 I/O 模型本身更见效。

本文素材来自 Sibexico 的博客文章和 HN 相关讨论。如果你对这个话题有更深入的一手经验,欢迎指出文中的不足。