Linux 内核 io_uring 异步 I/O 深度实战:从原理到高性能应用

一、io_uring 概述:为什么需要它

在 Linux 内核 5.1 中引入的 io_uring(曾用名 aioring、libcuring)是 Linux 异步 I/O 的革命性框架。它解决了长期以来 Linux AIO (libaio) 的诸多痛点:

  • 传统 AIO:仅支持 O_DIRECT 文件 I/O,不支持网络 I/O,API 设计繁琐,完成事件需要额外拷贝
  • epoll:本质仍是同步就绪通知,真正的 I/O 操作仍需阻塞调用
  • io_uring:统一文件与网络异步 I/O,零拷贝提交与完成,支持轮询模式(polling),吞吐量可达数百万 IOPS

io_uring 的核心设计理念是 共享环形缓冲区(Shared Ring Buffers):用户态与内核态通过两个无锁环形队列通信,避免了传统 syscall 的 io_submit / io_getevents 多次系统调用开销。

二、核心数据结构

// 提交队列条目 (Submission Queue Entry)
struct io_uring_sqe {
    __u8   opcode;    // 操作码:IORING_OP_READV, IORING_OP_WRITEV 等
    __u8   flags;     // 标志位
    __u16  ioprio;    // I/O 优先级
    __s32  fd;        // 文件描述符
    union { __u64 off; ... };  // 文件偏移
    union { __u64 addr; ... }; // 缓冲区地址
    __u32  len;       // 缓冲区长度
    union {
        __kernel_rwf_t rw_flags;
        __u32 fsync_flags;
        ...
    };
    __u64  user_data; // 用户自定义标识,完成时回传
    union { __u16 buf_index; ... };
    __u16  personality;
    __s32  splice_fd_in;
    ...
};

// 完成队列条目 (Completion Queue Entry)
struct io_uring_cqe {
    __u64  user_data;  // 提交时设置的标识
    __s32  res;        // 操作结果(类似返回值)
    __u32  flags;      // 完成标志
};

核心结构关系如下:

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   用户态     │     │   共享内存   │     │   内核态     │
├──────────────┤     ├──────────────┤     ├──────────────┤
│              │     │  SQ Ring     │     │              │
│  填充 SQE    │────▶│  (提交队列)  │────▶│  处理 I/O    │
│              │     │              │     │              │
│  消费 CQE    │◀────│  CQ Ring     │◀────│  完成 I/O    │
│              │     │  (完成队列)  │     │              │
│              │     │  SQEs Array  │     │              │
└──────────────┘     └──────────────┘     └──────────────┘
     SQ Tail              环形缓冲区              ↑
     CQ Head                                     ↓
  内核读 SQ Tail                          内核写 CQ Tail
  用户写 SQ Tail                          用户读 CQ Head

三、io_uring 初始化与基础 API

使用 liburing 库进行 io_uring 编程非常简洁:

#include <liburing.h>

// 1. 初始化 io_uring 实例
struct io_uring ring;
struct io_uring_params params = {0};

// 设置队列深度(2 的幂次,内核实际可能分配更多)
int ret = io_uring_queue_init_params(256, &ring, &/params);
if (ret < 0) {
    fprintf(stderr, "io_uring init failed: %s\n", strerror(-ret));
    return 1;
}

// 检查内核版本是否支持指定功能
if (!/params.features & IORING_FEAT_SINGLE_MMAP) {
    // 需要分别 mmap SQ Ring 和 CQ Ring
}

初始化完成后,io_uring 实例通过 mmap 将两个环形缓冲区映射到用户空间(Linux 5.4+ 支持 IORING_FEAT_SINGLE_MMAP,一次 mmap 映射两个缓冲区)。

四、提交 I/O 请求:Read 与 Write 实战

4.1 异步读文件

ssize_t read_with_uring(int fd, void *buf, size_t len, off_t offset) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    if (!sqe) {
        // SQ 已满,需要先提交一批请求
        io_uring_submit(&ring);
        sqe = io_uring_get_sqe(&ring);
    }

    // 设置 SQE:预读取(readv)
    io_uring_prep_readv(sqe, fd, &iovec, 1, offset);

    // 设置 user_data 用于完成时匹配
    io_uring_sqe_set_data(sqe, (void*)(uintptr_t)req_id);

    // 提交到内核(仅更新 SQ Tail,不触发 syscall)
    io_uring_submit(&ring);
    // 注意:io_uring_submit 本身是一次 syscall,但可以批量提交

    return 0; // 异步返回,稍后从 CQE 获取结果
}

