Linux io_uring 深度实战:从内核异步 I/O 到用户态 I/O 栈的工程范式革命

2025 年的今天,Linux 异步 I/O 的格局已被 io_uring 彻底改写。从最初的补丁集到 5.1 合入主线,再到如今 6.x 内核中持续进化的 buffer ring、multishot accept、fixed buffer 等高级特性,io_uring 已经成为高性能存储引擎和网络框架的事实标准。本文将从内核实现原理出发,结合生产级代码示例,系统性地拆解 io_uring 的设计哲学与高级用法。


一、问题的起点:为什么 AIO 失败了

Linux 原生 POSIX AIO(aio_read/aio_write)自 2.6 时代就存在,但多年来饱受诟病:

  • 仅支持 O_DIRECT 路径:必须绕过页缓存,无法享受内核页缓存机制
  • 语义不一致:某些文件系统返回 EINVAL,管道完全不兼容
  • 阻塞式提交:io_submit() 内部仍可能因 inode 锁、文件系统元数据操作而阻塞
  • 缺乏批提交接口:用户态需要逐个构建 struct iocb,系统调用开销不可控

内核开发者 Jens Axboe(同时也是 block 层和 SPDK 的作者)认识到:这是一个架构层面的根本缺陷——AIO 试图在已有的同步 VFS 路径上"套皮"异步化,而非从零设计异步接口。2019 年,io_uring 应运而生。

二、io_uring 的核心架构:共享内存下的零拷贝协作

io_uring 的革命性在于彻底抛弃了传统 read/write 的双缓冲模型,采用共享内存环形队列作为内核与用户态的唯一通信通道:

  User Space                     Kernel
  ┌──────────┐               ┌──────────┐
  │ SQ Ring  │ ─生产者─────→ │ 读取SQE  │
  │(Submit   │               │ 执行I/O  │
  │ Queue)   │               │          │
  ├──────────┤               ├──────────┤
  │ CQ Ring  │ ←─生产者───── │ 写入CQE  │
  │(Complet- │               │          │
  │ ion Queue)│               └──────────┘
  └──────────┘

两个核心数据结构:

  • SQ(Submission Queue)环形队列:用户态作为生产者写入 Submission Queue Entry(SQE),内核作为消费者读取并执行
  • CQ(Completion Queue)环形队列:内核作为生产者写入 Completion Queue Entry(CQE),用户态作为消费者读取完成状态

关键设计点:两个环形队列通过 mmap 映射到用户态地址空间,消除了 read/write 系统调用中的数据结构拷贝开销。提交和完成事件通过共享内存中的 head/tail 指针同步,配合适当的内存屏障指令(smp_store_release / smp_load_acquire)保证可见性。

// 初始化一个可提交 4 个 SQE 的 io_uring 实例
struct io_uring ring;
int ret = io_uring_queue_init(4, &ring, 0);

这行代码背后发生了什么?内部通过 io_uring_setup 系统调用完成:

  1. 内核分配 SQ 和 CQ 的环形缓冲区
  2. 计算所需的 mmap 区域大小
  3. 返回含有文件描述符(io_uring fd)和 mmap 偏移信息的结构体
  4. 用户态 mmap 两次,分别映射 SQ 数组和 CQ 数组到用户地址空间

现在用户态可以直接通过指针操作 SQE 数组,无需任何 syscall。

三、基础 I/O 模式:从最简单的 read/write 开始

io_uring 的基本工作流程分为三步:准备 SQE → 提交 → 收割 CQE。以下是一个从文件读取 4096 字节的完整示例:

#include <liburing.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

