Linux 5.1 引入的 io_uring 不仅仅是一次 API 升级——它是对「操作系统如何为异步 I/O 服务」这一根本问题的重新设计。在它之前,POSIX AIO 受限于 O_DIRECT 对齐、无法顺畅处理网络 I/O、每次操作需要多次系统调用;而 libaio 虽然绕过了部分限制,却仍然缺乏与 epoll 对等的异步生态闭环。io_uring 以「用户态与内核共享两个 ring buffer」为核心,将系统调用从每次 I/O 触发一次缩减到「批量提交 + 单次 enter」,从根本上解决了用户态-内核态之间的通信瓶颈。到 Linux 6.x,io_uring 已经覆盖了磁盘 I/O、网络 (sendmsg/recvmsg/send_zc)、文件系统 (statx/openat/close)、甚至 uring_cmd (直通 NVMe 控制命令),成为事实上的 Linux 异步 I/O 统一框架。

一、核心架构:SQ、CQ、SQ 三元组

io_uring 的本质是两个无锁环形缓冲区 (ring buffer) 加上一个提交队列尾部指针数组。所有结构都映射到用户态内存,用户态直接写入提交队列,内核直接读取;完成事件由内核写入 Completion Queue,用户态轮询读取。整个过程(热路径)不需要任何系统调用。

// 三个核心数据结构(简化)
struct io_uring_sq {
    unsigned *head;        // 内核消费头(内核写,用户态读)
    unsigned *tail;        // 用户态生产尾(用户态写,内核读)
    unsigned *ring_mask;   // 用于快速取模
    unsigned *ring_entries;
    unsigned *flags;       // 用于 SQPOLL 模式:检查是否需要 enter
    struct io_uring_sqe *sqes[SQ ring size];  // SQE 数组(包含具体 I/O 参数)
};

struct io_uring_cq {
    unsigned *head;        // 用户态消费头(用户态写,内核读)
    unsigned *tail;        // 内核生产尾(内核写,用户态读)
    unsigned *ring_mask;
    unsigned *ring_entries;
    struct io_uring_cqe *cqes[CQ ring size];  // CQE 数组
};

struct io_uring {
    struct io_uring_sq sq;
    struct io_uring_cq CQ;
    int ring_fd;           // 通过 io_uring_setup 获得的文件描述符
};

关键设计要点:

  • head 与 tail 分离:生产者只更新自己控制的 tail,消费者只更新自己控制的 head,避免 cache line bouncing
  • ring_mask 替代取模:2 的幂次大小用位运算,SQ/CQ 支持 up to 32768 条目
  • SQE 数组与 SQ ring 分离:SQ ring 存索引,真正的大对象(sqe 包含 64 字节 union)在数组中,减少 ring 上的 cache contention

二、SQE 生命周期详解

一个 SQE (Submission Queue Entry) 从准备到完成经历 4 个阶段:

  1. 初始化:用户态直接写入 sqes[tail % ring_mask],无需 syscall
  2. 提交:更新 sq->tail;批量完成后调用 io_uring_enter(ring_fd, submitted, min_complete, flags, NULL)
  3. 内核处理:内核从 SQ ring 取出 SQE,转化为内部 kiocb 或网络层请求,开始执行
  4. 完成回写:内核写 cqes[tail % ring_mask],更新 cq->tail;用户态通过头指针读取
// 最小化系统调用路径(submit all + wait for completion)
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_readv(sqe, fd, &iovec, 1, offset);
sqe-->user_data = (uint64_t)my_op_context;  // 关键:用于完成时回调

// 批量提交:一次 enter 提交 N 个 SQE
int submitted = io_uring_submit(ring); // 内部调用 io_uring_enter

// 热路径:无锁读取完成事件
struct io_uring_cqe *cqe;
unsigned head;
io_uring_for_each_cqe(ring, head, cqe) {
    handle_completion(cqe->user_data, cqe->res);
}
io_uring_cq_advance(ring, nr_completed);  // 批量推进 head

三、三大注册机制:fixed files / fixed buffers / registered buffers

原始的 io_uring 每次 SQE 都携带 fd 和 buffer 指针,内核需要通过 fget()/get_user_pages() 把「fd → file」「user VA → struct page」翻译出来,热路径额外开销显著。5.1+ 起提供 io_uring_register 预注册接口,将这一翻译提前完成。