4.2 异步写文件

void write_with_uring(int fd, const void *buf, size_t len, off_t offset,
                       uint64_t user_data) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

    // 使用固定缓冲区(IORING_REGISTER_BUFFERS)优化
    // buf_index 指向预先注册的缓冲区
    io_uring_prep_write_fixed(sqe, fd, buf, len, offset, buf_index);

    io_uring_sqe_set_data(sqe, (void*)(uintptr_t)user_data);

    // 批量提交时才调用 io_uring_submit()
    io_uring_submit(&ring);
}

4.3 等待并收割完成事件

struct io_uring_cqe *cqe;
unsigned head;
int completed = 0;

// 方式1:阻塞等待至少一个完成事件
io_uring_wait_cqe(&ring, &/cqe);

// 方式2:非阻塞 peek(常见于事件循环)
if (io_uring_peek_cqe(&ring, &/cqe) == 0) {
    uint64_t ud = (uint64_t)io_uring_cqe_get_data(cqe);
    int res = cqe->res; // 实际读取的字节数,或负的错误码
    // 处理完成...
    io_uring_cqe_seen(&ring, cqe);
}

// 方式3:批量收割(高效事件循环模式)
io_uring_for_each_cqe(&ring, head, cqe) {
    uint64_t ud = (uint64_t)io_uring_cqe_get_data(cqe);
    handle_completion(ud, cqe->res);
    completed++;
}
if (completed > 0) {
    io_uring_cq_advance(&ring, completed);
}

五、高级特性:固定缓冲区与固定文件

io_uring 提供多种机制进一步减少开销:

5.1 注册固定缓冲区(Registered Buffers)

每次 I/O 操作都需要内核将用户缓冲区映射到内核空间(get_user_pages),注册缓冲区后只需首次 pin 页,后续操作直接使用索引:

// 预注册一组缓冲区
struct iovec iovecs[BUF_COUNT];
for (int i = 0; i < BUF_COUNT; i++) {
    iovecs[i].iov_base = aligned_alloc(4096, BUF_SIZE);
    iovecs[i].iov_len = BUF_SIZE;
    // 预填充数据(注册 I/O)
}
io_uring_register_buffers(&ring, iovecs, BUF_COUNT);

// 之后使用 buf_index 而非 addr 指针
io_uring_prep_read_fixed(sqe, fd, NULL, len, offset, buf_index);

// 清理
io_uring_unregister_buffers(&ring);

性能收益:对于高频小 I/O 场景,可减少 15-30% 的延迟。

5.2 注册固定文件(Registered Files)

每次 I/O 都需要内核通过 fd 查找 file 结构并增加引用计数,注册文件后使用索引:

int fds[] = { fd1, fd2, fd3 };
io_uring_register_files(&ring, fds, 3);

// 在 SQE 中使用固定文件索引
sqe->flags |= IOSQE_FIXED_FILE; // 标记为使用注册文件
sqe->fd = file_index;            // 使用数组索引而非 fd 值

5.3 缓冲区选择(Buffer Selection)

Linux 5.7+ 支持自动分组缓冲区选择:

// 注册缓冲区分组
struct io_uring_buf_reg reg = {
    .ring_addr = (unsigned long)ring_buffer_addr,
    .ring_entries = 256,
    .bgid = 1, // 缓冲区组 ID
};
io_uring_register_buf_ring(&ring, &/reg, 0);

// 设置 SQE:跳过 addr/len,内核自动从分组中选择缓冲区
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = 1;

// 完成时通过 cqe->/flags >> IORING_CQE_BUFFER_SHIFT 获取选中的缓冲区 ID
int buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;

六、内核轮询模式(IORING_SETUP_IOPOLL / SQPOLL)

io_uring 提供两种内核侧轮询模式,彻底消除系统调用:

6.1 SQPOLL(提交队列轮询)