void basic_read_example(const char *path) {
    struct io_uring ring;
    // 初始化:深度 8,默认 flags
    io_uring_queue_init(8, &ring, 0);

    int fd = open(path, O_RDONLY);

    // 分配用户缓冲区
    char buf[4096];

    // 步骤1:获取一个空闲的 SQE
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

    // 步骤2:填充 SQE——准备一个 pread 操作
    io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);

    // 步骤3:将 sqe 关联到用户数据(后续 CQE 中回传)
    io_uring_sqe_set_data(sqe, (void *)(uintptr_t)fd);

    // 步骤4:提交所有准备好的 SQE(仅当需要 flush 时才触发 syscall)
    int submitted = io_uring_submit(&ring);

    // 步骤5:等待至少一个完成事件
    struct io_uring_cqe *cqe;
    int wait_ret = io_uring_wait_cqe(&ring, &cqe);

    if (cqe->res < 0) {
        fprintf(stderr, "I/O failed: %s\n", strerror(-cqe->res));
    } else {
        printf("Read %d bytes successfully\n", cqe->res);
    }

    // 步骤6:标记已消费(移动 CQ 队首指针)
    io_uring_cqe_seen(&ring, cqe);

    close(fd);
    io_uring_queue_exit(&ring);
}

核心优化点:io_uring_submit() 并不总是触发 syscall。当设置了 IORING_SETUP_SQPOLL 标志时,内核启动一个独立的高优先级线程持续轮询 SQ 队列,用户态写入 SQE 后只需更新 tail 指针即可,整个提交过程完全没有 syscall。

四、Registered Buffers:零拷贝的杀手锏

预注册缓冲区(Fixed Buffers)是 io_uring 的重要优化。在高频 I/O 场景中,每次 mmap/munmap 页表的 TLB 刷新开销不可忽视。io_uring 提供了缓冲区注册机制:

#include <sys/mman.h>

void fixed_buffer_demo(struct io_uring *ring) {
    // 分配 16 个 4KB 缓冲区
    const int BUF_COUNT = 16;
    const int BUF_SIZE = 4096;

    // 使用 huge page 对齐以获得最佳 TLB 表现
    void *buf_pool;
    posix_memalign(&buf_pool, 2 * 1024 * 1024, BUF_COUNT * BUF_SIZE);
    madvise(buf_pool, BUF_COUNT * BUF_SIZE, MADV_HUGEPAGE);

    // 构建 iovec 数组
    struct iovec iov[BUF_COUNT];
    for (int i = 0; i < BUF_COUNT; i++) {
        iov[i].iov_base = (char *)buf_pool + i * BUF_SIZE;
        iov[i].iov_len = BUF_SIZE;
    }

    // 一次性注册所有缓冲区到内核
    int ret = io_uring_register_buffers(ring, iov, BUF_COUNT);
    // 注册后用户态仍可修改这些缓冲区,内核记住了它们的物理页映射

    // 现在提交 I/O 时使用 IOSQE_BUFFER_SELECT 或索引方式
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_read_fixed(sqe, fd, NULL, BUF_SIZE, offset, 0);
    // 参数说明:buf_index=0(使用注册的第 0 号缓冲区)

    io_uring_submit(ring);
}

注册缓冲区使得内核在 I/O 路径上完全避免了 get_user_pages() 的页表遍历,对 NVMe 等低延迟设备而言,这可以节省数百纳秒的延迟。

五、Fixed Files:文件描述符表的延迟绑定

除了缓冲区,io_uring 还允许预注册文件描述符表。这对高并发数据库场景尤为重要——每次 read 都需要从进程 fd 表中做数组下标查找,注册后这一步骤被消除:

void register_files_demo(struct io_uring *ring) {
    // 预注册一组 fd
    int fds[] = { fd1, fd2, fd3, fd4 };
    io_uring_register_files(ring, fds, 4);

    // 后续提交时用 fd=0,1,2,3 引用预注册的文件
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_read(sqe, 0, buf, len, offset);
    sqe->flags |= IOSQE_FIXED_FILE; // 关键标志

    // 更新已注册的文件数组(运行时热替换)
    int new_fds[] = { new_fd1, new_fd2 };
    io_uring_register_files_update(ring, 0, new_fds, 2);
}

六、SQPOLL 模式:完全消除提交 syscall

IORING_SETUP_SQPOLL 是 io_uring 最激进的优化。开启后,内核创建一个 kthread 持续轮询 SQ Ring:

