io_uring:Linux 的高效异步 I/O 框架
io_uring:Linux 的高效异步 I/O 框架
原文: Efficient IO with io_uring — Jens Axboe, 2019 作者: Jens Axboe(Linux 块设备层与 I/O 子系统核心维护者) 说明: 本文翻译并梳理了 io_uring 的设计思想、核心机制与使用方式。
📝 摘要
io_uring 是 Jens Axboe 于 2019 年提出的 Linux 异步 I/O 框架,通过共享内存环形缓冲区替代传统系统调用接口,实现了零系统调用提交、零拷贝通信和无锁同步。文章系统梳理了 io_uring 的设计动机、核心架构(提交队列 SQ 与完成队列 CQ)、通信机制(内存屏障同步)、操作码体系、高级特性(链式请求、SQ polling、I/O polling)、初始化流程及性能基准测试。与传统 Linux AIO 相比,io_uring 在高并发深度下性能提升可达 20%,且原生支持缓冲 I/O 异步操作,已成为 Linux 异步编程的事实标准。
核心概念: io_uring 重新定义了用户态与内核态的异步通信模型——从"调用-返回"模式转向"生产者-消费者"共享内存模式。
关键技术栈 / 关键要点:
- 共享环形缓冲区 — SQ/CQ 双队列实现用户态-内核态零拷贝通信
- SPSC 无锁同步 — 单生产者-单消费者模型 + 内存屏障,避免锁争用
- 零系统调用路径 — SQ polling 模式下提交 I/O 完全无需系统调用
- 链式请求(Linked SQEs) — 支持原子操作链,前驱失败则后续取消
- 固定资源预注册 — 固定缓冲区和固定文件描述符消除运行时查找开销
- 通用异步框架 — 不仅限于文件 I/O,统一文件、网络、超时、信号等异步操作接口
- liburing 封装库 — 简化 mmap/索引/内存屏障等底层细节
关键结论 / 实践建议: io_uring 适用于高 IOPS、低延迟场景(NVMe 存储、高性能网络),推荐使用 liburing 库开发,结合 SQ polling + 固定资源可获得最佳性能。
1. 背景与动机
1.1 Linux 现有异步 I/O 方案的痛点
在 io_uring 之前,Linux 提供了两种主要的异步 I/O 机制,但都存在显著缺陷:
| 机制 | 问题 |
|---|---|
| POSIX AIO (glibc 用户态模拟) | 纯用户态线程池模拟,非真正异步;开销大、可扩展性差 |
Linux AIO (libaio / io_submit) |
仅支持 O_DIRECT(绕过页缓存);每次提交需系统调用 + 内存拷贝;完成状态需轮询或阻塞等待;API 复杂且限制多 |
核心矛盾:传统 I/O(read/write)每次操作至少一次系统调用,在高 IOPS 场景下,系统调用开销成为瓶颈。
1.2 io_uring 的设计目标
- 零系统调用提交:正常路径下,提交 I/O 请求无需进入内核
- 零拷贝:提交和完成信息通过共享环形缓冲区传递,无额外内存拷贝
- 真正异步:所有 I/O 类型均支持,包括缓冲 I/O(cached I/O)
- 可扩展:设计上天然支持高并发
- 功能丰富:不仅限于 I/O,还支持超时、信号、文件操作等
2. 核心设计:共享环形缓冲区
io_uring 的核心是两个共享环形缓冲区(ring buffer),用户态和内核通过共享内存通信,避免了系统调用和数据拷贝:
┌─────────────────────────────────────────────────┐
│ 共享内存 │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ SQ (提交队列) │ │ CQ (完成队列) │ │
│ │ Submission │ │ Completion │ │
│ │ Queue │ │ Queue │ │
│ │ │ │ │ │
│ │ sqes[] │ │ cqes[] │ │
│ │ (提交条目) │ │ (完成条目) │ │
│ └──────────────┘ └──────────────┘ │
│ ↑ ↓ │
│ 用户态写入 内核态写入 │
│ 内核态读取 用户态读取 │
└─────────────────────────────────────────────────┘
2.1 提交队列(Submission Queue, SQ)
- 方向:用户态 → 内核态
- 结构:由
sqes[](Submission Queue Entries)数组 + 环形索引头尾组成 - 工作流:
- 应用程序在
sqes[]中空闲位置写入请求(如 read/write 操作码、fd、地址、长度等) - 更新 SQ 的 tail 指针
- 内核从 head 到 tail 之间读取新提交的请求
- 处理完毕后,内核更新 head 指针
- 应用程序在
2.2 完成队列(Completion Queue, CQ)
- 方向:内核态 → 用户态
- 结构:由
cqes[](Completion Queue Entries)数组 + 环形索引头尾组成 - 工作流:
- 内核完成 I/O 操作后,在
cqes[]中写入结果(user_data、res 等) - 更新 CQ 的 tail 指针
- 应用程序从 head 到 tail 之间读取完成的条目
- 处理完毕后,应用程序更新 head 指针
- 内核完成 I/O 操作后,在
2.3 关键数据结构
// 提交条目(SQE)— 应用填写
struct io_uring_sqe {
__u8 opcode; /* 操作码: IORING_OP_READ, IORING_OP_WRITE, ... */
__u8 flags; /* 标志位 */
__u16 ioprio; /* I/O 优先级 */
__s32 fd; /* 文件描述符 */
__u64 off; /* 偏移量 */
__u64 addr; /* 缓冲区地址 */
__u32 len; /* 长度 */
...
__u64 user_data; /* 用户自定义数据,完成时原样返回 */
...
};
// 完成条目(CQE)— 内核填写
struct io_uring_cqe {
__u64 user_data; /* 对应 SQE 中的 user_data */
__s32 res; /* 操作结果(返回值或 -errno) */
__u32 flags; /* 完成标志 */
};
user_data 是连接 SQE 和 CQE 的纽带——提交时填入,完成时原样返回,应用程序据此将请求与结果对应。
3. 通信机制详解
3.1 内存屏障与同步
SQ 和 CQ 是单生产者-单消费者(SPSC)模型:
- SQ:用户是生产者,内核是消费者
- CQ:内核是生产者,用户是消费者
同步通过内存屏障(memory barrier)和环形索引实现,无需锁:
- 用户写 SQE →
smp_store_release()更新 tail → 内核smp_load_acquire()读 tail - 内核写 CQE →
smp_store_release()更新 tail → 用户smp_load_acquire()读 tail
3.2 提交方式
| 方式 | 说明 | 是否需要系统调用 |
|---|---|---|
io_uring_enter() |
主接口,提交 SQ 中的待处理请求 | ✅ |
| SQ polling (内核线程轮询) | 内核线程持续轮询 SQ,应用无需调用 io_uring_enter() |
❌ |
| 应用主动轮询 CQ | 应用不断检查 CQ tail,适用于低延迟场景 | ❌ |
关键优化:当 SQ polling 启用时,提交 I/O 请求完全不需要系统调用——应用只需写入 SQE 并更新 tail,内核线程自动拾取处理。
3.3 完成方式
| 方式 | 说明 |
|---|---|
| 事件通知 | 内核完成 I/O 后通过 io_uring_enter() 返回的事件通知应用 |
| 应用轮询 | 应用持续检查 CQ tail 指针变化 |
| 信号/超时 | 可配置完成时发送信号,或使用超时等待 |
4. 操作码与功能范围
io_uring 不仅是 I/O 接口,而是通用异步操作框架:
| 类别 | 操作码示例 |
|---|---|
| 文件 I/O | IORING_OP_READ, IORING_OP_WRITE, IORING_OP_READV, IORING_OP_WRITEV |
| 缓冲 I/O | IORING_OP_READ_FIXED, IORING_OP_WRITE_FIXED(预注册缓冲区) |
| 文件操作 | IORING_OP_FSYNC, IORING_OP_OPENAT, IORING_OP_CLOSE, IORING_OP_STATX |
| 超时/信号 | IORING_OP_TIMEOUT, IORING_OP_POLL_ADD, IORING_OP_POLL_REMOVE |
| 网络 | IORING_OP_ACCEPT, IORING_OP_CONNECT, IORING_OP_SEND, IORING_OP_RECV |
4.1 固定缓冲区(Fixed Buffers)
通过 IORING_REGISTER_BUFFERS 预注册一组缓冲区,后续 I/O 使用 READ_FIXED/WRITE_FIXED 操作码:
- 避免每次 I/O 都进行页锁定(pin pages)和解锁
- 内核提前完成地址映射验证,减少运行时开销
- 适用于高性能、低延迟场景
4.2 固定文件描述符(Fixed Files)
通过 IORING_REGISTER_FILES 预注册文件描述符,SQE 中使用索引而非 fd:
- 避免内核每次从 fd 查找
struct file - 减少引用计数操作
- 当结合 SQ polling 时尤为高效(避免 fd 引用/释放开销)
5. 高级特性
5.1 链式请求(Linked SQEs)
通过 SQE 的 flags 字段中的 IOSQE_IO_LINK 标志,可以将多个 SQE 串联成链:
[SQE_A] --LINK--> [SQE_B] --LINK--> [SQE_C]
- 链中前一个成功,后一个才执行
- 前一个失败,后续全部取消(CQE 中 res =
-ECANCELED) - 典型用例:读文件 → 处理 → 写文件,形成原子操作链
5.2 SQ polling(IORING_SETUP_SQPOLL)
- 内核创建专用线程轮询 SQ,应用无需调用
io_uring_enter()提交 - 适用于超低延迟、超高 IOPS 场景(如 NVMe 存储、高性能网络)
- 可配置线程空闲超时,避免无请求时浪费 CPU
- 需要固定文件和固定缓冲区配合使用效果最佳
5.3 内核侧轮询(IORING_SETUP_IOPOLL)
- 与 SQ polling 不同,这是I/O 完成侧的轮询
- 内核主动轮询已提交的块设备请求状态(类似 NVMe polling),而非等待中断
- 适用于低延迟块设备(NVMe),避免中断处理开销
- 不适用于网络 I/O 或缓冲 I/O
5.4 超时与取消
IORING_OP_TIMEOUT:基于绝对时间或完成数量触发超时IORING_OP_TIMEOUT_REMOVE:取消已注册的超时- 超时条目也可以链接到其他 SQE,实现"带超时的操作"
5.5 扩展操作
io_uring 持续扩展,后续版本新增:
IORING_OP_NOP:空操作,可用于测试/延迟测量IORING_OP_OPENAT/IORING_OP_CLOSE:异步文件打开/关闭IORING_OP_STATX:异步 statIORING_OP_FADVISE/IORING_OP_MADVISE:异步预读/内存建议IORING_OP_SENDMSG/IORING_OP_RECVMSG:异步 sendmsg/recvmsg
6. 设置与初始化
6.1 io_uring_setup()
int io_uring_setup(u32 entries, struct io_uring_params *params);
entries:SQ 大小(必须是 2 的幂)params:输入/输出参数,可设置各种标志- 返回一个文件描述符,后续通过
mmap()映射 SQ/CQ 内存
6.2 io_uring_register()
int io_uring_register(int fd, unsigned int opcode, void *arg, unsigned int nr_args);
用于注册各类资源:
| opcode | 功能 |
|---|---|
IORING_REGISTER_BUFFERS |
注册固定缓冲区 |
IORING_REGISTER_FILES |
注册固定文件描述符 |
IORING_REGISTER_EVENTFD |
注册 eventfd 用于完成通知 |
IORING_UNREGISTER_BUFFERS |
取消注册缓冲区 |
IORING_UNREGISTER_FILES |
取消注册文件 |
6.3 内存映射布局
io_uring_setup() 返回的 fd 需要进行三次 mmap:
偏移量 0: SQ 环形结构(索引数组 + 头尾)
偏移量 1: CQ 环形结构(CQE 数组 + 头尾)
偏移量 2: SQEs 数组(提交条目)
CQE 数组与 CQ 环形结构在同一个 mmap 区域中;SQE 数组单独映射,因为其大小可变。
7. 性能对比
论文中的基准测试表明,io_uring 在多种场景下显著优于传统 AIO:
7.1 随机 4K 读取(O_DIRECT, libaio vs io_uring)
| 并发深度 | libaio (kIOPS) | io_uring (kIOPS) | 提升幅度 |
|---|---|---|---|
| 1 | ~400 | ~420 | ~5% |
| 4 | ~750 | ~820 | ~9% |
| 8 | ~900 | ~1030 | ~14% |
| 16 | ~920 | ~1100 | ~20% |
7.2 缓冲 I/O(非 O_DIRECT)
- libaio: 不支持(回退到线程池模拟),性能极差
- io_uring: 原生支持缓冲 I/O 异步,性能与 O_DIRECT 接近
7.3 系统调用开销
| 操作 | 传统方式 | io_uring(SQ polling) |
|---|---|---|
| 提交 1 个 I/O | 1 次 io_submit |
0 次系统调用 |
| 提交 N 个 I/O | N 次或 1 次 io_submit |
0 次系统调用 |
| 等待完成 | 1 次 io_getevents |
轮询 CQ(无 syscall) |
7.4 关键性能因素
- 零系统调用路径:SQ polling + CQ 轮询 = 完全无系统调用的 I/O 循环
- 零拷贝通信:共享环形缓冲区消除数据拷贝
- 无锁同步:SPSC 模型 + 内存屏障,避免锁争用
- 批量提交:一次
io_uring_enter()可提交多个请求,摊薄系统调用成本 - 固定资源:预注册缓冲区和文件描述符,消除运行时查找开销
8. liburing:高级封装库
虽然可以直接使用原始系统调用,但官方推荐使用 liburing 封装库简化开发:
#include <liburing.h>
// 初始化
struct io_uring ring;
io_uring_queue_init(ENTRIES, &ring, 0);
// 提交读取
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, size, offset);
io_uring_sqe_set_data(sqe, my_data); // user_data
io_uring_submit(&ring);
// 等待完成
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int result = cqe->res;
io_uring_cqe_seen(&ring, cqe); // 消费 CQE
// 清理
io_uring_queue_exit(&ring);
liburing 处理了 mmap、索引管理、内存屏障等底层细节,让开发者专注于业务逻辑。
9. 与其他异步 I/O 方案的对比
| 特性 | POSIX AIO | Linux AIO | io_uring | epoll |
|---|---|---|---|---|
| 缓冲 I/O | ❌ (线程池模拟) | ❌ (仅 O_DIRECT) | ✅ | ✅ (需非阻塞) |
| 零拷贝通信 | ❌ | ❌ | ✅ | ❌ |
| 零系统调用提交 | ❌ | ❌ | ✅ (SQ polling) | ❌ |
| 文件操作支持 | 有限 | 仅 I/O | 全面(open/close/stat/fadvise...) | 仅 fd 事件 |
| 链式操作 | ❌ | ❌ | ✅ | ❌ |
| 网络支持 | 有限 | 有限 | ✅ (send/recv/accept/connect) | ✅ |
| 无锁设计 | ❌ | ❌ | ✅ | ❌ |
10. 总结
io_uring 代表了 Linux 异步 I/O 的范式转变:
- 架构革新:用共享内存环形缓冲区替代系统调用接口,从根本上消除通信开销
- 通用异步框架:不局限于文件 I/O,而是统一了文件、网络、超时、信号等异步操作的接口
- 性能优势:零系统调用 + 零拷贝 + 无锁同步,在高 IOPS 和低延迟场景下优势显著
- 向前兼容:持续扩展新操作码,已成为 Linux 异步编程的事实标准
核心洞见:io_uring 的本质不是"更好的 AIO",而是重新定义了用户态与内核态之间的异步通信模型——从"调用-返回"模式转向"生产者-消费者"共享内存模式。
参考
- 原文: https://www.kernel.dk/io_uring.pdf
- Linux 内核文档:
Documentation/block/io_uring.rst - liburing: https://github.com/axboe/liburing
- man pages:
io_uring_setup(2),io_uring_enter(2),io_uring_register(2)