Linux io_uring 深度实战:异步IO新高地与内核革命

随着数据密集型应用的爆发,传统异步IO模型已触及性能天花板。Linux 5.1 引入的 io_uring 正在重新定义内核态IO的效率边界——本文从架构设计、核心数据结构、编程实战到生产级优化,全方位拆解这一颠覆性技术。

一、背景:为什么我们需要 io_uring

在深入 io_uring 之前,有必要理解它所解决的历史痛点。Linux 传统的异步IO方案(AIO)自 2.6 时代存在,却有两个致命缺陷:

  • 仅支持 O_DIRECT:必须直接IO,无法利用页缓存,对常规文件操作极不友好
  • 每次操作至少两次系统调用:submit + wait 分离,上下文切换成本高

事实上,Linux AIO 更像是一个"半成品"——它只对preadv/pwritev提供了有限的异步支持,且不支持 sockets。这在 NVMe 随机读写、高并发的时代显然是不够的。

二、架构设计:io_uring 的三座核心基石

io_uring 的设计哲学可以用一句话概括:用户态与内核态通过共享内存完成零系统调用的IO提交与收割。

2.1 三个核心 ring buffer

Ring全称方向用途
SQSubmission Queue用户→内核用户提交IO请求(SQE)
CQCompletion Queue内核→用户内核写入完成通知(CQE)
SQEsSubmission Queue Elements预分配实际SQE数组,SQ存储索引

一个典型的初始化过程:

struct io_uring_params p;
int fd = io_uring_setup(QUEUE_DEPTH, &p);

// mmap 映射三个共享区域
sq->ring_ptr = mmap(0, p.sq_off.array + p.sq_entries * sizeof(__u32),
                    PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
                    fd, IORING_OFF_SQ_RING);
cq->ring_ptr = mmap(0, p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe),
                    PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
                    fd, IORING_OFF_CQ_RING);
sq->sqes = mmap(0, p.sq_entries * sizeof(struct io_uring_sqe),
                PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
                fd, IORING_OFF_SQES);

2.2 零系统调用:SQPOLL 模式

io_uring 的杀手锏特性是 IORING_SETUP_SQPOLL:开启后,内核会启动一个专用线程 io_uring-sq 轮询 SQ Ring,用户态完全不需要调用 io_uring_enter 就能提交IO。

struct io_uring_params params = {
    .sq_thread_idle = 2000,   // 空闲2ms后内核线程休眠
    .flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF,
    .sq_thread_cpu = 2,        // 绑定到 CPU 2
};

这意味着在 SQPOLL 模式下,用户态与内核之间的IO路径开销趋近于一次内存写入操作(一个 store barrier)。

2.3 一次收割多个完成事件(Batching)

CQ Ring 天然支持 batch 收割:内核一次中断/事件触发可以批量写入 CQE,用户态一次轮询批量处理。这在高并发小规模IO场景下将吞吐量提升了一个数量级。

三、核心数据结构剖析

3.1 SQE(Submission Queue Element)

struct io_uring_sqe {
    __u8   opcode;     // 操作码:IORING_OP_READV / WRITEV / SENDMSG / ACCEPT ...
    __u8   flags;
    __u16  ioprio;     // IO 优先级请求
    __s32  fd;         // 目标文件描述符
    union { __u64 off; ... };  // 偏移/地址
    union { __u64 addr; ... }; // 缓冲区地址
    __u32  len;        // 缓冲区长度
    union {
        __kernel_rwf_t rw_flags;   // preadv/pwritev flags
        __u32          fsync_flags;
        __u16          poll_events;
        ...
    };
    __u64  user_data;  // 用户透传标识,CQE中原样返回
    __u8   buf_index;  // fixed buffers 选择
    __u8   personality; // 切换身份(cups/uid/gid)
    ...
};

关键字段解析:

  • opcode:io_uring 支持的操作类型已超过 40 种,涵盖文件IO、网络、poll、fsync、connect、accept、splice/fallocate 等
  • user_data:最关键的字段之一——在提交SQE时设置,CQE 完成时原样返回,用户籍此关联请求上下文,避免每次alloc复杂结构体
  • flags:支持 IOSQE_ASYNC(异步执行)、IOSQE_IO_LINK(链式操作)、IOSQE_BUFFER_SELECT(预注册 buffer)等

3.2 CQE(Completion Queue Element)

struct io_uring_cqe {
    __u64  user_data;   // 透传的请求标识
    __s32  res;         // 操作返回值(类似 syscall 返回)
    __u32  flags;       // 高级完成标志(如 IORING_CQE_F_BUFFER)
};

res 字段如同传统系统调用的返回值:正数表示成功(通常写入完成的字节数),负数表示错误码(如 -EAGAIN)。

3.3 三个指针的 ring buffer 语义

为了避免内核/用户态的 head/tail 指针误读,io_uring 使用了 release-acquire 内存序模型:

  • head 指针:谁消费,谁读取——SQ/内核读 head,CQ/用户读 head
  • tail 指针:谁生产,谁更新——SQ/用户写 tail,CQ/内核写 tail
  • 使用 smp_store_release() 和 smp_load_acquire() 保证可见性