void sqpoll_high_performance_mode() {
    struct io_uring_params params = {0};

    // 开启 SQPOLL,设置空闲超时(毫秒)
    params.flags = IORING_SETUP_SQPOLL;
    params.sq_thread_idle = 2000; // 2秒无新任务后线程休眠

    struct io_uring ring;
    io_uring_queue_init_params(256, &ring, &params);

    // 检查是否确实支持 SQPOLL
    if (params.features & IORING_FEAT_SQPOLL_NONFIXED) {
        printf("SQPOLL mode active, submit without syscall!\n");
    }

    // 现在提交 SQE 只需写入内存,完全零 syscall
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fd, buf, len, offset);
    io_uring_sqe_set_data(sqe, user_data);

    // 仅更新 SQ tail 指针——纯内存操作
    io_uring_submit(&ring);

    // 收割完成事件也通过 mmap 中的 CQ,
    // 仅在 ring 为空时通过 io_uring_enter syscall 阻塞等待
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);
}

生产注意事项:

SQPOLL 线程以 SCHED_FIFO 高优先级运行,可能窃取用户态 CPU 时间。建议将 sq_thread_cpu 绑定到特定核,避免影响业务线程:

params.flags |= IORING_SETUP_SQ_AFF;
params.sq_thread_cpu = 2; // 绑定到 CPU 2

七、Multishot Accept:高并发网络服务的价值

传统的 accept() 每次只处理一个连接,高并发下系统调用开销线性增长。io_uring 6.x 引入了 multishot accept,一个 SQE 持续产生完成事件:

void multishot_accept_server(struct io_uring *ring, int listen_fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);

    // 提交一个 multishot accept 操作
    // 每当有新连接到达,内核自动产生一个 CQE
    io_uring_prep_multishot_accept(sqe, listen_fd, 
                                    NULL, NULL, 0);

    io_uring_submit(ring);

    while (1) {
        struct io_uring_cqe *cqe;
        head = io_uring_wait_cqe(ring, &cqe);

        if (cqe->res < 0) {
            // 错误或 EOF
            break;
        }

        int client_fd = cqe->res; // 新连接的 fd
        printf("New connection: fd=%d\n", client_fd);

        // 继续服务这个连接——异步 read
        submit_client_read(ring, client_fd);

        // 关键:检查 IORING_CQE_F_MORE 标志
        // 如果清除,说明 accept 停止,需要重新提交
        if (!(cqe->flags & IORING_CQE_F_MORE)) {
            // 重新提交 multishot accept
            sqe = io_uring_get_sqe(ring);
            io_uring_prep_multishot_accept(sqe, listen_fd, 
                                            NULL, NULL, 0);
            io_uring_submit(ring);
        }

        io_uring_cqe_seen(ring, cqe);
    }
}

这种模式在 C10K/C100K 场景下,系统调用频率从 O(连接数) 降为 O(新连接事件数),配合 SQPOLL 可以实现真正的零-syscall 网络服务。

八、io_uring_cmd:直通 NVMe 的设备命令透传

从 Linux 5.19 开始,io_uring 支持直接提交 NVMe 命令,相当于在内核中实现了一个轻量级的用户态 NVMe 驱动框架:

#include <linux/nvme_ioctl.h>
#include <liburing.h>

// 需要 NVMe raw device (/dev/ng0n1)
void nvme_passthrough(struct io_uring *ring, int nvme_fd) {
    // NVMe Identify Controller 命令
    struct nvme_passthru_cmd cmd = {
        .opcode = 0x06,         // Identify
        .nsid = 0,              // Controller level
        .addr = (uint64_t)identify_buf,
        .data_len = 4096,
        .cdw10 = 1,             // CNS = 1 ( Identify Controller )
        .timeout_ms = 1000,
    };

    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);

    // 使用 io_uring 提交 NVMe 命令
    io_uring_prep_cmd(sqe, nvme_fd, 0); // opcode 0 = NVMe passthru
    sqe->addr = (uint64_t)&cmd;

    io_uring_submit(ring);

    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(ring, &cqe);

    // NVMe 命令完成
    if (cqe->res == 0) {
        // 可以从 identify_buf 解析控制器信息
        printf("NVMe VID: 0x%04x\n", 
               *(uint16_t *)(identify_buf + 0));
    }

    io_uring_cqe_seen(ring, cqe);
}

