Linux 异步 I/O:Epoll vs. io_uring

io_uring 是 Linux 上现代的异步 I/O 标准,它提供了一种基于完成(completion-based)的模型,极大地减少了处理高吞吐量网络流量所需的系统调用次数。虽然 epoll 几十年来一直是行业标准,但 io_uring 消除了与就绪通知相关的冗余系统调用,允许开发者批量处理操作并实现更高的吞吐量。

就绪模型:epoll

Epoll 是一个基于就绪(readiness-based)的系统。它在文件描述符就绪进行 I/O 操作时通知应用程序,但它本身并不执行操作。由于每个事件所需的系统调用数量,这造成了显著的性能瓶颈:

  • 注册: 一次性调用 epoll_ctl 来注册套接字。
  • 通知: 调用 epoll_wait 来确定哪些套接字已就绪。
  • 执行: 调用单独的 read()write() 来实际移动数据。

由于每次系统调用都会触发用户模式和内核模式之间的上下文切换,在处理成千上万个并发连接时,开销变得难以承受。这就是一种先验的架构限制,通常会导致基于 epoll 的简单代理无法超越像 Nginx 或 HAProxy 这样经过高度优化的工具。

完成模型:io_uring

在 Linux 内核 v5.1 (2019) 中引入的 io_uring 将架构从就绪转向了完成。io_uring 不再是告诉应用程序何时可以读取,而是告诉应用程序读取何时完成

工作原理

Io_uring 利用了应用程序和内核之间共享的两个环形缓冲区(ring buffers):

  1. 提交队列 (SQ): 应用程序在此处放置 I/O 请求。
  2. 完成队列 (CQ): 内核在此处放置已完成操作的结果。

这种共享内存架构允许应用程序通过单个 io_uring_enter() 系统调用提交一批多个 I/O 操作并获取多个完成结果。在某些情况下,开销可以降低到几乎为零。

高级性能特性

  • SQPOLL (提交队列轮询): 通过使用 IORING_SETUP_SQPOLL,会创建一个专门的内核线程来轮询提交队列。这使得在稳定状态下,应用程序完全不需要调用 io_uring_enter(),尽管这会增加 CPU 使用率,因为即使在队列为空时线程也会旋转。
  • 零拷贝 I/O: 开发者可以使用 io_uring_register_buffers() 来避免每次操作时内核对内存的重新映射。对于网络发送,IORING_OP_SEND_ZC(在内核 6.0+ 中可用)允许内核完全跳过将缓冲区拷贝到内核空间的过程。

对比总结

特性 epoll io_uring
模型 就绪 (就绪时通知) 完成 (完成后通知)
系统调用开销 高 (每个事件 2+ 次系统调用) 低 (通过 SQPOLL 可实现 1 次系统调用处理一批或 0 次)
内核版本 遗留版本 (自 2002 年起) 现代版本 (v5.1+, 2019)
复杂度 相对简单 较高 (需要管理环形缓冲区)

实现考虑与权衡

虽然 io_uring 提供卓越的性能,但它也引入了特定的工程挑战和安全问题:

安全与稳定性

由于某些安全风险,一些环境默认禁用了 io_uring。因为它在内核和用户态之间使用直接内存共享,它一直是多个漏洞利用的目标。这就是为什么一些高性能运行时(如 Go)默认不使用它的原因。

错误处理

与立即返回错误的同步系统调用不同,io_uring 的错误是异步返回的。它们位于完成队列条目 (CQE) 的 res 字段中,这需要一种不同的错误管理方法。

进一步的性能优化

为了将性能推向超越 io_uring 的极限,开发者可以探索:

  • CPU Pinning: 将线程和监听套接字 (SO_INCOMING_CPU) 绑定到特定核心,以避免跨 CPU 通信。
  • 内存对齐: 使用专门的分配器,如 mimallocconcurrencykit,来实现内存对齐的缓冲区。
  • 内核旁路 (Kernel Bypass): 在极端情况下,使用 DPDK (Data Plane Development Kit) 可以完全绕过内核,尽管这会显著增加实现复杂度。

Sources