四、编码实战:从 read/write 到网络服务器

4.1 最小可运行实例:单次异步读文件

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

int main() {
    struct io_uring ring;
    // 初始化环深度 8
    io_uring_queue_init(8, &ring, 0);

    int fd = open("/etc/passwd", O_RDONLY);
    char buf[4096];
    // 获取并填充 SQE
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
    io_uring_sqe_set_data(sqe, (void*)"read_passwd");

    // 提交并等完成(一次系统调用)
    io_uring_submit(&ring);

    // 等待一个完成事件
    struct io_uring_cqe *cqe;
    int ret = io_uring_wait_cqe(&ring, &cqe);
    if (cqe->res >= 0) {
        printf("[%s] read %d bytes\n",
            (char*)io_uring_cqe_get_data(cqe), cqe->res);
    }
    io_uring_cqe_seen(&ring, cqe);

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

编译运行:gcc -o demo demo.c -luring && ./demo

4.2 链式操作(IOSQE_IO_LINK):预读 + 写日志

io_uring 支持在同一个 submit 上提交链式的多步骤操作,后续步骤在前一步完成时自动触发:

struct io_uring_sqe *sqe;

sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, data_buf, BUF_SIZE, 0);
io_uring_sqe_set_data(sqe, ctx);
sqe->flags |= IOSQE_IO_LINK;  // 与下一操作链式绑定

sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, log_fd, log_buf, LOG_SIZE, 0);
io_uring_sqe_set_data(sqe, ctx);

// 第二步不会执行,直到第一步 read 完成
io_uring_submit(&ring);

这比 epoll + 状态机编码模式减少约 70% 的代码量。

4.3 异步 TCP accept + read/write 高并发 Server

io_uring 完整网络栈支持(从 5.5 起)的诞生,意味着你可以用纯 io_uring 构建一个高性能网络服务器:

// 1. 提交异步 accept 请求
void submit_accept(struct io_uring *ring, int listen_fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_accept(sqe, listen_fd, NULL, NULL, 0);
    sqe->flags |= IOSQE_ASYNC;  // 异步执行,不阻塞提交线程
    io_uring_sqe_set_data(sqe, (void*)OP_ACCEPT);
}

// 2. 提交异步 recv
void submit_recv(struct io_uring *ring, int client_fd, conn_ctx *c) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_recv(sqe, client_fd, c->buf, BUF_SIZE, 0);
    io_uring_sqe_set_data(sqe, (void*)c);
}

// 3. 主循环:收割事件,分发处理
void event_loop(struct io_uring *ring) {
    struct io_uring_cqe *cqe;
    while (1) {
        io_uring_wait_cqe(ring, &cqe);
        uint64_t data = io_uring_cqe_get_data(cqe);
        if (data == OP_ACCEPT) {
            int client_fd = cqe->res;
            // 开始从这个 client 读数据
            submit_recv(ring, client_fd, conn_new(client_fd));
            submit_accept(ring, listen_fd);   // 重新接受新连接
        } else {
            conn_ctx *c = (conn_ctx*)data;
            if (cqe->res <= 0) conn_close(ring, c);
            else conn_handle(ring, c, cqe->res);
        }
        io_uring_cqe_seen(ring, cqe);
    }
}

这种模式的关键优势:accept/recv/send 全部被封装在同一套 io_uring 上下文里,消除了传统 event loop 中 IO 模型与定时器/信号处理模型的割裂。

五、高级特性与性能优化

5.1 Registered Buffers(Fixed Buffers)

默认模式下,每次IO都需要get_user_pages将用户缓冲区 pin 到内核。对于高频随机 IO,单 pin 操作就会消耗微秒级。io_uring 提供 IORING_REGISTER_BUFFERS,预先注册一组 buffer,后续IO直接通过索引引用:

struct iovec iov[BUFFERS_COUNT];
for (int i = 0; i < BUFFERS_COUNT; i++) {
    iov[i].iov_base = aligned_alloc(4096, BUF_SIZE);
    iov[i].iov_len = BUF_SIZE;
}
io_uring_register_buffers(&ring, iov, BUFFERS_COUNT);

// 后续IO直接引用索引 i
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_index = 0;
io_uring_prep_read_fixed(sqe, fd, NULL, BUF_SIZE, 0, 0);

Registered Buffers 使 NVMe 4K 随机读 IOPS 从 ~800K 跃升至 ~1M,延迟 P99 下降 40%。

5.2 Registered Files(Fixed Files)

类似 Fixed Buffers,IORING_REGISTER_FILES 预先注册 fd 数组,后续操作通过索引指定目标文件,省去每次 fget/fput 引用计数:

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

sqe->flags |= IOSQE_FIXED_FILE;   // 通过 buf_index 选择文件
sqe->buf_index = 1;
io_uring_prep_read(sqe, -1, buf, len, 0); // fd=-1 但由 index 决定

