Linux io_uring 异步 I/O 革命

Linux io_uring 异步 I/O 革命:从内核Submission Queue到生产级性能工程

Linux 内核 5.1 引入的 io_uring,经过五年演进,已成为 Linux 平台高性能异步 I/O 的事实标准。它不仅解决了 POSIX AIO(libaio)长期存在的种种限制,还以独特的零拷贝共享内存队列设计,将用户态与内核态之间的 I/O 通信开销降到接近零。Nginx、PostgreSQL、Rust tokio、Go netpoll、SPDK、QEMU 等关键基础设施已深度集成 io_uring。本文将深入剖析 io_uring 的核心架构,结合代码示例讲解关键生产级特性,并讨论工程落地中的真实权衡。

一、诞生于缺陷:为什么 Linux AIO 无法满足需求

在 io_uring 出现之前,Linux 已有的异步 I/O 方案存在根本性缺陷。

传统的 O_NONBLOCK select/poll/epoll 模型本质上是通知型的:"数据准备好了,来读吧"。但每次 I/O 仍需通过同步 read/write 系统调用完成,期间仍需 CPU 参与数据拷贝。epoll 解决的是事件通知效率问题,不是 I/O 本身的异步化。

因此出现了 POSIX AIO(Linux 通过 io_submit / io_getevents 实现),这套方案本意是真正的异步 I/O 提交,但存在以下严重限制:

设计缺陷清单

  • 仅支持 O_DIRECT 模式使用:必须使用直接 I/O,绕过页缓存。这导致普通文件系统场景必须自行管理缓存层,工程代价极高。
  • 不支持 buffered I/O:无法使用内核页缓存加速随机读,丧失了 Linux 内存管理的热数据优势。
  • 每次 I/O 必须新分配 iocb 结构:无法预分配和复用,高并发场景下内存分配带来显著延迟。
  • 不支持连接型操作(linked operations):无法表达 "先读头再读数据" 的工作流。
  • kernel 内部使用 aio_ring 环形缓冲区:该缓冲区不可 mmap,每次完成事件仍需系统调用获取。
// 传统 AIO 使用方式:必须 O_DIRECT,繁琐且受限
struct iocb cb = { .aio_lio_opcode = IOCB_CMD_PREAD,
                   .aio_fildes = fd,
                   .aio_buf = buf,
                   .aio_nbytes = size,
                   .aio_offset = offset };
struct iocb *cbs[] = { &cb };
io_submit(ctx, 1, cbs);
struct io_event events[1];
io_getevents(ctx, 1, 1, events, NULL); // 又是一次 syscall

Jens Axboe(io_uring 作者,同时也是 blk-mq 块层革命的设计者)对这些问题给出了一个根本性的重新设计。

二、io_uring 核心架构:Shared Ring Buffer 设计哲学

io_uring 最核心的设计是整个系统仅在初始化时通过一次系统调用建立映射,随后所有 I/O 提交和完成都通过两个无锁环形缓冲区完成,零系统调用贯穿常态操作路径。

这两道环形缓冲区分别为:

Submission Queue (SQ) — 提交队列

  • 用户态 io_uring 库写入 Submission Queue Entry (SQE)
  • 内核 IO 工作线程从 SQ 中取出 SQE 并执行
  • SQ 在生产者-消费者协议下工作,用户态为生产者,内核为消费侧

Completion Queue (CQ) — 完成队列

  • 内核 I/O 工作线程写入 Completion Queue Entry (CQE) 到 CQ
  • 用户态应用轮询或事件驱动方式读取 CQE
  • 内核为生产者,用户为消费者

这两个环形缓冲区通过 mmap 直接映射到用户态地址空间,整个通信路径无需任何系统调用。

初始化代码直观认识

#include <liburing.h>

struct io_uring ring;

// 初始化:一次 syscall 完成所有设置
int ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
// 此后 ring.sq.ring_ptr 和 ring.cq.ring_ptr 都已 mmap 到用户空间

// 获取一个 SQE(无锁,仅用户态操作)
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe, fd, &iov, 1, offset);
io_uring_sqe_set_data(sqe, my_request_context);

// 提交 SQE:批量刷入内核环形队列
io_uring_submit(&ring);

// 等待 CQE 出现
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);

