io_uring:深入内核异步 I/O 引擎
前置知识
- ›第 1 篇:架构与目录结构概览
- ›第 4 篇:系统调用路径与 VFS
- ›理解内存顺序与原子操作
- ›熟悉异步 I/O 概念
io_uring:深入内核异步 I/O 引擎
在第 4 篇中,我们完整追踪了系统调用路径:权限切换、寄存器保存与恢复、Spectre 缓解措施、分发、VFS 遍历以及返回。这一流程已经很快——但当应用程序每秒执行数百万次 I/O 操作时,每次都要进出内核的开销就会成为真正的瓶颈。io_uring 通过在内核与用户空间之间共享环形缓冲区来消除这一开销,从而在热路径上无需任何系统调用即可完成 I/O 的提交与完成。
io_uring 的重要程度足以让它拥有自己的顶层目录——这是极少数从 fs/ 中独立出来、直接放在内核源码树根目录的子系统之一。正如第 1 篇目录结构图中所示,它通过顶层 Kbuild 中的 obj-$(CONFIG_IO_URING) += io_uring/ 进行条件编译。
io_uring 为何而生
传统 Linux I/O 面临两种都不够理想的选择:
- 同步系统调用(
read/write)——简单但会阻塞。每次调用都要经历完整的系统调用进入/退出路径。 - aio(Linux AIO)——真正的异步,但仅限于文件的直接 I/O,API 繁琐且每次操作的开销很高。
高性能服务器(数据库、Web 服务器、存储引擎)需要每秒处理数十万次 I/O 操作。在这种规模下,第 4 篇中介绍的系统调用开销——swapgs、CR3 切换、pt_regs 构造、Spectre 缓解——乘以每一次操作后,累积影响就变得相当可观。
io_uring 通过共享内存设计解决了这个问题:用户空间将提交队列条目(SQE)直接写入内核可见的内存,内核则将完成队列条目(CQE)写回。在 SQ poll 模式下,就连通知内核有新任务这一步也不需要系统调用。
环形缓冲区架构
核心数据结构是一对通过 mmap() 在用户空间与内核之间共享的环形缓冲区:
flowchart LR
subgraph "Userspace Process"
SQT["SQ Tail (written by app)"]
CQH["CQ Head (written by app)"]
end
subgraph "Shared Memory (mmap'd)"
subgraph "Submission Queue"
SQE1["SQE 0"]
SQE2["SQE 1"]
SQE3["SQE ..."]
SQE4["SQE N"]
end
subgraph "Completion Queue"
CQE1["CQE 0"]
CQE2["CQE 1"]
CQE3["CQE ..."]
CQE4["CQE N"]
end
end
subgraph "Kernel"
SQH["SQ Head (written by kernel)"]
CQT["CQ Tail (written by kernel)"]
end
SQT -->|"smp_store_release"| SQE1
SQH -->|"smp_load_acquire"| SQE1
CQT -->|"smp_store_release"| CQE1
CQH -->|"smp_load_acquire"| CQE1
io_uring.c 开头的注释说明了内存顺序约定:
A note on the read/write ordering memory barriers that are matched between
the application and kernel side.
After the application reads the CQ ring tail, it must use an
appropriate smp_rmb() to pair with the smp_wmb() the kernel uses
before writing the tail (using smp_load_acquire to read the tail will
do). It also needs a smp_mb() before updating CQ head (ordering the
entry load(s) with the head store), pairing with an implicit barrier
through a control-dependency in io_get_cqe.
这是一个无锁的单生产者/单消费者协议:应用程序生产 SQE 并消费 CQE,内核则消费 SQE 并生产 CQE。唯一的同步手段是内存屏障,其开销远低于锁或系统调用。
保存所有这些状态的主上下文结构体是 struct io_ring_ctx:
include/linux/io_uring_types.h#L271-L289
struct io_ring_ctx {
/* const or read-mostly hot data */
struct {
unsigned int flags;
unsigned int drain_next: 1;
unsigned int task_complete: 1;
unsigned int lockless_cq: 1;
unsigned int syscall_iopoll: 1;
...
与第 3 篇调度器中的 struct rq 类似,这里的字段也经过精心分组——const or read-mostly hot data(常量或只读热数据)与频繁写入的字段分开存放,以尽量减少缓存竞争。
提示: UAPI 头文件
include/uapi/linux/io_uring.h定义了用户空间所见的结构体——struct io_uring_sqe和struct io_uring_cqe。这正是第 1 篇中讨论的稳定 ABI 边界所在。
操作定义模式
io_uring 支持数十种操作类型(read、write、send、recv、accept、connect、poll、timeout 等),每种操作都由一个 struct io_issue_def 来描述:
struct io_issue_def {
unsigned needs_file : 1;
unsigned plug : 1;
unsigned ioprio : 1;
unsigned iopoll : 1;
unsigned buffer_select : 1;
unsigned hash_reg_file : 1;
unsigned unbound_nonreg_file : 1;
unsigned pollin : 1;
unsigned pollout : 1;
...
unsigned short async_size;
int (*issue)(struct io_kiocb *, unsigned int);
int (*prep)(struct io_kiocb *, const struct io_uring_sqe *);
};
这是 C 语言 vtable 模式的又一体现,但粒度比 VFS 更细。每个操作通过位域声明自身的能力(是否需要文件?是否支持 iopoll?是否支持 buffer 选择?),并提供两个函数指针:prep 用于验证并准备 SQE,issue 用于执行操作。
所有操作被收录到一张以操作码为索引的分发表中:
const struct io_issue_def io_issue_defs[] = {
[IORING_OP_NOP] = {
.audit_skip = 1,
.iopoll = 1,
.prep = io_nop_prep,
.issue = io_nop,
},
[IORING_OP_READV] = {
.needs_file = 1,
.unbound_nonreg_file = 1,
.pollin = 1,
.buffer_select = 1,
...
.prep = io_prep_readv,
.issue = io_read,
},
...
代码按操作族分文件组织:rw.c 负责读写,net.c 负责网络操作,poll.c 负责轮询,timeout.c 负责超时,依此类推。
| 源文件 | 操作 |
|---|---|
io_uring/rw.c |
READV, WRITEV, READ_FIXED, WRITE_FIXED |
io_uring/net.c |
SENDMSG, RECVMSG, SEND, RECV, ACCEPT, CONNECT |
io_uring/poll.c |
POLL_ADD, POLL_REMOVE |
io_uring/timeout.c |
TIMEOUT, TIMEOUT_REMOVE, LINK_TIMEOUT |
io_uring/openclose.c |
OPENAT, CLOSE |
io_uring/sqpoll.c |
SQ poll 线程管理 |
io_uring/io-wq.c |
工作线程池 |
操作生命周期:prep → issue → completion
内核处理提交队列条目时,遵循清晰的生命周期流程:
flowchart TD
A["Userspace writes SQE<br/>to submission queue"] --> B["Kernel reads SQE<br/>(smp_load_acquire)"]
B --> C["io_issue_defs[opcode].prep(req, sqe)<br/>Validate and prepare"]
C --> D{"prep result?"}
D -->|Success| E["io_issue_defs[opcode].issue(req, flags)<br/>Execute operation"]
D -->|Error| G["Post CQE with error"]
E --> F{"Result?"}
F -->|Complete| H["Post CQE to completion queue<br/>(smp_store_release tail)"]
F -->|Would block| I["Delegate to io-wq<br/>worker thread"]
I --> H
prep 阶段在内核消费 SQ 时同步执行。它会验证 SQE 的各个字段、提取参数,并初始化内部的 struct io_kiocb 请求。如果准备失败(文件描述符无效、标志位不合法),则立即写入一个错误 CQE。
issue 阶段尝试完成操作。对于许多操作——尤其是命中页面缓存的情况——可以立即成功返回。如果操作会发生阻塞(例如读取不在缓存中的数据),则返回 -EAGAIN,请求被移交给 io-wq 工作线程池进行异步处理。
completion 阶段将带有结果的 CQE 写入完成环。应用程序在下次消费 CQ 时即可看到结果。
SQ Poll 模式与 io-wq 工作线程池
SQ Poll 模式(IORING_SETUP_SQPOLL)
在标准模式下,应用程序仍需调用一次系统调用 io_uring_enter() 来通知内核"有新的 SQE 了"。SQ poll 模式连这一步也省去了:一个专用的内核线程持续轮询提交队列,检查是否有新条目。
该轮询线程运行在 io_uring/sqpoll.c 的 io_sq_thread() 函数中。它会在可配置的时间内持续自旋,寻找新的 SQE;当队列空闲时才进入休眠以节省 CPU。进入休眠后,应用程序可以通过 SQ flags 中的 IORING_SQ_NEED_WAKEUP 标志感知到这一状态,并发送一次唤醒系统调用。
这在稳定运行状态下实现了真正的零系统调用 I/O:应用程序向共享内存写入 SQE、读取 CQE,内核的轮询线程负责处理提交。对于高吞吐量负载(NVMe 存储、高速网络),系统调用开销被彻底消除。
flowchart LR
subgraph "Standard Mode"
A1["App writes SQE"] --> A2["App calls io_uring_enter()"]
A2 --> A3["Kernel processes SQEs"]
end
subgraph "SQ Poll Mode"
B1["App writes SQE"] --> B2["Kernel poll thread<br/>sees new SQE"]
B2 --> B3["Kernel processes SQEs"]
end
io-wq 工作线程池
并非所有操作都能在不阻塞的情况下完成。当 issue() 返回 -EAGAIN 时,请求会被排入 io-wq 工作线程池,其实现位于 io_uring/io-wq.c。该线程池维护两类工作者:
- 有界工作者(bounded workers)——处理无需额外文件访问的任务(数量上限,防止资源耗尽)
- 无界工作者(unbounded workers)——处理非普通文件(socket、pipe)上的任务,此类任务可能需要大量并发线程
io-wq 子系统负责管理线程的创建、休眠/唤醒以及跨线程池的任务窃取,本质上是一个专为 io_uring 需求量身定制的内核线程池。
提示: 对 io_uring 进行基准测试时,留意 io-wq 线程的创建情况——如果看到大量工作线程被创建,说明你的操作正在阻塞。切换到直接 I/O(
O_DIRECT)或确保数据已在页面缓存中,可以让操作保持在快速的内联路径上执行。
下一步
至此,我们已经了解了进入内核的两条路径:传统的系统调用路径(第 4 篇)和基于共享内存的 io_uring 路径。在最后一篇文章中,我们将探索内核最新引入的编程语言——Rust。我们将看到 kernel crate 如何将这些相同的 C 接口(VFS 操作、驱动注册、initcall 机制)封装成安全的 Rust 抽象,并深入剖析一个真实的 Rust GPU 驱动。