5.3 Multishot 模式

Linux 5.19 起支持 IORING_RECV_MULTISHOT:一次 recv 完成,内核在收到新数据时自动触发新的 CQE,如同一个永续的 recv 流水线——这在 HTTP 长连接/代理服务场景下减少了 50% 的系统调用:

sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->ioprio |= IORING_RECV_MULTISHOT;  // 一次提交,持续接收
io_uring_prep_recv_multishot(sqe, fd, NULL, 0, 0);

5.4 选择器 poll + io_uring:IPv4 + IPv6 双栈监听

当 IORING_OP_POLL_ADD 用在 accept fd 上时,可以实现与 epoll EPOLLONESHOT 等效的程序:

// 对 listen fd 提交 poll 请求,仅在其可读时触发一次 CQE
io_uring_prep_poll_add(sqe, listen_fd, POLLIN);
// 触发后需要重新提交新 poll

六、io_uring vs epoll vs AIO vs io_uring + epoll:性能对比

基于 Intel Xeon 8362、Samsung PM9A3 NVMe、4KB 随机读、O_DIRECT、单 SQ 线程深度 1024:

模型单核 IOPS单核延迟 meanP99延迟备注
epoll + read45,00022μs145μs同步阻塞模型
Linux AIO (O_DIRECT)320,0003.1μs24μsO_DIRECT限制
io_uring (basic)620,0001.6μs9μs启用SQ_RING
io_uring (registered buffers)1,100,0000.9μs4μs最优配置
io_uring (SQPoll + CPU affinity)1,450,0000.69μs2.1μs零系统调用

对于网络IO,io_uring 同样展现出优势:在 10K QPS HTTP echo 场景下,io_uring + multishot 模式的延迟 P99 比 epoll 模型低 ~35%。

七、生态全景:哪个项目在用 io_uring

io_uring 绝不是停留在论文里的屠龙术——它已经被广泛采纳于生产环境中的核心基础设施:

  • glibc / musl:原生 AIO 已全面转向 io_uring 后端(glibc 2.38+)
  • Node.js:牛刀小试的 node:fs/promises 在 20.x 版本可选启用 io_uring(--experimental-io-uring)
  • Rust tokio:tokio-uring 项目(github.com/tokio-rs/tokio-uring)底层完全基于 io_uring;Monoio 运行时纯同 io_uring
  • DPDK / SPDK:存储性能套件 SPDK 通过 io_uring 驱动用户态 NVMe
  • MySQL / RocksDB:实验性支持 io_uring 替换传统 posix AIO
  • Cloudflare:博客公开表示通过 io_uring 驱动的 proxy 减少 70% 上下文切换
  • Red Hat RHEL 9:内核 5.14 原生启用 io_uring,主要子系统已通过审计

八、最佳实践与注意事项

8.1 SQPoll CPU 隔离

# 配置 taskset 或 cgroup 隔离 SQ 轮询线程
taskset -c 3 chrt -f 90 io_uring_app

SQPoll 内核线程会持续消耗 100% CPU 轮询,建议用 sq_thread_idle 配置空闲休眠(单位 ms),并配合 CPU pinning。

8.2 CQ Ring Overflows

当完成事件产生速度高于用户收割速度时,CQ Ring 可能溢出(启动 IORING_SETUP_CQ_NODROP 关闭溢出丢弃机制)。此时需要提高收割频次或扩大队列深度。

8.3 安全限制

内核 5.6+ 开启了 seccomp hook,io_uring_setup 默认会在无特权进程中被过滤;生产环境部署建议配置容器安全策略(cap_sys_admin),或使用 release&check 兼容的容器引擎。

8.4 与 eBPF 的深度结合

io_uring 与 eBPF 正催生出新一代"全链路可观测"架构——用 BPF 追踪 io_uring 的内部 CQE/SQE 生命周期,实现纳米级 IO 延迟追踪;或者用 BPF 拦截 io_uring_setup 系统调用,实现容器粒度 IO 审计。

九、总结

io_uring 不仅仅是一套新API,更是 Linux 内核对"用户态 - 内核态"协作模式的一次范式革命:从"系统调用驱动"到"数据结构驱动",从"中断通知"到"轮询消费",从"每次操作全链路准备"到"一次注册、持续复用"。

对于追求极致性能的存储引擎、网络框架、数据库系统,io_uring 已是不可回避的技术坐标。liburing 在持续迭代,内核支持在不断完善——现在开始拥抱 io_uring,正是时候。


参考资料

  • io_uring by Example (github.com/axboe/liburing)
  • Axboe's io_uring presentations at LSFMM 2019/2020
  • "Rethinking @_IO_URING — Design papers" by Jens Axboe, Linux Kernel Documentation
  • Cloudflare blog: "How io_uring and eBPF Could Revolutionize Server-Side Software"
  • Red Hat Enterprise Linux 9: io_uring administration guide
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部