Read OSS

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 面临两种都不够理想的选择:

  1. 同步系统调用read/write)——简单但会阻塞。每次调用都要经历完整的系统调用进入/退出路径。
  2. 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 开头的注释说明了内存顺序约定:

io_uring/io_uring.c#L1-L41

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_sqestruct io_uring_cqe。这正是第 1 篇中讨论的稳定 ABI 边界所在。

操作定义模式

io_uring 支持数十种操作类型(read、write、send、recv、accept、connect、poll、timeout 等),每种操作都由一个 struct io_issue_def 来描述:

io_uring/opdef.h#L7-L44

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 用于执行操作。

所有操作被收录到一张以操作码为索引的分发表中:

io_uring/opdef.c#L54-L60

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.cio_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 驱动。