"我对我们做的东西已经很满意了,但我的学生们不这么想——他们想做点真正有用的东西。" 一篇 epoll vs io_uring 的技术笔记,意外引出了一群真正的高手
故事是这样开始的
一位老师,带着学生做了一个反向代理服务器项目,叫 TinyGate。
教学项目嘛,能跑起来、原理讲得通,就算成功。老师对这个"基本能用的产品"挺满意。
但学生不买账。
他们想做点"真正有用"的东西,结果发现自己的代理服务器有架构上的硬伤,性能怎么都追不上 nginx 和 haproxy 这些工业级选手。学生们不死心,硬是把老师"架"着去研究——这些顶级工具底层到底是怎么处理异步 I/O 的,怎么把那些开销砍掉。
于是 TinyGate 经历了一次重写:从最初基于 worker 线程的简单版本,改成基于 epoll 的版本。性能确实有了质的飞跃,但依然打不过 nginx/haproxy。
研究继续深入,团队最终把目光转向了 io_uring——然后,项目又一次从零重写。
这篇文章,就是这场"被学生逼成专家"的旅程的技术笔记。HN 上 159 赞,37 条评论,质量相当高。
先搞懂两套机制的本质区别
epoll:我告诉你,可以读了
epoll 是 2002 年进入 Linux 内核的,在很长一段时间里,它几乎是唯一的选择。
它的工作模式是「就绪通知」:内核告诉你"现在这个 socket 可以读/写了",但读写这个动作,还得你自己调用 read()/write() 去完成。
问题就出在这里——这意味着每个 I/O 事件,你至少要付出 两次系统调用的代价:一次 epoll_wait 拿到"可以读了"的通知,一次 read 真正去读数据。再加上一次性的 epoll_ctl 注册开销。
每一次系统调用,都意味着一次用户态和内核态之间的上下文切换。连接数一旦上来,这个开销会被疯狂放大。
io_uring:我告诉你,已经读完了
17 年后,2019 年,io_uring 登场了。
它换了一种完全不同的模式:「完成通知」。你不需要先问"能读吗",再去读——你直接告诉内核"帮我把这个读完",内核做完了会直接把结果放进一个共享内存区域,等你来取。
这个共享区域是两个环形缓冲区(ring buffer)——这也是 io_uring 名字的由来:
- • 提交队列(SQ):你的程序往里面放"我要做的事"
关键的好处是:你可以一次性提交一整批操作,然后一次性收割一整批结果,而不是像 epoll 那样一个事件配一对系统调用。
如果你想把系统调用压到几乎为零,还有个狠招——IORING_SETUP_SQPOLL:开一个内核线程专门盯着提交队列,你的程序完全不需要主动调用 io_uring_enter()。代价是这个线程会一直空转占用 CPU(后面会细说这个坑)。
代码对比:少了多少东西?
文章用一个最简单的例子——读取标准输入——分别用两种方式实现。
epoll 版本,完整流程需要三次系统调用:
int epoll_fd = epoll_create1(0);
struct epoll_event ev, events[MAX_EVENTS];
ev.events = EPOLLIN;
ev.data.fd = STDIN_FILENO;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); // 注册
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); // 等通知
for (int i = 0; i < n; i++) {
if (events[i].data.fd == STDIN_FILENO) {
char buf[256];
read(STDIN_FILENO, buf, sizeof(buf)); // 真正去读
}
}
io_uring 版本:
struct io_uring ring;
io_uring_queue_init(8, &ring, 0);
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); // 等"已完成"
// cqe->res 里直接就是读到的字节数,不需要再调 read()
少掉的部分一目了然:
- • 完成时不需要再额外调用
read()——结果已经在 cqe->res 里了
当然,作者也诚实地提醒:io_uring_submit() 和 io_uring_wait_cqe() 内部依然藏着一次 io_uring_enter() 调用,除非你开了 SQPOLL,否则这次调用躲不掉。这两个示例也都简化了——比如 stdin 一直没数据的话会永久阻塞,io_uring 版本也没检查 sqe 为 NULL 的边界情况。
评论区:真正的硬核环节开始了
正文讲完了基础概念,但这篇文章真正的价值,是评论区里一群有实战经验的工程师补上的细节。这才是技术圈讨论的精髓——正文是地图,评论区是真正走过这条路的人留下的脚印。
🎯 性能优化:光换 io_uring 还不够
有经验丰富的网络工程师 toast0 看了眼 TinyGate 的代码,直接指出一个关键缺失:
没有做 CPU 绑定(cpu pinning)。把线程和监听 socket 都绑定到固定的 CPU 核心上(用 SO_INCOMING_CPU 这个 sockopt),通常能再榨出一些性能。
更进一步,他提到如果连出方向的 socket 也能做到 CPU 对齐,提升会更明显——虽然他承认,目前还没有特别好用的标准 API 来做这件事,但如果你知道网卡用的哈希算法(很可能是 Toeplitz),其实可以手动挑选源端口号来"凑出"合适的哈希结果。
核心目标只有一句话:让你的代理服务器处理数据包时,完全不需要跨核心通信。
紧接着 camkego 又补了一刀,提到一个经典的性能陷阱——伪共享(false sharing):不同核心上的线程如果写入了同一个 64 或 128 字节的缓存块,即使逻辑上是各写各的,也会因为缓存一致性协议而互相拖慢。
⚡ "切到 io_uring 之后 CPU 占用反而升高了"——这是 bug 还是正常?
评论区里一个很有意思的反直觉案例:MathMonkeyMan 提到他在一个数据库服务器上,把 asio 的后端从 epoll 换成 io_uring,CPU 占用率直接飙升。
这条评论引出了一场相当深入的讨论:
vlovich123 解释说,这其实是正常现象——本来花在"等 I/O"上的时间,现在变成了真正在干活的 CPU 时间。但也有人持不同观点,FooBarWidget 反驳说这个解释站不住脚:
epoll 本身就是非阻塞的,只要有活干就不会傻等。io_uring 提升的是 CPU 效率(比如把多次系统调用打包成一次),但并不会减少"阻塞等待"本身。
最终 vlovich123 给出了一个更完整的心智模型:io_uring 真正省下的,是上下文切换、锁竞争这些"硬件层面排队等待"的开销。这是一个经济学上的 Jevons 悖论——I/O 变得更便宜了,于是你单位时间内能做更多 I/O,CPU 因此更频繁地有真正的活要干,利用率自然就上去了。
这条解释拿到了这串讨论里最多的认同。
🔒 安全性争议:io_uring 是性能猛兽,也是漏洞重灾区
技术讨论里最现实的一块:Uptrenda 提到,io_uring 确实能带来约 20% 的请求处理速度提升,但代价是——它需要内核显式开启,并且在很多生产环境里默认被禁用,原因是安全问题。
它涉及内核和用户态之间的直接内存共享,这本身就有点"令人不安"。近年来 io_uring 多次成为漏洞利用的目标。正因如此,即便是像 Go 这样追求极致性能的工程项目,也没有把 io_uring 当作默认选项内置进去。
这条评论下面,有人提到了一个最新进展:随着 cBPF 支持的加入,现在可以精确限制 io_uring 能执行哪些操作,而不是简单粗暴地全面禁用——这个问题"上周才刚解决"。另外也有人提到,RHEL 9/10 现在已经默认完整支持 io_uring,这意味着大量企业级 Linux 环境已经可以放心用了。
不过也有更谨慎的声音:在很多环境里 io_uring 依然被 seccomp 限制关闭,因为它是一个不太配合内核安全审计子系统(audit subsystem)的"seccomp 绕过路径",历史上也确实是权限提升漏洞的高发区之一。
🛠️ 顺手安利的工具箱
讨论里还散落着不少干货级工具推荐:
- •
concurrencykit/ck 和 mimalloc——适合做零拷贝、内存对齐的反向代理 - •
libxdp——如果想做 DDoS 防护和更高级的四层处理 - • 有人提到用
splice(2) 可以在 io_uring 下实现类似 sendfile 的效果,虽然没有 sendfile 那么好用,但效果相近
😂 评论区的清醒吐槽
讨论的最后,GalaxyNova 留下了这条获得不少共鸣的吐槽:
"时间来到 2050 年,Linux 上轮询一个 socket 已经有 20 种不同的方法了。"
这大概是每个长期关注 Linux 内核演进的工程师都会会心一笑的真实写照——select → poll → epoll → io_uring,每一代都在解决上一代的问题,但选择困难症也跟着代代相传。
这篇文章和这场讨论,到底告诉我们什么
如果只看正文,结论很清晰:在支持 io_uring 的现代内核上(5.1+,2019 年发布),大部分场景下没什么理由还死守 epoll。从"就绪通知"到"完成通知"的范式转变,把大量原本要在应用层处理的工作,下放给了内核。
但评论区把这个"简单结论"拉回了工程现实:
- 1. 性能优化从来不是单一开关能解决的——io_uring 只是地基,CPU 亲和性、避免伪共享、零拷贝缓冲区注册,每一项都要单独抠
- 2. CPU 占用率本身不是好指标——评论区反复强调,要看的是吞吐量和延迟,不是"CPU 表盘上的数字变好看了"
- 3. 新技术的性能红利,往往伴随着尚未走完的安全成熟期——这恰恰是工程决策里最难拿捏的部分:你愿意用多新的内核特性,去换多少性能?
文章作者在结尾表达了自己鲜明的立场:
"对于一个基于现代 Linux 服务器、从零开始的项目,io_uring 绝对是正确的方向。我是坚定的'尽早抛弃老系统支持'拥护者——如果你现在还在跑一个发布超过 7 年的内核,这恐怕不是什么好主意。"
这是一个工程师的真实立场,但评论区的高手们提醒我们:技术选型从来没有免费的午餐,新范式带来的每一分性能提升,背后都对应着需要重新学习的运维成本、需要重新评估的攻击面,以及"你的系统到底跑在哪个内核版本上"这个永远绕不开的现实问题。
🔗 参考资料
- 1. 原文:epoll vs io_uring in Linux
- 4. io_uring 与 zero-syscall HTTPS 服务器(评论区提到的延伸阅读)
- 5. liburing 官方讨论区:网络场景下的内核限制
👋 如果你在生产环境用过 io_uring,欢迎在评论区聊聊踩过的坑。