关键点:io_uring_submit 本身不触发系统调用,它只更新 SQ 的 tail index。只有以下情况才会触发系统调用(io_uring_enter):

  1. 用户显式请求 io_uring_enter
  2. 设置了 IORING_SETUP_SQPOLL 时内核线程自动处理
  3. 设置了 IORING_ENTER_GETEVENTS 时等待

这意味着正确使用时,整个 I/O 生命周期可以做到零系统调用。

三、缓冲区注册与文件注册:避免 per-I/O 开销

生产环境高并发 I/O 场景中,每次 read/write 的 mmap 页固定和文件 fd 查找本身就是开销大头。io_uring 提供了两种预注册机制。

Fixed Buffers(IORING_REGISTER_BUFFERS)

预先注册一组缓冲区,内核在 I/O 入口处直接获取,免去每次的 get_user_pages / pin 操作。

#define BUF_SIZE  (4 * 1024)
#define BUF_COUNT 128

struct iovec iovecs[BUF_COUNT];
for (int i = 0; i < BUF_COUNT; i++) {
    posix_memalign(&iovecs[i].iov_base, BUF_SIZE, BUF_SIZE);
    iovecs[i].iov_len = BUF_SIZE;
}

// 一次性注册整个缓冲区数组
int ret = io_uring_register_buffers(&ring, iovecs, BUF_COUNT);

// 使用预注册缓冲区提交读
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, NULL, BUF_SIZE, offset, buf_index);

Fixed Buffers 对于固定大小 I/O(如数据库页面读取、KV 引擎 value 加载)效果极为显著,可降低约 20-30% 的单次 I/O 延迟。

Fixed Files(IORING_REGISTER_FILES)

预先注册一批文件描述符,提交时用 IOSQE_FIXED_FILE 标志 + index 直接引用,避免内核每次 fget_light() 查找 file 结构的开销。

int file_fds[] = { fd_database, fd_wal, fd_general_log, ... };
io_uring_register_files(&ring, file_fds, 4);

// 提交时直接用 index
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0, buf, len, offset);  // fd=0 → file_fds[0]
sqe->flags |= IOSQE_FIXED_FILE;

四、SQPOLL 模式:内核侧轮询,彻底零 syscall

IORING_SETUP_SQPOLL 模式创建一个内核线程,该线程持续轮询 SQ 并将 SQE 取出处理。配合 IORING_SETUP_SQ_AFF 可将轮询线程绑定专属 CPU 核:

struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF;
params.sq_thread_idle = 2000;       // 空闲 2ms 后线程休眠
params.sq_thread_cpu = 2;           // 绑定 CPU2

io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);

SQPOLL 模式下,只要 SQ tail 有更新,内核线程自动感知,用户态完全无需系统调用。

实测数据(Jens Axboe 在 io_uring 内核文档中数据):

模式 单核 4K 随机读延迟 单核 IOPS
sync read ~6μs ~160K
libaio (O_DIRECT) ~4.5μs ~220K
io_uring (basic) ~2.5μs ~400K
io_uring (fixed buffers + SQPOLL) ~1.8μs ~550K

五、多射(Multi-shot)操作:网络代理的性能倍增器

io_uring 5.19+ 引入了 multi-shot recv/send 特性,一次提交可以多次触发完成事件,极大降低了 accept + read + write 工作流中的提交次数。

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv_multishot(sqe, client_fd, NULL, 0, 0);
sqe->buf_group = BG_ID;           // 使用 automatic buffer selection
sqe->flags |= IOSQE_BUFFER_SELECT; // 自动分配接收缓冲区

传统 epoll + 同步 read 的工作流每次事件循环至少 2-3 次系统调用。io_uring multi-shot 场景下,一次 recv multishot 提交即可在同一个 CQE stream 中返回客户端多次发送数据。对于网关类场景,multi-shot 可以减少约 60-70% 的系统调用次数。

六、工作流链接(Linked Operations):原子化批量 I/O

io_uring 支持 IOSQE_IO_LINK 标记表达操作间的前后顺序依赖,即使 SQE 是无序的,链接操作仍保证顺序执行。

struct io_uring_sqe *sqe;

// 步骤1:读取文件头
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, &header_buf, sizeof(header), 0);
sqe->flags |= IOSQE_IO_LINK;

