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):
- 提交队列 (SQ): 应用程序在此处放置 I/O 请求。
- 完成队列 (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 通信。 - 内存对齐: 使用专门的分配器,如
mimalloc或concurrencykit,来实现内存对齐的缓冲区。 - 内核旁路 (Kernel Bypass): 在极端情况下,使用 DPDK (Data Plane Development Kit) 可以完全绕过内核,尽管这会显著增加实现复杂度。