struct io_uring_params params = {
    .flags = IORING_SETUP_SQPOLL,   // 内核线程轮询 SQ
    .sq_thread_idle = 2000,         // 空闲 2s 后内核线程睡眠(ms)
    .sq_thread_cpu = 3,             // 绑定内核线程到 CPU 3
};
io_uring_queue_init_params(QUEUE_DEPTH, &ring, &/params);

// 用户态操作完全不需要 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);
// 写入新的 tail 指针(通过共享内存,无 syscall)
io_uring_sqe_submit(&ring); // 等价于写 SQ Tail,无 syscall

// 设置 IORING_SETUP_SQPOLL 后,若 SQ 已满则需提交
// 使用 io_uring_submit 时,内核线程已处理,仅做标记

SQPOLL 线程模型:

  • 内核创建名为 io_uring-sq 的线程轮询提交队列
  • 用户态更新 SQ Tail 即通知内核线程(通过写内存 + write barrier)
  • 无需 io_uring_submit syscall
  • 适合:极低延迟需求,可承受独占一个 CPU 核心

6.2 IOPOLL(I/O 完成轮询)

struct io_uring_params params = {
    .flags = IORING_SETUP_IOPOLL, // 设备侧轮询(需要硬件支持)
};
io_uring_queue_init_params(QUEUE_DEPTH, &ring, &/params);

// IOPOLL 配合 NVMe 设备(设置 /sys/block/nvme0n1/queue/io_poll=1)
// 内核轮询设备完成队列而不使用中断
// 延迟可低至 10μs 以下 (vs 中断模式的 50~200μs)

6.3 无中断全轮询模式

同时设置 SQPOLL + IOPOLL 可达到全异步、无 syscall、无中断的极致性能:

params.flags |= IORING_SETUP_SQPOLL | IORING_SETUP_IOPOLL;
// 用户态零 syscall 提交 + 内核主动轮询完成
// 配合 bufring 自主缓冲区选择,纯 I/O 处理路径完全绕过 syscall

七、io_uring 链接操作与依赖链

io_uring 支持将多个 SQE 链接为一个执行链(Linked SQE),实现依赖关系:

// 链式操作:先读 header,读成功后读 body
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, fd, header_buf, HEADER_SIZE, 0);
io_uring_sqe_set_data(sqe1, REQ_HEADER);

struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe2, fd, body_buf, body_len, HEADER_SIZE);
io_uring_sqe_set_data(sqe2, REQ_BODY);

// 链接:sqa2 只在 sqa1 成功后执行(IOSQE_IO_LINK)
sqe1->flags |= IOSQE_IO_LINK;
sqe2->flags &= ~IOSQE_IO_LINK;

// 如果前一个失败,链中后续操作标记为失败(跳过执行)
// 使用 IOSQE_IO_HARDLINK 强链接:前面失败时后面也不执行
sqe1->flags |= IOSQE_IO_HARDLINK;

io_uring_submit(&ring);

链式操作实用场景:

  • 协议解析:先读固定头,根据长度字段再读变长体
  • 数据库 WAL:先写日志,日志写入成功后再更新索引
  • 多阶段 pipeline:初始化→读数据→处理→写结果

八、实际应用案例:高性能网络服务器

8.1 基于 io_uring 的 TCP echo 服务器

// 关键流程(使用 liburing 的高层封装):
// 1. 初始化 + 注册缓冲区
// 2. 创建 accept 请求
// 3. 事件循环:收割 CQE → 分发处理 → 提交新 SQE

void accept_connection() {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    // 异步 accept
    io_uring_prep_accept(sqe, listen_fd, &/addr, &/addrlen, 0);
    io_uring_sqe_set_data(sqe, (void*)OP_ACCEPT);
    io_uring_submit(&ring);
}

void event_loop() {
    struct io_uring_cqe *cqe;
    unsigned head;

    while (running) {
        // 等待完成事件(非阻塞:io_uring_peek_cqe)
        int ret = io_uring_wait_cqe(&ring, &/cqe);
        if (ret < 0) continue;

        io_uring_for_each_cqe(&ring, head, cqe) {
            int op = (int)(uintptr_t)io_uring_cqe_get_data(cqe);
            int res = cqe->res;

            switch (op) {
            case OP_ACCEPT: // accept 完成,获取新连接
                if (res >= 0) {
                    client_fd = res;
                    // 对新连接提交 read 请求
                    submit_read(client_fd);
                    // 重新 accept 下一个连接
                    accept_connection();
                }
                break;
            case OP_READ:   // read 完成
                if (res > 0) {
                    // 回写数据
                    submit_write(client_fd, buf, res);
                }
                break;
            case OP_WRITE:  // write 完成,继续读
                submit_read(client_fd);
                break;
            }
        }
        io_uring_cq_advance(&ring, cqe_count);
    }
}

