从 epoll 到 io_uring:Linux 异步 I/O 的范式迁移
# 导语
在 Linux 高并发网络编程里,异步 I/O 的实现方式几乎决定了服务端程序的性能上限。作者从教学项目 TinyGate 反向代理的迭代讲起:第一版采用简单 worker 架构,能用但性能有限;第二版切到 epoll 后吞吐显著提升;而为了继续逼近 nginx、HAProxy 这类成熟系统,他们最终又转向 io_uring,并为此重写项目。文章的核心问题是:为什么曾经的主流 epoll 正在被 io_uring 挑战,二者到底差在哪里?
# 核心内容
作者首先回顾了
epoll 的历史位置。epoll 在 2002 年进入 Linux 内核,长期以来几乎是 Linux 下处理大量连接的标准方案。它采用的是“就绪通知”模型:内核告诉应用某个文件描述符“可以读”或“可以写”,随后应用仍然需要自己调用 `read()` 或 `write()` 完成真正的数据传输。也就是说,一次 I/O 事件通常至少涉及 `epoll_wait()` 加实际读写两个系统调用;文件描述符还需要通过 `epoll_ctl()` 预先注册。对于连接数和事件量很高的服务来说,频繁从用户态切换到内核态会形成可观开销。
io_uring 则代表另一种思路。它在 Linux 5.1 时代出现,采用“完成通知”模型:应用把要执行的 I/O 请求写入与内核共享的提交队列(Submission Queue),内核执行后再把结果写入完成队列(Completion Queue)。这两个队列都是环形缓冲区,因此得名 io_uring。应用不再是先等“可用”,再单独读写;而是提交一批操作,随后收取一批完成结果。默认情况下仍需要 `io_uring_enter()` 通知内核处理队列,但一次调用可以批量提交和回收多个操作,系统调用成本从“按操作付费”变成更接近“按批次付费”。若使用 `IORING_SETUP_SQPOLL`,还可以让内核线程主动轮询提交队列,在稳定负载下进一步减少系统调用,不过代价是额外 CPU 消耗。
文章还用简化 C 示例对比两者:epoll 需要创建实例、注册文件描述符、等待事件、再调用 read;io_uring 则创建 ring 后提交读请求,等待完成项,并从完成项中读取结果。作者强调,示例省略了生产环境必须处理的细节,例如队列满时 `io_uring_get_sqe()` 可能返回空、标准输入无数据时可能阻塞等。
# 深度解读
这篇文章有价值的地方,不只是宣称 io_uring 更快,而是指出了 Linux I/O 编程模型的根本变化:
从 readiness 到 completion。epoll 的优势是成熟、简单、可移植性较好,尤其适合事件驱动网络服务;但它本质上仍要求应用程序围绕“事件循环 + 后续系统调用”组织逻辑。io_uring 则把更多工作前移到提交队列,并把完成结果异步返回,让应用更像是在描述一组操作,而不是反复询问“现在能不能做”。
不过,io_uring 并不是无条件免费午餐。首先,它要求较新的内核,旧系统和保守发行版环境未必适合直接依赖。其次,完成模型会改变错误处理方式:错误不再像同步 syscall 那样立刻以返回值出现,而是异步写入 completion queue entry 的 `res` 字段,程序结构和调试心智都要随之调整。再次,SQPOLL 虽能减少 syscall,却会占用 CPU;zero-copy 也需要注册 buffer 或使用较新内核提供的 `IORING_OP_SEND_ZC`,并非打开开关就自然获得收益。
因此,真正的取舍不是“epoll 落后、io_uring 先进”这么简单,而是看项目是否愿意接受更现代但更复杂的内核接口。对新写的高性能代理、数据库、存储或网络服务,io_uring 的批处理、共享内存队列和完成通知模型很有吸引力;对已经稳定运行、性能瓶颈不在 syscall、或必须兼容旧内核的软件,epoll 仍然是可靠选择。
# 启示与展望
对工程读者来说,本文最大的启示是:性能优化往往不是换一个 API 名字,而是换一种系统设计方式。epoll 时代强调事件循环和非阻塞读写,io_uring 时代则更强调批量提交、异步完成、buffer 管理与内核协作。如果从零构建现代 Linux 服务,并且部署环境可控,优先研究 io_uring 是合理方向;但在迁移前,应先用真实业务负载 benchmark,而不是只依据理论 syscall 数量决策。未来 Linux 高性能 I/O 很可能继续向 io_uring 集中,但成熟软件生态仍会长期保留 epoll:前者代表上限,后者代表稳态。