// 1) 固定文件注册(避免 fget/fput)
int fds[] = {fd1, fd2, fd3};
io_uring_register_files(ring, fds, 3);
// 之后 SQE 设置: sqe->=IORING_FIXED_FILE(0) 而非原始 fd

// 2) 固定缓冲区注册(避免 pin pages + map)
struct iovec iovecs[nr_bufs];
io_uring_register_buffers(ring, iovecs, nr_bufs);
// SQE 设置: sqe->addr = iovecs[i].iov_base; sqe->=REGISTERED_BUFFER(i)

// 3) 5.13+ 事件注册(稀疏事件通知)
io_uring_register_eventfd(ring, eventfd_fd);  // epoll 集成
io_uring_register_eventfd_async(ring, eventfd_fd);  // 空闲期不通知

实际收益:registered buffers 在 O_DIRECT 场景下减少 ~20-30% 的 submit 延迟固定文件注册在多 fd 轮询场景减少 fget/fput 原子操作;eventfd 注册使得 io_uring 与 epoll/协程框架无缝集成。

四、SQPOLL / IORING_SETUP_SQPOLL 内核线程轮询

默认模式下,用户态批量 submit 后仍需要调用 io_uring_enter 告诉内核「有活要干」。当 SQPOLL 设定后,内核创建专用线程(io-wq + sqthread),持续扫描 SQ ring,无用户态 enter 也能主动消费 SQE。

// 启用 SQPOLL
struct io_uring_params params = {
    .sq_thread_idle = 2000,  // ms,空闲超过此值线程睡眠
};
io_uring_queue_init_params(ring_size, &ring, &params);

// SQPOLL 设定后:
// - 用户态写入 sqe + 更新 tail 后,内核线程自动 detect 新条目
// - 当 sq_thread_idle 到期 && 无新条目,线程让出 CPU(避免忙等)
// - 用户态仍可用 io_uring_enter(ENTER_SQ_WAKEUP) 唤醒睡眠中的轮询线程

优点:零 syscall 提交 + 内核自动拉取,P99 延迟进一步下降。

注意:SQPOLL 线程以绑定的方式运行在提交者 CPU 上,跨 NUMA 场景需谨慎;SQPOLL + registered buffers + IORING_SETUP_ATTACH_WQ 组合使用时需要理解 WAITQ 唤醒语义。

五、buf_group:多缓冲池选择性 recv

6.x 引入的 io_uring_prep_provide_buffers 与 buf_group 机制解决了「异步接收但缓冲区提前分配」的难题——内核从 pool 中摘下 buffer 用完后回收,用户态只需要管理 pool 水位。

// NVMe Passthrough(等价于 ioctl NVME_IOCTL_ADMIN_CMD)
struct nvme_passthru_cmd cmd = {
    .opcode = 0x06,         // identify
    .nsid = 1,
    .addr = (uint64_t)data,
    .data_len = 4096,
    .cdw10 = 1,
};
io_uring_cmd(ring, O_RDONLY, NVME_IOCTL_ADMIN_CMD, &cmd);
// 返回:cqe->res = NVMe Status Field; 无内核 BIO 中间层

与 SPDK 的关系:SPDK 原本依赖 uio/vfio 用户态驱动,iring_cmd 让内核态 io_uring 也能触及直通层,两者适用场景出现重叠。目前趋势是:IO 密集型低延迟(金融交易)仍首选 SPDK(完全 kernel bypass),稳定、可维护场景倾向 uring_cmd(内核统一管理、带 fs 层语义)。

七、与 epoll 的集成模式

io_uring 与 epoll 是互补而非替代——epoll 告诉内核「有 fd 就绪」,io_uring 执行「实际 I/O 并拿到结果」。常见组合:

// 模式1:epoll 监听 eventfd,io_uring 写 epoll 事件
io_uring_register_eventfd(ring, efd);
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, efd, &ev);
epoll_wait(...);  // 当 io_uring 有 CQE 写入时,efd 可读

// 模式2:epoll 读就绪后 io_uring submit read
while (1) {
    epoll_wait(epoll_fd, events, maxevents, timeout);
    for (each readable fd) {
        sqe = io_uring_get_sqe(ring);
        io_uring_prep_read(sqe, fd, buf, len, 0);
        io_uring_submit(ring);
    }
    // 批量收割 CQE
    io_uring_peek_batch_cqe(ring, cqes, batch_size);
}

