CheinTian / notes
Clipping · 8 分钟

io_uring:Linux 的高效异步 I/O 框架

kernel.dk Jens Axboe

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 重新定义了用户态与内核态的异步通信模型——从"调用-返回"模式转向"生产者-消费者"共享内存模式。

关键技术栈 / 关键要点:

  1. 共享环形缓冲区 — SQ/CQ 双队列实现用户态-内核态零拷贝通信
  2. SPSC 无锁同步 — 单生产者-单消费者模型 + 内存屏障,避免锁争用
  3. 零系统调用路径 — SQ polling 模式下提交 I/O 完全无需系统调用
  4. 链式请求(Linked SQEs) — 支持原子操作链,前驱失败则后续取消
  5. 固定资源预注册 — 固定缓冲区和固定文件描述符消除运行时查找开销
  6. 通用异步框架 — 不仅限于文件 I/O,统一文件、网络、超时、信号等异步操作接口
  7. 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),用户态和内核通过共享内存通信,避免了系统调用和数据拷贝:

plaintext
┌─────────────────────────────────────────────────┐
│                   共享内存                        │
│                                                   │
│  ┌──────────────┐        ┌──────────────┐        │
│  │  SQ (提交队列) │        │  CQ (完成队列) │        │
│  │  Submission   │        │  Completion   │        │
│  │  Queue        │        │  Queue        │        │
│  │               │        │               │        │
│  │  sqes[]       │        │  cqes[]       │        │
│  │  (提交条目)    │        │  (完成条目)    │        │
│  └──────────────┘        └──────────────┘        │
│         ↑                        ↓                │
│    用户态写入                 内核态写入            │
│    内核态读取                 用户态读取            │
└─────────────────────────────────────────────────┘

2.1 提交队列(Submission Queue, SQ)

  • 方向:用户态 → 内核态
  • 结构:由 sqes[](Submission Queue Entries)数组 + 环形索引头尾组成
  • 工作流:
    1. 应用程序在 sqes[] 中空闲位置写入请求(如 read/write 操作码、fd、地址、长度等)
    2. 更新 SQ 的 tail 指针
    3. 内核从 head 到 tail 之间读取新提交的请求
    4. 处理完毕后,内核更新 head 指针

2.2 完成队列(Completion Queue, CQ)

  • 方向:内核态 → 用户态
  • 结构:由 cqes[](Completion Queue Entries)数组 + 环形索引头尾组成
  • 工作流:
    1. 内核完成 I/O 操作后,在 cqes[] 中写入结果(user_data、res 等)
    2. 更新 CQ 的 tail 指针
    3. 应用程序从 head 到 tail 之间读取完成的条目
    4. 处理完毕后,应用程序更新 head 指针

2.3 关键数据结构

c
// 提交条目(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 串联成链:

plaintext
  [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:异步 stat
  • IORING_OP_FADVISE / IORING_OP_MADVISE:异步预读/内存建议
  • IORING_OP_SENDMSG / IORING_OP_RECVMSG:异步 sendmsg/recvmsg

6. 设置与初始化

6.1 io_uring_setup()

c
int io_uring_setup(u32 entries, struct io_uring_params *params);
  • entries:SQ 大小(必须是 2 的幂)
  • params:输入/输出参数,可设置各种标志
  • 返回一个文件描述符,后续通过 mmap() 映射 SQ/CQ 内存

6.2 io_uring_register()

c
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:

plaintext
偏移量 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 关键性能因素

  1. 零系统调用路径:SQ polling + CQ 轮询 = 完全无系统调用的 I/O 循环
  2. 零拷贝通信:共享环形缓冲区消除数据拷贝
  3. 无锁同步:SPSC 模型 + 内存屏障,避免锁争用
  4. 批量提交:一次 io_uring_enter() 可提交多个请求,摊薄系统调用成本
  5. 固定资源:预注册缓冲区和文件描述符,消除运行时查找开销

8. liburing:高级封装库

虽然可以直接使用原始系统调用,但官方推荐使用 liburing 封装库简化开发:

c
#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 的范式转变:

  1. 架构革新:用共享内存环形缓冲区替代系统调用接口,从根本上消除通信开销
  2. 通用异步框架:不局限于文件 I/O,而是统一了文件、网络、超时、信号等异步操作的接口
  3. 性能优势:零系统调用 + 零拷贝 + 无锁同步,在高 IOPS 和低延迟场景下优势显著
  4. 向前兼容:持续扩展新操作码,已成为 Linux 异步编程的事实标准

核心洞见:io_uring 的本质不是"更好的 AIO",而是重新定义了用户态与内核态之间的异步通信模型——从"调用-返回"模式转向"生产者-消费者"共享内存模式。


参考