一、io_uring 的本质:绕过一切,直达真相
Linux 开发者久受同步 IO 系统调用模型之苦:read()/write() 每次陷入内核、每次数据拷贝、每次阻塞等待。即便 2001 年就有了 aio(POSIX AIO),但由于其设计上的种种限制(仅支持 O_DIRECT 文件、glibc 实现为线程池模拟、无法处理 socket),在 Linux 异步 IO 领域一直是鸡肋般的存在。
Jens Axboe 看到了问题的根源:不在于"异步"做不到,而是"同步"的系统调用接口本身就是瓶颈。于是 io_uring 被设计为一种全新的异步 IO 框架——它不改良 API,而是重建了用户态与内核之间的通信机制。
io_uring 的核心设计包含三个子系统:
- Submission Queue (SQ):用户态向内核提交 IO 请求的环形缓冲区
- Completion Queue (CQ):内核向用户态返回完成事件的环形缓冲区
- io-wq (Worker Queue):内核侧异步执行线程,负责将阻塞操作卸载到线程中执行
这三个子系统通过 共享内存 + 原子操作 实现用户态与内核之间零系统调用的 IO 提交与收割,从而实现了真正的"零开销异步"。
二、三大系统调用:建立通信基础设施
理解 io_uring 的第一步是理解它暴露的三个系统调用,每个对应不同的职责阶段。
2.1 io_uring_setup:创建环的"握手"
io_uring_setup(unsigned entries, struct io_uring_params *p) 是第一个调用,它的任务非常清晰:在内核中分配数据结构,并将两个环形缓冲区映射到用户空间。
struct io_uring_params { __u32 sq_entries; // SQ 条目数(实际可能向上取 2^n) __u32 cq_entries; // CQ 条目数(通常 >= SQ) __u32 flags; // IORING_SETUP_IOPOLL / SQPOLL 等 __u32 sq_thread_cpu; // SQPOLL 线程 CPU 亲和 __u32 sq_thread_idle;// SQPOLL 线程空闲超时(ms) __u32 features; // 内核支持的特性位 struct io_sqring_offsets sq_off; // SQ 区域偏移量 struct io_cqring_offsets cq_off; // CQ 区域偏移量 };
内核在 io_uring_setup() 函数中做以下事情:
- 参数校验:
entries必须在 [1, 4096] 范围内,且向上补齐到 2 的幂(保证位运算取模) - 分配
struct io_ring_ctx上下文结构(这是每个 io_uring instance 的核心管理结构) - 分配 SQ 和 CQ 环形缓冲区,通过
kvmalloc()分配内核内存 - 填充
io_uring_params结构体,返回偏移信息给用户态 - 用户态通过
mmap()将 SQ、SQEs、CQ 三段内存映射到用户空间
为什么需要 SQEs 单独分配? 因为 SQE(Submission Queue Entry)是变长数据(包含 union of read/write/poll 等不同操作的参数),而 SQ 环形队列本身只存储 SQE 的 index。分离存储使得 SQ ring 可以是一个纯索引数组,由内核维护指针数组快速间接寻址。
2.2 io_uring_register:内核持有资源的"契约"
io_uring_register() 是第二个可选但极其重要的系统调用。它的功能是将资源提前注册到 io_uring 上下文中,避免每次 IO 操作都要经过内核的 fd 查找和 fget/fput 生命周期管理。
目前支持的注册操作包括:
| 操作码 | 功能 | 内核实现 |
|---|---|---|
IORING_REGISTER_BUFFERS | 注册一组预分配的缓冲区 | 内核建立 struct io_mapped_region 引用计数数组,io 直接使用 iovec |
IORING_UNREGISTER_BUFFERS | 注销缓冲区 | 释放映射引用 |
IORING_REGISTER_FILES | 注册一组文件描述符 | 内核 io_sqe_files_register() → fdget() 一次缓存 |
IORING_REGISTER_FILES_UPDATE | 更新注册的 fd 数组中某几项 | 热更新,免重新注册 |
IORING_REGISTER_EVENTFD | 注册 eventfd 用于 IO 线程通知 | 建立 poll 触发链 |
IORING_REGISTER_PROBE | 探测内核支持哪些 op 码 | 返回位图 |
IORING_REGISTER_PERSONALITY | 注册 persona(多用户凭证) | 隔离多个进程共享 ring |
注册的核心收益:IORING_OP_READ_FIXED/IORING_OP_WRITE_FIXED 可以直接使用注册的 buffer index 作为参数,绕过了内核 get_user_pages() 的开销,在 NVMe 场景下提升约 10-15% IOPS。
2.3 io_uring_enter:真正的"开关"
io_uring_enter() 是第三个也是唯一需要在 IO 循环中调用的系统调用。它有三个作用:
- 提交 SQE:告诉内核"新的 SQ tail 到此为止,请处理"
- 等待完成事件:如果
min_complete > 0,阻塞直到至少有min_complete个 CQE 可用 - 两种模式:
- 无 IOPOLL:
flags = 0,仅提交,不等待(非阻塞收割) - 有 IOPOLL:
IORING_ENTER_GETEVENTS,提交 + 阻塞等待
- 无 IOPOLL:
内核的 io_uring_enter() 函数做了什么?我们追踪关键路径:
// 内核源码简化路径: fs/io_uring.c io_uring_enter() ├─ // 1. 安全检查 ├─ io_submit_sqes(ctx, submitted) │ ├─ // 从 ring tail 读取 SQEs │ ├─ // 解析 op code → io_issue() │ │ ├─ IORING_OP_READV → io_read() │ │ ├─ IORING_OP_WRITEV → io_write() │ │ ├─ IORING_OP_FSYNC → io_fsync() │ │ ├─ IORING_OP_SENDMSG → io_sendmsg() │ │ └─ ... │ ├─ // 如果能直接完成(如 fixed buf),直接写 CQE │ └─ // 如果 blocking (如 file IO),加入 io-wq ├─ // 2. 如果需要等待 └─ io_cqring_wait(ctx, min_complete) └─ // 睡眠直到 CQE 数量 >= min_complete
关键认知:io_uring_enter() 不是每次 IO 都需要的。如果使用 SQPOLL 模式(内核轮询),用户态根本不需要调用 io_uring_enter(),内核线程会持续扫描 SQ 并自动提交。这是 io_uring 能达到亚微秒级延迟的关键。
三、环形队列上下文:共享内存的精确布局
io_uring 的零拷贝共享内存机制是理解所有高级特性的基础。io_uring_setup() 返回后,用户态通过 3 次 mmap() 映射三段内存区域:
3.1 三段内存映射
/* io_uring_params 中提供的偏移结构 */ struct io_sqring_offsets { __u32 head; // 写入位置:内核读取后更新 __u32 tail; // 新条目位置:用户态递增 __u32 ring_mask; // 取模掩码:(entries - 1) __u32 ring_entries; // 条目数量(2^n) __u32 flags; // SQ_DROPPED 等状态位 __u32 dropped; // 溢出丢弃计数 __u32 array; // SQEs 索引数组偏移 __u32 resv[3]; }; /* 用户态映射三段内存 */ sq_ring = mmap(..., sq_off_sq_ring, PROT_READ); // SQ 环形元数据 + head/tail cq_ring = mmap(..., sq_off_cq_ring, PROT_READ); // CQ 环形元数据 sqes = mmap(..., sq_off_sqes, PROT_READ); // SQE 条目数组(64 字节 × n)
3.2 Cached 与 Coherent 模型
io_uring head/tail 指针的更新机制是一个重要的内存模型问题:
- head:由 消费者 更新(谁消费谁写 head)。SQ 的头由内核更新,CQ 的头由用户态更新。
- tail:由 生产者 更新(谁生产谁写 tail)。SQ 的尾由用户态更新,CQ 的尾由内核更新。
关键在于:head 被消费者更新后对生产者立即可见。这通过以下机制保证:
- SMP 级别:x86 的 TSO 内存模型天然保证写操作的顺序一致性
- 编译屏障:内核使用
smp_store_release()写 head,用户态使用smp_load_acquire()读 head(liburing 封装了这些) - 缓存行优化:head/tail 各自的 cache line 分离(预取 padding),避免 False Sharing
3.3 SQE 的写入流程(用户态视角)
一个完整的 SQE 提交流程如下:
// 伪代码:用户态提交一个 readv 操作 // 1. 获取 SQ tail(读取当前写入位置) tail = sq->tail; index = tail & ring_mask; // 位运算取模 // 2. 填充 SQE 数组中对应索引的位置 sqe = &sqes[index]; sqe->opcode = IORING_OP_READV; sqe->fd = fd; sqe->addr = (uint64_t)iovec; sqe->len = iovec_count; sqe->off = 文件偏移; sqe->user_data = 请求的唯一标识; // 3. 将 SQE index 写入 SQ ring sq->array[index] = index; // 4. 发布 tail(让内核能看到) smp_store_release(&sq->tail, tail + 1); // release 语义 // 5. 通知内核(系统调用 or 内核轮询) if (!sqpoll) io_uring_enter(ring_fd, 1, 0, 0); // 仅提交
四、io-wq Worker 线程模型:异步执行的黑盒剖析
io_uring 中最复杂也最灵活的部分是 io-wq (io_uring worker queue)。对于阻塞操作(如普通文件 IO),内核不能直接在提交路径上阻塞当前的提交线程(来自 io_uring_enter 的系统调用上下文)。于是需要一个内核线程池来专门处理这些"不可能立即完成"的请求。
4.1 Worker 线程的生命周期
/* io_wq 核心结构体 */ struct io_wq { unsigned long state; struct io_wq_hash *hash; struct task_struct **fork_notification; atomic_t worker_refs; struct completion done; struct hlist_node cpuhp_node; struct io_wq_acct acct[IO_WQ_ACCT_NR]; // BOUND + UNBOUND 两类 }; struct io_wq_acct { unsigned nr_workers; // 当前 worker 数量 unsigned max_workers; // 最大 worker 数 atomic_t nr_running; // 正在执行的 worker 数 raw_spinlock_t wait_entry_lock; struct io_wq_work_list work_list; };
每个 io-wq 上下文维护两条 worker 队列:
- BOUND workers:绑定到特定的 CPU 核心,适用于要求低延迟的场景(如 NVMe 轮询模式)
- UNBOUND workers:不绑定 CPU,可以迁移到任何核心,适用于通用场景
4.2 动态扩缩容:基于负载的 Worker 管理
io-wq 最精妙的设计在于按需创建 worker、空闲超时销毁的弹性机制:
- 当新 work 入队时,如果所有已有的 worker 都在忙碌,且有新的 work 等待超过一定时间(默认 ~500ms),则创建新的 worker
- Worker 空闲超时(
acct->exit_wq标志 + 定时器)后自行退出 - Worker 数量受
max_workers限制(BOUND 类默认 = 每个 CPU 25% 的允许权重) - 非优先级 work 不得超过
WQ_NR_UNBOUND / 2 的并发限制
这种设计避免了 epoll + 线程池模型中"固定线程数"导致的要么资源浪费、要么上下文切换频繁的问题。
4.3 Work 的执行流水线
一个 io_wq work 的执行过程:
// worker 线程函数简化 io_worker_handle_work() ├─ // 1. 从 work_list 取出一个有 pending 的 work ├─ // 2. 检查是否需要取消(cancellation) ├─ // 3. 执行 io_issue()(真正的读写逻辑) │ ├─ if success: 直接写 CQ entry → 返回 │ └─ if rework needed: 加入 work 继续处理 ├─ // 4. 处理 priority 逻辑(LINK 链后续) └─ // 5. 检查 idle timeout → 决定是否退出
五、零拷贝进阶:Fixed Buffers 与 Fixed Files 的内核实现
io_uring 提供了两种"固定注册"机制来绕过热路径上的内核开销。
5.1 Registered Buffers (RQ/RINGBUF)
注册的 buffer 在内核中的表示如下:
struct io_mapped_ubuf { u64 ubuf; // 用户空间地址 u64 ubuf_end; unsigned int nr_bvecs; unsigned int bid; // buffer id struct bio_vec bvec[]; // 物理页描述符数组 };
注册时,内核遍历所有用户空间 buffer 的虚拟地址,通过 get_user_pages_fast() 获取对应的物理页(pin 住内存不被交换出去),提取为 bio_vec 数组。这样在每次 IO 执行时,不再需要重新做 get_user_pages(),且 buffer 的 mmap 和 pin 是一次性成本。
buffer provider ringbuf(也称为 BQ 模式)更进一步:每次 IO 完成时,选一个可用的 buffer 写入 CQ entry 中。这要求 buffer pool 管理:
- 用户态通过
IORING_OP_PROVIDE_BUFFERS将 buffer 归还 - 内核维护
io_buffer_list双向链表 - 支持
BUF_BGID分组,不同 IO 使用不同 buffer pool
5.2 Fixed Files:一次缓存 fd 一世
预注册 fd 到 io_uring 后,用户态可以使用 IOSQE_FIXED_FILE 标志占位索引,而非传递原始 fd。内核侧查表后直接 fget() 操作,省去了每次的 fd 查找(fdget() → files_fdtable() → array 索引 → fget_rcu())。
注册文件时的内核结构:
struct io_fixed_file_table { struct file **files; // 文件指针数组 unsigned long *bitmap; // 占用位图 size_t alloc_size; };
执行 FIXED 操作时的关键路径:io_grab_fixed_files() 直接索引 files[index],比原始 fd 的 RCU 查表快 1-2 个数量级。
六、内核 6.x 新特性:迈向无状态异步
Linux 内核在 6.1 到 6.12 之间为 io_uring 引入了大量新特性,下面列出最有影响力的几个。
6.1 IORING_SETUP_SUBMIT_ALL(6.1+)
旧行为:io_uring_enter() 提交时如果某个 SQE 失败,会立即停止提交后续条目。这导致"一个坏请求卡住所有后续"。
新行为:设置此 flag 后,内核会尽可能多地提交,只有真正无法恢复的错误才返回。某个请求失败不影响后续的成功执行。
6.2 Registered Ring(6.3+)
传统模式即使使用 SQPOLL,也需要在提交最后一个 SQE 前调用 io_uring_enter() 来刷新 SQ tail。Registered Ring 允许用户态在映射内存上直接写 tail 指针来"免系统调用的刷新":
- 前提:所有 fd、buffer 已注册,无其他需要系统调用的操作
- 效果:即使是 SQPOLL 模式也能真正零系统调用运行
6.3 Automatic Cancellation(6.6+)
自动取消特性分三层:
| 模式 | 触发条件 | 效果 |
|---|---|---|
IORING_ASYNC_CANCEL_FD | 取消指定 fd 的所有请求 | 常用于 fd close 前清理 |
IORING_ASYNC_CANCEL_ANY | 取消所有与该 context 关联的请求 | 常用于 exit 前清理 |
IORING_ASYNC_CANCEL_IO_URING | 当 ring fd 关闭时自动取消 | 防止资源泄漏 |
在内核实现中,取消通过红黑树(struct io_kiocb 按 key 排序)高效定位目标请求,对于 io-wq 中的 work 则设置 IO_WQ_WORK_UNBOUND 标记让 work 函数检测到后自己退出。
6.4 Quiesce Mode(6.5+)
quiesce 解决的问题:某些应用需要"暂停提交新请求,但有大量 in-flight 的要求在不中断的前提下不再接受新请求"。
设置 IORING_SETUP_QUESCE 后,内核进入静默模式:
- 可以接受"任意数量"的新 SQE,但不会自动提交
- 只有显式调用
io_uring_enter()才触发提交 - 适合与 signal-driven IO 配合(CPU 密集型 + IO 密集型的混合调度)
七、性能调优:从 syscall 到 polling 的实战指南
7.1 四种模式的延迟对比
| 模式 | 系统调用 | 适用场景 | P99 延迟 |
|---|---|---|---|
| 默认(无 SQPOLL) | 每次 io_uring_enter() 一次 | 一般服务器 | 80-150 μs |
| SQPOLL | 无(除非相邻刷写不足) | 高频 IO 服务 | 30-60 μs |
| SQPOLL + IOPOLL | 零 | NVMe 超低延迟 | 5-15 μs |
| SQPOLL + Registered Ring | 零 | 网络零拷贝转发 | 1-5 μs |
7.2 关键调优参数
/* /proc sysctl 调优 */ # worker 线程并发上限(默认 max 256) /proc/sys/kernel/io_uring/max_worker # SQPOLL 线程 ulimit ulimit -l unlimited // SQPOLL 使用 mlock 锁定内存,需提升 memlock ulimit -u unlimited // 线程上限 # io-wq 最大并发 work 数(/sys/fs/io_uring/max_work) / sys/fs/io_uring/max_work // 仅影响 UNBOUND 类
7.3 调试工具
- /proc/[pid]/io_uring:显示该进程所有 io_uring 实例的统计(CQ/SQ entries、dropped count)
- tracepoint:
tracefs/events/io_uring/提供 30+ 个 tracepoint(request_submit、io_workqueue_execute_start/end 等) - perf:
perf record -e 'io_uring:*' -a sleep 10可抓取全系统 io_uring 事件流
八、总结:io_uring 的设计哲学
io_uring 之所以能成为 Linux IO 的未来,不是因为某一个单独的特性,而是因为它围绕一个核心洞察构建了一整套连贯的设计:
- 共享内存是最快的 IPC:SQ/CQ 直接在用户态和内核之间共享,避免了系统调用的参数拷贝和特权级切换
- 一切资源都应该是可预注册的:fd、buffer、personality 提前交给内核,热路径上无 lookup,无锁化
- 线程应该是自管理的:io-wq 动态扩缩容,空闲自动销毁,没有任何需要用户态干预的策略参数
- 系统调用应该是可选的:SQPOLL + Registered Ring 可以实现真正的零系统调用运行
这种"零拷贝 + 零系统调用 + 零干预"的三位一体设计,使得 io_uring 不仅仅是 epoll 的替代——它正在重新定义用户态与内核通信的范式。从 SPDK 用户态驱动到 io_uring-based 网络栈(如 cloudflare 的 Quiche),再到 Rust 的 Tokio-uring 异步运行时,整个高性能 Linux 技术栈正在向 io_uring 收敛。return 0 的不是而是 IORING_OP_LAST——一个旧时代的终结和一个更激动人心时代的开始。

发表评论 取消回复