8.2 使用 accept + send/recv 多路复用

Linux 5.19+ 引入了 multishot accept,一次提交,每次新连接都产生 CQE:

// Multishot accept:内核持续监听,每来一个连接就触发一次 CQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, listen_fd, &/addr, &/addrlen, 0);
sqe->flags |= IOSQE_CQE_SKIP_SUCCESS; // 成功时不产生额外 CQE
io_uring_sqe_set_data(sqe, (void*)OP_ACCEPT);

io_uring_submit(&ring);

九、性能对比与调优指南

9.1 典型吞吐量对比

场景read/write syscalllibaioepoll+readio_uring (baseline)io_uring (SQPOLL+IOPOLL)
4KB 随机读 NVMe200K IOPS500K IOPSN/A1.2M IOPS3.5M IOPS
4KB 随机写 NVMe180K IOPS400K IOPSN/A1.0M IOPS3.0M IOPS
Echo 服务器 (QPS)120KN/A250K450K800K

9.2 关键调优参数

  • 队列深度:根据存储设备 IOPS 能力设置。NVMe SSD 建议 256-1024
  • sq_thread_idle:SQPOLL 模式内核线程空闲超时,单位 ms。低延迟场景设 0 保持常驻
  • IORING_REGISTER_RING_FDS:允许 eventfd/epoll 监控 ring->cq.cqes,便于嵌入 epoll 事件循环
  • IORING_SETUP_ATTACH_WQ:多个 ring 共享同一个内核 worker 线程池
  • CQE batching:累积一定数量 CQE 再批量收割,减少 CQ 读取开销
  • 固定缓冲区:减少内核 pin/unpin 页面的次数

9.3 常见问题排查

问题1:io_uring_queue_init 返回 -ENOMEM

  • 原因:RLIMIT_MEMLOCK 限制不足,io_uring 需要锁定内存
  • 解决:ulimit -l unlimited 或增大 /etc/security/limits.conf 中的 memlock

问题2:SQPOLL 线程 CPU 占用 100%

  • 检查 sq_thread_idle 是否设置为 0
  • 解决:设置合理的超时值(如 2000ms),或在不活动时调用 io_uring_sq_ring_wait(&ring)

问题3:I/O 操作被降级为同步

  • 原因:文件没有 O_DIRECT 或 IOPOLL 对设备不支持
  • 解决:检查 /sys/block/<dev>/queue/io_poll,对字符设备 IOPOLL 不适用

十、io_uring 未来发展

io_uring 仍在快速演进中,值得关注的方向:

  • napi_busy_poll:内核 5.11+ 集成,io_uring 自动利用 NAPI busy polling 降低网络延迟
  • BPF 与 io_uring 融合:内核中 BPF 程序可以操作 SQE,实现可编程 I/O 调度
  • TIOCSQIOCB / io_uring 跨进程共享: 通过 fd 传递实现多进程共享同一 ring
  • in-kernel 驱动 io_uring:NVMe passthrough、io_uring 直接操作 NVMe SQ/CQ(绕过块层)
  • QP 队列模式:支持一对多广播模式,单次提交覆盖多个文件描述符

总结

io_uring 是 Linux I/O 路径的一次系统性重构,通过共享环形缓冲区实现了真正的零拷贝异步 I/O。从 5.1 版本的单一文件 I/O 到现在的网络、存储、缓冲区管理全覆盖,io_uring 已成为构建高性能服务端应用的核心基础设施。

掌握 io_uring 的关键在于理解其"批量提交+异步完成"的编程模型,善用固定缓冲区/文件免掉动态映射开销,针对场景选择合适的轮询模式,并结合链接操作构建多阶段异步工作流。随着内核持续优化和新特性加入,io_uring 的应用领域还将进一步扩大。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部