// 模式3:linux 5.19+ LINK operation (sqe->>=IOSQE_IO_LINK)
// 链式 A→B→C 提交,B 失败则 C 被取消
io_uring_prep_readv(sqe_a, ...); sqe_a->flags |= IOSQE_IO_LINK;
io_uring_prep_write(sqe_b, ...);  sqe_b->flags |= IOSQE_IO_LINK;
io_uring_prep_fsync(sqe_c, ...);  // A->B->C 原子链

八、生产陷阱与迁移清单

从 libaio 迁移到 io_uring 需要特别注意的 6 个陷阱:

陷阱libaio 行为io_uring 行为迁移建议
O_DIRECT 对齐必须 512B 对齐可选(buffered I/O 原生支持)确认是否真的需要 direct;如不需要,移除 O_DIRECT 获得更好的页缓存利用
线程安全单个 iocb 非线程安全SQE 写入需要锁保护或 per-thread ring多线程场景用 IORING_SETUP_ATTACH_WQ 共享 SQ 线程或各自独立 ring
文件状态同步fsync 返回后数据稳定落地IORING_OP_FSYNC 成功后仍需 barrier 保证使用 sqe->=IORING_FSYNC_DATASYNC / IORING_FSYNC_SYNC
EINTR / 已提交失败提交后回调中处理错误已提交的 SQE 可能在 CQE 中返回 EAGAIN 等错误每个 user_data 必须包含完整上下文,不能丢失
连接场景不能收网络 I/O支持 sendmsg/recvmsg/send_zc网络+磁盘统一用 io_uring,避免 libaio + epoll 两套栈
阻塞操作始终返回 -EAGAIN 或排队可选 IOSQE_ASYNC 强制走 workqueue涉及文件系统元数据的调用(如 mkdir)建议加 IOSQE_ASYNC

九、内核 5.10-6.4 关键特性演进

内核版本关键 io_uring 改进
5.10IORING_FEAT_FAST_POLL(内核内部 fast poll,减少 epoll 切换)、IORING_OP_TIMEOUT(纳秒级定时器)、eventfd_async
5.11IORING_OP_SHUTDOWN、IORING_OP_ACCEPT、IORING_OP_RECVMSG/SENDMSG(基础网络支持)
5.15fixed buffer 回收机制、IORING_OP_PROVIDE_BUFFERS(原 provide buffers 进入稳定)、fork 后 ring 稳定
5.19IOSQE_IO_LINK 升级为 IOSQE_IO_HARDLINK(严格串行)、multishot recv(一次注册多次触发、减少 SQE 消耗)
6.0IORING_OP_URING_COMMAND(uring_cmd 直通 NVMe)、IORING_SETUP_SUBMIT_ALL(提交失败不中断链)、ring 屏障重做减少 smp_mb 次数
6.1一键式 zero-copy send_zc(减少一次 copy)、IORING_OP_SENDMSG_ZC、sqpoll task_sq 独立调度实体
6.2用户态选择的 buffer pool 与 io_uring 集成 (IORING_OP_PROVIDE_BUFFERS_GROUP)、AF_XDP 深度集成
6.3PARSE 命令的 uring_cmd 绑定、IORING_OP_FIXED_FD_INSTALL(注册 fd 时不立即消耗)、重做的 req-&;cache 减少高频分配延迟
6.4io_uring 线程池完全工作队列与 BATCH 策略改进、IORING_OP_READ/WRITE 自动选 kio 或 buffered、成熟的大小 SQ 对齐

十、实战:RocksDB 集成 io_uring 的代码素描

RocksDB 在 8.4+ 版本实验性引入了 io_uring 适配层,下面是一个简化的集成模型,展示 libaio → io_uring 迁移的核心映射关系:

class IOUringEngine {
    io_uring ring_;
    std::atomic<uint64_t> inflight_{0};

public:
    IOUringEngine() {
        struct io_uring_params p = {};
        p.flags |= IORING_SETUP_SQPOLL;       // 内核轮询
        p.sq_thread_idle = 1000;               // 1s 空闲睡眠
        io_uring_queue_init_params(256, &ring_, &p);
        // 固定缓冲区注册(用于 WAL 写)
        io_uring_register_buffers(&ring_, wal_iovs_, 64);
    }