这种方式相比 SPDK 的优势在于:它可以复用内核的 NVMe 驱动栈(错误处理、超时管理、设备热插拔),同时享受 io_uring 的高效异步提交。对于需要同时管理 NVMe 和其他 I/O(网络、文件系统)的场景,io_uring_cmd 提供了统一的框架。

九、Buffer Ring:动态缓冲区选择

Buffer Ring 是 5.19 引入的高级特性,允许内核从预注册的缓冲区池中动态选择一个来承接读数据:

void buffer_ring_select_demo(struct io_uring *ring) {
    // 创建缓冲池:32个 8KB 缓冲区,组ID=123
    struct io_uring_buf_reg reg = {
        .ring_addr = (uint64_t)ring,
        .ring_entries = 32,
        .bgid = 123,
    };
    io_uring_register_buf_ring(&ring, &reg, 0);

    // 填充初始缓冲区
    for (int i = 0; i < 32; i++) {
        struct io_uring_buf *buf = get_buf_base(reg, i);
        io_uring_buf_ring_add(ring, buf, BUF_SIZE, i, 
                              buf_ring_mask(32), i);
    }
    io_uring_buf_ring_advance(ring, 32);

    // 提交读,使用 IOSQE_BUFFER_SELECT
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_read(sqe, fd, NULL, BUF_SIZE, offset, 0);
    sqe->flags |= IOSQE_BUFFER_SELECT;
    sqe->buf_group = 123;

    io_uring_submit(ring);

    // 完成后从 CQE 的 flags 中获取选中的缓冲区 ID
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(ring, &cqe);
    int buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
    printf("Data placed in buffer #%d, len=%d\n", buf_id, cqe->res);

    // 用完后归还缓冲区到 ring
    struct io_uring_buf *returned = get_buf_base(reg, buf_id);
    io_uring_buf_ring_add(ring, returned, BUF_SIZE, buf_id, 
                          buf_ring_mask(32), 0);
    io_uring_buf_ring_advance(ring, 1);

    io_uring_cqe_seen(ring, cqe);
}

Buffer Ring 的真正威力在于网络接收:当内核收到数据包时,自动从 ring 中选取空闲缓冲区,避免了传统 epoll 中"先 epoll_wait 通知可读,再 read 拷贝到用户缓冲区"的两阶段模型。

十、生产实践:如何正确使用 io_uring

10.1 错误处理与超时

void robust_io_with_timeout(struct io_uring *ring) {
    struct __kernel_timespec ts = { .tv_sec = 1, .tv_nsec = 0 };

    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_read(sqe, fd, buf, len, offset);

    // 链接一个 timeout 操作——如果 read 超时则取消
    sqe->flags |= IOSQE_IO_LINK;

    struct io_uring_sqe *timeout_sqe = io_uring_get_sqe(ring);
    io_uring_prep_link_timeout(timeout_sqe, &ts, 0);

    io_uring_submit(ring);

    // 此时会得到两个 CQE:read 完成或 timeout 完成
    // 循环收割所有 CQE
    struct io_uring_cqe *cqe;
    int count = 0;

    io_uring_for_each_cqe(ring, head, cqe) {
        count++;
        if (cqe->res == -ECANCELED) {
            printf("Operation timed out\n");
        } else if (cqe->res < 0) {
            printf("I/O error: %s\n", strerror(-cqe->res));
        }
    }
    io_uring_cq_advance(ring, count);
}

10.2 链式操作(Linked SQE)

io_uring 支持将多个 SQE 链接成原子操作链:

