← 返回信息流
AI 资讯Hacker News·2 小时前

Linux中Epoll与Io_uring性能对比解析

原标题:Epoll vs. Io_uring in Linux

速览

本文详细分析了Linux操作系统中Epoll与Io_uring两种I/O处理模型的核心机制。通过对比两者的架构设计与实际性能数据,揭示了Io_uring在降低系统调用开销和提升高并发场景下的优势。该研究为开发者优化高性能网络服务提供了重要的技术参考。

AI 深度解读

Linux 异步 I/O 深度解析:Epoll 与 Io_uring 的演进与抉择

背景

故事的起因源于一个教育项目。去年,作者与学生们共同构建了一个名为 TinyGate 的反向代理服务器。初版架构简单,采用基于 Worker 的模式,虽然功能上基本可用,且作为教学项目让作者感到自豪,但学生们并不满意。他们希望构建真正具有实用价值的工具,并对“产品”存在的架构局限性感到失望——特别是在性能上,无法与 nginxhaproxy 等巨头相抗衡。

为了突破这一瓶颈,学生们迫使作者深入研究这些高性能工具的内部机制,特别是如何处理异步 I/O 以减少巨大的开销。于是,团队开发了基于 epoll 的 TinyGate 第二版。尽管在基准测试中依然落后于 nginxhaproxy,但相比第一版,其性能有了显著提升。然而,epoll 并非完美无缺,最终团队决定转向 io_uring,并因此不得不对项目进行彻底的重写。这一经历促使作者深入调研 Linux 提供的两种异步 I/O 队列系统,并分享其核心差异与选型建议。

核心内容

Epoll 的遗产与局限

当作者刚开始在 Linux 上进行开发时,epoll 是当时唯一的选择,也是管理异步执行事实上的标准。然而,epoll 存在一个根本性的性能瓶颈:它严重依赖系统调用(syscalls)。

epoll 的工作模式是“就绪通知”(Readiness Model)。它告诉应用程序“何时可以进行 I/O”,但应用程序随后仍需手动调用 read()write() 来实际执行数据传输。这意味着每次 I/O 事件至少涉及两次系统调用:一次是 epoll_wait 检查就绪状态,另一次是实际的 read/write。加上最初注册文件描述符时的 epoll_ctl,每次 I/O 操作都伴随着用户态与内核态之间的上下文切换。在高并发连接场景下,这种开销是巨大的。

Io_uring 的崛起

epoll 于 2002 年引入 Linux 内核 17 年后,io_uring 于 2019 年(Linux 内核 v5.1+)登场,彻底改变了游戏规则。

io_uring 采用“完成通知”(Completion Model)。它不再告诉应用程序何时可以开始 I/O,而是直接通知应用程序 I/O 何时完成。这种架构转变将大量工作从用户态应用程序转移到了内核态。

其核心机制如下:

  1. 共享内存与环形缓冲区:内核与应用通过共享内存交互,数据存储在环形缓冲区(Ring Buffers)中。
  2. 批量处理:应用程序将 I/O 操作提交到提交队列(Submission Queue, SQ),内核处理完毕后,将结果写入完成队列(Completion Queue, CQ)。
  3. 减少系统调用:虽然默认情况下仍需调用 io_uring_enter() 来通知内核检查提交队列并回收完成结果,但一次调用可以提交一批操作并回收一批结果。这相比 epoll 中“每次操作一对系统调用”的模式,极大地减少了上下文切换。
  4. 零系统调用模式(SQPOLL):若启用 IORING_SETUP_SQPOLL,内核会启动一个专用线程轮询提交队列。在稳态下,应用程序几乎无需进行系统调用,代价是该内核线程会持续消耗 CPU 资源。

架构对比与代码实现

从架构上看,epoll 要求每次 I/O 操作跨越内核边界,而 io_uring 允许应用程序支付一次少量的“设置费”(创建环形缓冲区)和每批操作的“批次费”(io_uring_enter),而非每操作一次。

在代码层面,作者通过 C 语言示例展示了两者的差异:

  • Epoll 流程

    1. 创建实例。
    2. 注册文件描述符(如 stdin)。
    3. 等待事件(epoll_wait)。
    4. 执行读取(read)。
    • 开销:每个 I/O 事件涉及 3 次系统调用(1 次注册 + 1 次等待 + 1 次读取)。
  • Io_uring 流程

    1. 创建实例(使用 liburing 库)。
    2. 提交 I/O 请求(无需单独的就绪检查)。
    3. 等待完成(io_uring_wait_cqe)。
    4. 处理结果(无需单独的 read 调用)。
    • 开销:资源消耗显著降低。除非启用 SQPOLL,否则每个批次涉及 1 次 io_uring_enter 调用。

注意:示例代码为简化演示,省略了错误处理(如 stdin 无数据时的阻塞、提交队列满时 io_uring_get_sqe 返回 NULL 等情况)。

Io_uring 的额外特性

  1. 零拷贝(Zero-copy):通过 io_uring_register_buffers 预先注册缓冲区,避免内核在每次操作中重新映射内存。对于网络发送,Linux 6.0+ 支持 IORING_OP_SEND_ZC,可完全跳过将缓冲区复制到内核的步骤。
  2. SQPOLL 的 CPU 成本:即使队列为空,IORING_SETUP_SQPOLL 也会保持内核线程轮询,消耗 CPU。虽然存在空闲超时机制使其休眠,但这并非免费午餐。
  3. 异步错误处理:错误信息作为 cqe 结构体中 res 字段的一部分异步返回,而非像传统同步系统调用那样直接通过返回值返回。

关键要点

  • 模型转变epoll 是“就绪模型”(告诉我何时可以读/写),io_uring 是“完成模型”(告诉我何时读/写已完成)。后者将更多工作卸载至内核,减少了用户态开销。
  • 系统调用效率epoll 每次 I/O 至少需要两次系统调用(等待+读写);io_uring 支持批量提交和回收,单次系统调用可处理多个操作,显著降低上下文切换成本。
  • 性能优化选项:启用 IORING_SETUP_SQPOLL 可实现近乎零系统调用的稳态性能,但需承担内核线程持续轮询带来的 CPU 开销。
  • 零拷贝支持io_uring 提供了原生的零拷贝机制(如预注册缓冲区和 SEND_ZC 操作),进一步提升了网络 I/O 效率。
  • 错误处理差异io_uring 的错误处理是异步的,需从完成队列条目(CQE)中获取,而非直接的系统调用返回值。
  • 适用场景:在现代 Linux 内核(v5.1+)上,io_uring 在架构上优于 epoll,特别适合高并发、低延迟的网络服务开发。

意义与影响

io_uring 代表了现代 Linux 异步 I/O 的新标准。对于在最新 Linux 服务器上从头开始的项目(如作者重写的 TinyGate),io_uring 是绝对的首选。

作者强烈建议,在合理的前提下,应尽早放弃对旧系统的支持。如果服务器仍运行超过 7 年前的内核版本,从长远来看并非明智之举。随着 io_uring 成为主流,epoll 将逐渐退居二线,成为遗留系统的维护选项。这一技术演进不仅提升了 Linux 网络栈的性能上限,也要求开发者重新审视异步 I/O 的设计模式,从“轮询就绪”转向“提交并等待完成”,从而构建更高效、更简洁的高性能网络应用。

查看原文 →sibexi.co