    // 异步写 WAL
    void WriteWAL(const Slice& data, uint64_t offset, Callback cb) {
        io_uring_sqe* sqe = io_uring_get_sqe(&ring_);
        io_uring_prep_writev(sqe, wal_fd_, &iov, 1, offset);
        sqe->user_data = reinterpret_cast<uint64_t>(new WALRequest{cb, data});
        sqe->buf_index = WAL_BUF_ID;   // 固定 buffer
        sqe->flags |= IOSQE_FIXED_FILE | IOSQE_IO_LINK;
        inflight_.fetch_add(1, std::memory_order_relaxed);
    }

    // 批量提交
    void SubmitIfNeeded(int batch = 32) {
        if (inflight_ >= batch) {
            io_uring_submit(&ring_);
            inflight_.store(0, std::memory_order_relaxed);
        }
    }

    // 收割完成事件
    void ReapCompletions(int max = 64) {
        io_uring_cqe* cqes[64];
        int n = io_uring_peek_batch_cqe(&ring_, cqes, max);
        for (int i = 0; i < n; ++i) {
            auto* req = reinterpret_cast<WALRequest*>(cqes[i]->user_data);
            req-&gt;(cqes[i]->res);  // 用户回调
            delete req;
        }
        io_uring_cq_advance(&ring_, n);
    }
};

十一、性能基准:io_uring vs libaio vs sync(NVMe 4K 随机读)

在 Intel Optane P5800X (1TB) 上,reactor 模式固定提交 + SQPOLL + registered buffers 的测试数据:

模式IOPS (QD=128)平均延迟 (μs)P99 延迟 (μs)系统调用频率
sync pread180K7101520每次 I/O 1 次
libaio (io_submit)620K207580每次 io_submit 1 次
io_uring (enter per batch)980K131210每 commit batch 1 次
io_uring (SQPOLL + reg)1,280K99.5142热路径 0 次

差距根源并非硬件不同,而是 io_uring 的 SQ→内核 单向写入消除了 libaio io_submit 紧耦合的 syscall batching 开销、避免了 pinned buffer 重复映射。SQPOLL 进一步把「唤醒内核」这一步也消除,延迟曲线向左大幅移动。

十二、安全:seccomp 与 uring 隔离

io_uring 暴露的 syscall (io_uring_setup / io_uring_enter / io_uring_register) 传统上被容器/沙箱禁止——一个 io_uring 调用能同时达到「执行任意 I/O」「共享内存 pin page」「spin 内核线程」三种特权。6.x 新增了 IORING_SETUP_R_DISABLED(ring 初始化后禁止提交、仅 register 后才开放)和 io_uring 专用 seccomp profiles。


// 启动禁用模式,只有显式 io_uring_register 才允许 enter
struct io_uring_params p = {
    .flags = IORING_SETUP_R_DISABLED | IORING_SETUP_SUBMIT_ALL
};
io_uring_queue_init_params(1, &ring, &p);
// 失败:io_uring_enter(&ring, 1, 0, 0, NULL); // 返回 EPERM

// 注册缓冲区后明确开启
io_uring_register(&ring, IORING_REGISTER_ENABLE_RINGS, NULL, 0);
// 现在 io_uring_enter 可用

容器场景推荐将 io_uring 子系统加入 allow list,配合 CAP_SYS_ADMIN drop 与 seccomp 白名单;多租户场景考虑 per-user 的 SQ/CQ size cgroup 限制。

十三、总结:谁应该立刻迁移

场景建议
WAL / LSM tree 写libaio + O_DIRECT → io_uring + registered buffer(减少 kernel pin 时间)
分布式存储 Raft log 盘libaio → io_uring + fixed files + batched submit(降低 fsync 尾延迟)
Web 静态服务 (nginx)启用 io_uring sendfile + accept 绕过
高频交易 / 金融订单保持 SPDK(更稳定 kernel bypass),但配 io_uring 监控/net 链路
容器平台 (k8s)开启 io_uring 白名单,统一块 I/O + 网络栈
嵌入式 / 轻量级 Java 服务JDK 21 起虚拟线程 + io_uring transport,避免 Netty epoll

io_uring 代表的不仅仅是「更快 I/O」,而是「用户态与内核协作方式」的范式转变。从 5.1 的实验性特性到 6.x 的异步 I/O 统一入口,它已经完成了跨越。对掌握存储/网络子系统的工程师而言,理解 SQ/CQE 生命周期、SQPOLL 语义、registered buffer 收益——不再是加分项,而是必备标签。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部