// 步骤2:仅当步骤1完成后才执行
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, &payload_buf, header.payload_len, header.payload_offset);
sqe->flags |= IOSQE_IO_LINK;

// 步骤3:写日志
sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, log_fd, log_entry, log_len, -1);

// 一次性提交三个有依赖的 SQE
io_uring_submit(&ring);

所有三个 SQE 一次提交,内核保证按序执行,无需用户态线程在中间做 wait + re-submit。

七、生产级工程实践:一个简单的 KV Storage 引擎示例

让我们看一个实际的、可编译运行的 io_uring KV 引擎骨架:

#include <liburing.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>

#define QUEUE_DEPTH 256
#define BLOCK_SIZE  4096
#define MAX_KEYS    (1 << 16)

struct kv_store {
    struct io_uring ring;
    int backing_fd;
    uint32_t offsets[MAX_KEYS];
};

struct request {
    uint64_t user_data;
    int completed;
    ssize_t result;
};

int kv_init(struct kv_store *kv, const char *path, int keys) {
    kv->backing_fd = open(path, O_RDWR | O_CREAT, 0644);
    if (kv->backing_fd < 0) return -1;

    fallocate(kv->backing_fd, 0, 0, keys * BLOCK_SIZE);

    struct io_uring_params params = {0};
    params.flags = IORING_SETUP_SQPOLL;
    params.sq_thread_idle = 2000;

    int ret = io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);
    if (ret < 0) return ret;

    struct iovec *iovecs = malloc(sizeof(struct iovec) * keys);
    for (int i = 0; i < keys; i++) {
        posix_memalign(&iovecs[i].iov_base, BLOCK_SIZE, BLOCK_SIZE);
        iovecs[i].iov_len = BLOCK_SIZE;
    }
    io_uring_register_buffers(&ring, iovecs, keys);
    return 0;
}

int kv_read(struct kv_store *kv, uint32_t key, void *buf, struct request *req) {
    if (key >= MAX_KEYS) return -1;

    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    if (!sqe) {
        io_uring_submit(&ring);
        sqe = io_uring_get_sqe(&ring);
    }

    io_uring_prep_read_fixed(sqe, kv->backing_fd, NULL,
                             BLOCK_SIZE, key * BLOCK_SIZE, key);
    io_uring_sqe_set_data64(sqe, (uint64_t)req);
    req->completed = 0;

    return 0;
}

void kv_flush(struct kv_store *kv) {
    io_uring_submit(&ring);
}

int kv_poll_completions(struct kv_store *kv) {
    struct io_uring_cqe *cqe;
    int count = 0;

    for (;;) {
        int ret = io_uring_peek_cqe(&ring, &cqe);
        if (ret == -EAGAIN) break;

        struct request *req = (struct request *)cqe->user_data;
        req->result = cqe->res;
        req->completed = 1;
        count++;

        io_uring_cqe_seen(&ring, cqe);
    }
    return count;
}

八、内核调度:io_wq 工作队列模型

io_uring 在内核侧使用 io_wq(I/O Workqueue)执行异步操作。io_wq 是一个动态调度的工作线程池,按需创建和销毁内核工作线程(io_worker)。

关键细节:

  • io_wq 使用 bounded 和 unbounded 两种 worker。bounded worker 用于有 REQ_F_NOWAIT 的快速路径;unbounded worker 用于可能阻塞的操作(如 buffered read 需等待页回写)。
  • 内核 5.15+ 引入了 io_wq 的 NUMA 感知调度,worker 分配时会参考 SQ 所属 CPU 的 NUMA node。
  • 通过 IORING_REGISTER_IOWQ_MAX_WORKERS 可以限制最大 worker 数,防止高并发下内核线程数爆炸。

九、与 io_uring 生态系统的集成

Rust: tokio-uring

tokio-uring 实现了在 tokio runtime 上无缝绑定 io_uring 作为 I/O 后端。

use tokio_uring::fs::File;

async fn read_file() -> Vec<u8> {
    let file = File::open("data.bin").await.unwrap();
    let buf = vec![0u8; 4096];
    let (res, buf) = file.read_at(buf, 0).await;
    res.unwrap();
    buf
}

Go: uring 库与 netpoll