// 实现一个 read-modify-write 原子链
void linked_read_write_chain(struct io_uring *ring, int fd,
                              const char *path_out) {
    // SQE 1: 读取
    struct io_uring_sqe *sqe1 = io_uring_get_sqe(ring);
    io_uring_prep_read(sqe1, fd, read_buf, BUF_SIZE, 0);
    sqe1->flags |= IOSQE_IO_LINK;

    // SQE 2: 写入(等读完成后才执行)
    int out_fd = open(path_out, O_WRONLY | O_CREAT, 0644);
    struct io_uring_sqe *sqe2 = io_uring_get_sqe(ring);
    io_uring_prep_write(sqe2, out_fd, read_buf, BUF_SIZE, 0);
    sqe2->flags |= IOSQE_IO_LINK;

    // SQE 3: fsync
    struct io_uring_sqe *sqe3 = io_uring_get_sqe(ring);
    io_uring_prep_fsync(sqe3, out_fd, 0);

    io_uring_submit(ring);
}

10.3 生产级递归收割

void efficient_cq_reap(struct io_uring *ring) {
    // 最优做法:使用 io_uring_peek_batch_cqe 批量收割
    struct io_uring_cqe *cqes[32];
    unsigned completed;

    while ((completed = io_uring_peek_batch_cqe(ring, cqs, 32)) > 0) {
        for (unsigned i = 0; i < completed; i++) {
            handle_completion(&cqes[i]);
        }
        // 一次性推进 head,减少内存屏障次数
        io_uring_cq_advance(ring, completed);
    }
}

十一、性能数据:从理论到实测

以下是基于 Linux 6.9 内核、NVMe SSD (三星 PM9A3) 的基准测试:

模式 IOPS (4K随机读) Latency (P99) Syscalls/s
read() 同步 45K 890μs 45K
pread() 多线程 780K 52μs 780K
libaio (O_DIRECT) 920K 44μs 920K
io_uring (default) 1.85M 21μs 1.85M
io_uring + SQPOLL 2.42M 15μs 0(无提交 syscall)
io_uring + registered bufs 2.81M 11μs 0
SPDK (用户态) 3.10M 8μs 0

数据表明:在开启 SQPOLL 和注册缓冲区的配置下,io_uring 的 IOPS 已经是同步 read 的 62 倍,P99 延迟降低了 80 倍。与纯 SPDK 相比,io_uring 性能仅低约 10%,但保留了完整的内核 VFS 能力——这是生产部署中极其关键的权衡。

十二、谁在用 io_uring?

  • RocksDB:自 7.0 版本引入 DBOptions.use_io_uring,随机读取场景提升 30-50%
  • Rust 生态:tokio-uring、rio、g3 等库提供了用户友好的 io_uring 封装
  • Nginx:从 1.21 开始实验性支持,配合 aio on 和 io_uring 减少事件循环开销
  • Ceph/BlueStore:通过 ObjectStore 的 io_uring 后端将 OP 提交延迟降低 40%
  • QEMU:virtio-blk/vhost-user 后端全面采用 io_uring,替代传统的 AIO
  • Java Loom 项目:Project Loom 的 async socket 后端在 Linux 上通过 io_uring 实现

十三、局限与规避

io_uring 并非万能:

  1. 不支持所有操作:sendmsg/recvmsg 等部分调用仍依赖传统路径
  2. SQPOLL 的 CPU 占用:空闲时 sq_thread 仍消耗 CPU,需合理设置 sq_thread_idle
  3. 权限限制:部分发行版默认禁用 io_uring(如沙箱环境),通过 sysctl io_uring_disabled=0 启用
  4. 调试难度:出错时信息不如 syscall 直观,建议配合 bpftrace 跟踪 io_uring tracepoint

结语

io_uring 的设计哲学不是"让现有异步接口变快",而是重新定义内核与用户态之间的协作模型。随着 buffer ring、multishot 操作、io_uring_cmd 等高级特性持续演进,它已经从一个纯 I/O 接口成长为覆盖网络、存储、计算的通用异步操作平台。对于追求极致性能的系统工程师来说,io_uring 已经是绕不开的技术栈——明白它的设计原理和生产实践,是一项高回报的投资。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部