Go runtime 的 netpoll 目前以 epoll 为基础,但社区已有实验性 io_uring 后端(如 github.com/godoes/uring),能将 Go 中的网络 I/O 更高效地提交到 io_uring。

SPDK/DPDK

SPDK(Storage Performance Development Kit)早已使用 io_uring 作为其 kernel 后端的异步 I/O 引擎,尤其是处理 NVMe passthrough 场景。

PostgreSQL

PostgreSQL 17 beta 已引入实验性 io_uring 支持,替代传统的 master + worker 共享 I/O 模式,大幅降低 vacuum 和 checkpointer 的 I/O 等待时间。

QEMU/KVM

QEMU 通过 io_uring 加速 qcow2 镜像和 raw 镜像的 guest 块 I/O,虚拟化层 storage I/O latency 显著降低。

十、限制与生态考量

io_uring 并非魔法,落地前需认清以下现实:

1. 安全性与权限模型

io_uring 强大的异步能力也带来了安全风险。2023 年 Google Project Zero 报告了多个 io_uring 相关的 CVE。Chrome 沙箱自此默认禁用 io_uring。内核引入了 CONFIG_SECURITY_IO_URING 和 per-io_uring 的 proc 可见性控制。

# 全局禁用 io_uring(某些容器环境)
sysctl io_uring_disabled=1
# 仅允许特权进程
sysctl io_uring_disabled=2

2. 版本碎片化

不同 Linux 版本 io_uring 支持特性差异较大:

  • 5.1: 基础 read/write/fsync
  • 5.5: fixed files/buffers, 非阻塞尝试提交
  • 5.10: SQPOLL 的 DEFER_TASKRUN 支持
  • 5.15: multi-shot accept/recv
  • 5.19: 更多 multi-shot 操作、TCP support
  • 6.0+: 单 provider multi-shot、socket 绑定优化
  • 6.6+: 文件 write fixed with appended buffer、IORING_RECVSEND_POLL_FIRST

3. Operation Coverage 不完全

并非所有内核路径都已异步化。例如:

  • stat() / statx() 操作在高度并行场景仍可能有阻塞点
  • 部分网络协议栈路径仍依赖 workqueue(如 TCP 拥塞控制发送路径)
  • 一些特殊文件系统(如 overlayfs + XFS 组合)的写回表现不一致

4. 调试与可观测性

io_uring 的异步性使得传统 strace 类工具失去意义。目前主要依赖:

  • IORING_REGISTER_RING_FDS + perf 观察
  • bpftrace 追踪 io_uring_* 系列 tracepoint
  • /proc/<pid>/io_uring 查看 ring 状态

十一、生产级使用策略总结

根据场景特征选择合适的 io_uring 配置:

场景 推荐配置 理由
SSD-backed KV Store SQPOLL + Fixed Files + Fixed Buffers 极低延迟,批量提交
HTTP 反向代理 Multi-shot recv/send + Linked SQEs 降低 per-request syscall
Database WAL Multi-shot accept + Fixed Files 批量 flush 控制
通用 Web Server Basic io_uring (offload slow ops) 保持兼容,渐进采用
容器/沙箱环境 Basic + 限制 DISABLE 启动 安全隔离优先

关键原则:io_uring 的价值不在"用或不用",而在于渐进式 adoption:例如仅将磁盘读写路径异步化,网络部分仍走 epoll,最终随内核成熟度和组织能力拓展到更多 subsystem。

总结

io_uring 是当前 Linux 内核中最具变革意义的基础设施之一。它通过共享环形缓冲区的设计,将异步 I/O 从"面向系统调用的包装"升级为"面向共享内存的协作",彻底改变了 Linux 上高性能 I/O 的复杂度分布。从 SPDK 的 NVMe 驱动到 PostgreSQL 的 WAL 管理,从 Rust 的 tokio 到 Go experimental netpoll,io_uring 正在重塑存储与网络性能的工程实践。

随着内核迭代,io_uring 的操作覆盖面、稳定性、安全模型都在持续完善。对于任何对 I/O 性能有严格要求的服务端项目,io_uring 已是必须纳入技术栈的核心选择。


参考内核源码:linux/io_uring/ 目录,主要作者 Jens Axboe (@axboe)。最新版本测试于 Linux 6.6~6.11 LTS 分支。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部