跳转到正文
zeno's blog
返回

Linux I/O(二):io_uring 的双环模型与工程边界

专题: Linux I/O

Table of contents

Open Table of contents

TL;DR

io_uring 的本质不是”更快的 epoll”,而是用户态和内核态共享两个 mmap 环(SQ 提交环 / CQ 完成环)+ 完成模型(submit then wait for completion),统一覆盖文件 / socket / timer / futex 的异步。相较 epoll 的 readiness 模型,它在存储 I/O(libaio 无法 buffered)和syscall 密集场景(SQPOLL 下热路径零 syscall)有质变;对已优化过的 epoll 网络栈提升只有 10–30%。工程上的真实门槛是内核版本下限 ≥ 5.15容器 seccomp 默认屏蔽安全 CVE 历史重、多云厂商禁用,以及一条必须铭刻的规则:CQE 的完成顺序和 SQE 的提交顺序无关,必须靠 user_data 关联

一句警示:Docker 25.0+ 默认 seccomp profile 屏蔽 io_uring_setup/enter/register,容器里跑会 EPERM。设计基于 io_uring 的服务前先确认部署环境。


1. Why:为什么 Linux 异步 I/O 在 io_uring 之前是一片废墟

Linux 在 io_uring 之前有四种”异步” I/O 方案,每一种都在某个维度上破产

方案模型关键缺陷
epoll + 非阻塞 rwreadiness只告诉你”可读/可写”,真正搬数据还得 read/write 系统调用;regular file 永远 ready,磁盘 I/O 根本不 async
POSIX AIO (aio_read)completion(伪)glibc 用户态线程池模拟,每个 op 一次 pthread 切换 + 回调
libaio (io_submit)completionO_DIRECT 真异步;buffered I/O 静默退化为阻塞。每 op 两次 syscall + iocb 拷贝
线程池(read + N 线程)completionM:N 切换、cache miss、锁竞争,高 IOPS 下永远跑不过事件驱动

epoll 解决的只是网络 readiness 通知。libaio 的 io_setup(2) 接口在内核里被维护了十年都无法扩展到网络 fd,也无法处理 buffered I/O——Jens Axboe(块层 maintainer)在 2019 年 LWN 上的原话是:“it is clear libaio will never reach its goals.

io_uring 的突破在两个维度上同时成立:

  1. 完成模型(completion-based):内核做完 I/O 后把结果直接塞进 CQ,用户态不用再发起 read——一次 SQE 完成全部工作
  2. 零拷贝元数据通道:SQ/CQ 是 mmap 共享内存,用户写 SQE、内核写 CQE,都不需要把 iocb 从用户态拷进内核

再叠加 SQPOLL 模式(内核线程轮询 SQ,热路径 0 syscall),就是今天 io_uring 的性能故事。


2. 架构:三段 mmap + 双环协议

2.1 内存布局

io_uring_setup(2) 返回一个 fd,用户 mmap 该 fd 拿到三段共享区(偏移来自 <linux/io_uring.h>):

mmap 偏移常量内容方向
IORING_OFF_SQ_RINGSQ 的 head/tail 指针 + array[] 间接索引用户写 tail,内核读 head
IORING_OFF_CQ_RINGCQ 的 head/tail 指针 + CQE 数组内核写 tail,用户读 head
IORING_OFF_SQESSQE 对象池(每项 64B,SETUP_SQE128 后 128B)用户填

内核 5.4+ 的 IORING_FEAT_SINGLE_MMAP 允许 SQ/CQ ring 合并为一次 mmap;6.5+ 的 IORING_SETUP_NO_MMAP 允许用户自己传入 huge pages。

2.2 数据结构

// <linux/io_uring.h> 简化版
struct io_uring_sqe {   // 64 B
    __u8  opcode;       // IORING_OP_READ / WRITE / RECV / ACCEPT ...
    __u8  flags;        // IOSQE_FIXED_FILE / IO_LINK / CQE_SKIP_SUCCESS ...
    __u16 ioprio;
    __s32 fd;           // 或 fixed file index
    __u64 off;
    __u64 addr;         // 用户 buffer
    __u32 len;
    __u32 op_flags;
    __u64 user_data;    // ★ 完成时原样回显到 CQE,是关联 SQE→CQE 的唯一钩子
    // ...
};

struct io_uring_cqe {   // 16 B
    __u64 user_data;    // 原样回显
    __s32 res;          // 成功返回值 或 -errno
    __u32 flags;        // IORING_CQE_F_MORE / F_BUFFER ...
};

user_data连接 SQE 与 CQE 的唯一手段。因为多个 I/O 并发提交后,完成顺序和提交顺序无关,必须靠 user_data 识别是谁。

2.3 提交协议(内核如何知道有新 SQE)

用户侧流程(liburing 已封装,这里讲原理):

1. tail = sq->tail               // 非原子读,自己写的
2. idx  = tail & sq->ring_mask
3. 填充 sqes[idx]                 // opcode/fd/addr/len/user_data
4. sq->array[idx] = idx           // 间接索引(6.6+ NO_SQARRAY 可省)
5. smp_store_release(sq->tail, tail + 1)   // 发布屏障
6. io_uring_enter(...)            // 通知内核(SQPOLL 模式下省略)

原子 release/acquire 配对:用户写完 SQE 后 release,内核 acquire 读到 tail 更新,才能安全读取 SQE 内容——绕开任何锁。

2.4 核心加速机制

机制内核版本作用
SQPOLL (IORING_SETUP_SQPOLL)5.1内核起专线程轮询 SQ,热路径 0 syscall;代价是 1 核常驻 CPU
IOPOLL (IORING_SETUP_IOPOLL)5.1块设备忙轮询,需 O_DIRECT + 驱动支持
Fixed Files (REGISTER_FILES)5.1fd 数组预注册,SQE 的 fd 变 index,省掉每次 fget/fput
Fixed Buffers (REGISTER_BUFFERS)5.1预 pin 用户内存页,READ_FIXED/WRITE_FIXED 免去每次 get_user_pages
Linked SQE (IOSQE_IO_LINK)5.3相邻 SQE 串依赖链,链中任意一环失败则后续 -ECANCELED
Multishot (poll/accept/recv)5.13–5.20一个 SQE 长期产生多个 CQE,用 IORING_CQE_F_MORE 标记”还会有”
SEND_ZC 零拷贝5.19避免用户页到内核 skb 的拷贝,大包收益明显
SINGLE_ISSUER + DEFER_TASKRUN6.0 / 6.1单线程提交约束 + 延迟 task work,消除 IPI

3. 三个核心系统调用

syscall作用
io_uring_setup(entries, params)创建 ring,返回 fd;params 回填偏移量和 features
io_uring_enter(fd, to_submit, min_complete, flags, sig)提交 + 等待一体(IORING_ENTER_GETEVENTS 同时 wait)
io_uring_register(fd, opcode, arg, nr)注册 buffers / files / eventfd / ring fd / restrictions

重要的 params.flags(按版本):

5.1  IOPOLL SQPOLL SQ_AFF CQSIZE CLAMP ATTACH_WQ
5.10 R_DISABLED
5.18 SUBMIT_ALL
5.19 COOP_TASKRUN TASKRUN_FLAG SQE128 CQE32
6.0  SINGLE_ISSUER
6.1  DEFER_TASKRUN
6.5  NO_MMAP REGISTERED_FD_ONLY
6.6  NO_SQARRAY

生产建议默认组合:SINGLE_ISSUER | COOP_TASKRUN | DEFER_TASKRUN(要求 6.1+),在单线程 reactor 下实测能再降 15–20% CPU。


4. liburing:生产代码应该用的封装

裸 io_uring 协议每版都可能加字段,直接 mmap/写 tail 会让代码耦合到具体内核版本。liburing 是 Jens Axboe 亲自维护的 C 库,API 稳定、跨版本兼容、自动选择最优路径(如 CQE32 或否)。

核心 API(<liburing.h>):

// 初始化
int  io_uring_queue_init(unsigned entries, struct io_uring *ring, unsigned flags);
int  io_uring_queue_init_params(unsigned entries, struct io_uring *ring,
                                struct io_uring_params *p);
void io_uring_queue_exit(struct io_uring *ring);

// 提交侧
struct io_uring_sqe *io_uring_get_sqe(struct io_uring *ring);
void io_uring_prep_read   (sqe, fd, buf, nbytes, offset);
void io_uring_prep_write  (sqe, fd, buf, nbytes, offset);
void io_uring_prep_recv   (sqe, fd, buf, len, flags);
void io_uring_prep_send   (sqe, fd, buf, len, flags);
void io_uring_prep_accept (sqe, fd, addr, addrlen, flags);
void io_uring_prep_multishot_accept(sqe, fd, addr, addrlen, flags);   // 5.19+
void io_uring_prep_connect(sqe, fd, addr, addrlen);
void io_uring_prep_close  (sqe, fd);
void io_uring_prep_openat (sqe, dfd, path, flags, mode);
void io_uring_prep_timeout(sqe, ts, count, flags);
void io_uring_sqe_set_data64(sqe, __u64 data);
int  io_uring_submit(struct io_uring *ring);
int  io_uring_submit_and_wait(struct io_uring *ring, unsigned wait_nr);

// 完成侧
int  io_uring_wait_cqe(struct io_uring *ring, struct io_uring_cqe **cqe);
int  io_uring_peek_cqe(struct io_uring *ring, struct io_uring_cqe **cqe);
unsigned io_uring_peek_batch_cqe(ring, cqes, count);
void io_uring_cqe_seen(struct io_uring *ring, struct io_uring_cqe *cqe);

// 注册(性能优化)
int io_uring_register_buffers(ring, iov, nr);
int io_uring_register_files  (ring, fds, nr);
int io_uring_register_eventfd(ring, fd);

io_uring_cqe_seen 不是可选的——不调就不前进 CQ head,CQ 填满后新 CQE 会溢出(5.5 前丢弃,5.5+ 靠 backlog 兜底但同样有上限)。


5. C++17 例:multishot accept 的 echo server

完整可编译例(需内核 5.19+ 支持 multishot_accept):

// g++ -std=c++17 -O2 echo.cpp -luring
#include <liburing.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
#include <cstdint>
#include <cstring>

enum Op : uint8_t { ACCEPT, READ, WRITE };

// Req 生命周期必须覆盖 SQE 提交到 CQE 到达的整段时间——堆分配最稳妥
struct Req {
    Op       op;
    int      fd;
    uint32_t len;
    char     buf[4096];
};

int main() {
    int listen_fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
    int one = 1;
    ::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
    sockaddr_in a{};
    a.sin_family = AF_INET;
    a.sin_port = htons(9000);
    a.sin_addr.s_addr = INADDR_ANY;
    if (::bind(listen_fd, reinterpret_cast<sockaddr*>(&a), sizeof(a)) < 0) {
        ::perror("bind"); return 1;
    }
    if (::listen(listen_fd, 1024) < 0) { ::perror("listen"); return 1; }

    io_uring ring;
    io_uring_params p{};
    // 需 Linux 6.1+;若更低版本去掉 DEFER_TASKRUN / SINGLE_ISSUER
    p.flags = IORING_SETUP_COOP_TASKRUN
            | IORING_SETUP_SINGLE_ISSUER
            | IORING_SETUP_DEFER_TASKRUN;
    if (io_uring_queue_init_params(4096, &ring, &p) < 0) {
        ::perror("io_uring_queue_init_params"); return 1;
    }

    // 一次性 arm multishot accept:之后每来一个连接产生一个 CQE
    auto* acc = new Req{ACCEPT, listen_fd, 0, {}};
    io_uring_sqe* sqe = io_uring_get_sqe(&ring);
    io_uring_prep_multishot_accept(sqe, listen_fd, nullptr, nullptr, 0);
    io_uring_sqe_set_data64(sqe, reinterpret_cast<uint64_t>(acc));
    io_uring_submit(&ring);

    for (;;) {
        io_uring_cqe* cqe = nullptr;
        int rc = io_uring_wait_cqe(&ring, &cqe);
        if (rc < 0) {
            if (rc == -EINTR) continue;
            ::fprintf(stderr, "wait_cqe: %s\n", ::strerror(-rc));
            break;
        }
        auto* r = reinterpret_cast<Req*>(cqe->user_data);
        int      res   = cqe->res;
        uint32_t flags = cqe->flags;

        switch (r->op) {
        case ACCEPT:
            if (res < 0) {
                ::fprintf(stderr, "accept: %s\n", ::strerror(-res));
            } else {
                auto* rd = new Req{READ, res, 0, {}};
                sqe = io_uring_get_sqe(&ring);
                io_uring_prep_recv(sqe, res, rd->buf, sizeof(rd->buf), 0);
                io_uring_sqe_set_data64(sqe, reinterpret_cast<uint64_t>(rd));
            }
            // multishot: 没带 F_MORE 说明已终结,需重新 arm
            if (!(flags & IORING_CQE_F_MORE)) {
                sqe = io_uring_get_sqe(&ring);
                io_uring_prep_multishot_accept(sqe, listen_fd, nullptr, nullptr, 0);
                io_uring_sqe_set_data64(sqe, reinterpret_cast<uint64_t>(r));
            }
            break;

        case READ:
            if (res <= 0) { ::close(r->fd); delete r; break; }
            r->op  = WRITE;
            r->len = static_cast<uint32_t>(res);
            sqe = io_uring_get_sqe(&ring);
            io_uring_prep_send(sqe, r->fd, r->buf, r->len, 0);
            io_uring_sqe_set_data64(sqe, reinterpret_cast<uint64_t>(r));
            break;

        case WRITE:
            if (res < 0) { ::close(r->fd); delete r; break; }
            // 生产代码要处理 short write:res < r->len 时续写剩余
            r->op = READ;
            sqe = io_uring_get_sqe(&ring);
            io_uring_prep_recv(sqe, r->fd, r->buf, sizeof(r->buf), 0);
            io_uring_sqe_set_data64(sqe, reinterpret_cast<uint64_t>(r));
            break;
        }

        io_uring_cqe_seen(&ring, cqe);
        io_uring_submit(&ring);
    }
    io_uring_queue_exit(&ring);
    return 0;
}

关键点:


6. 错误码与边界行为(必须理解)

错误场景处理
cqe->res >= 0成功;语义和对应同步 syscall 一致注意 short read/write(res < 请求 len
cqe->res == -EAGAIN内部调度被打断(罕见)重提 SQE
cqe->res == -EINTR被信号打断(通常是 enter 调用)重试
cqe->res == -ECANCELEDASYNC_CANCEL / 链断 / timeout link 取消清理关联资源
cqe->res == -EALREADY取消失败(op 已进入执行阶段)设计为可容忍
cqe->res == -EOPNOTSUPP当前内核 / 文件系统不支持该 opcode回退到同步或 epoll
cqe->res == -EINVALSQE 参数非法;包含 bit 位未清零永远先 memset SQE
io_uring_enter 返回 -EBUSYCQ 满,无法再提交先消费 CQE 再提交

关键陷阱io_uring_prep_* 不会自动清零 SQE 其他字段。liburing 内部对 union 大部分位做了清理,但自定义时务必 memset(sqe, 0, sizeof(*sqe)) 再设字段。


7. 内核版本时间线(决定你能用哪些特性)

内核关键特性
5.1 (2019-05)首发:READV/WRITEV/FSYNC/POLL_ADD,REGISTER_BUFFERS/FILES,SQPOLL(需 CAP_SYS_ADMIN)
5.3IOSQE_IO_LINK、SENDMSG/RECVMSG
5.5ACCEPT/CONNECT/ASYNC_CANCEL,FEAT_NODROP(CQ 溢出 backlog)
5.6READ/WRITE 标量版、OPENAT/CLOSE/STATX,async buffered read
5.7SPLICE/PROVIDE_BUFFERS,FEAT_FAST_POLL
5.11FEAT_EXT_ARG;SQPOLL 改为 CAP_SYS_NICE
5.12FEAT_NATIVE_WORKERS(真正任务身份 worker)
5.13multi-shot POLL;SQPOLL 无需任何特权
5.15 LTSREGISTER_IOWQ_MAX_WORKERS,direct descriptors
5.18REGISTER_RING_FDS、MSG_RING、SETUP_SUBMIT_ALL
5.19SEND_ZC 零拷贝、SETUP_COOP_TASKRUN/SQE128/CQE32、multishot ACCEPT
6.0SETUP_SINGLE_ISSUER
6.1 LTSSETUP_DEFER_TASKRUN(强烈推荐开)
6.5NO_MMAP(用户自带内存,huge pages 友好)、WAITID
6.6 LTSNO_SQARRAY/proc/sys/kernel/io_uring_disabled 开关
6.7FUTEX_WAIT/WAKE、READ_MULTISHOT

生产下限建议:5.15 LTS(保底);要完整享受 io_uring 性能:6.1 LTS / 6.6 LTS


8. Pitfalls(至少 10 个,每个都踩过人)

  1. 完成顺序 ≠ 提交顺序。并发的 SQE 内核可能按任何顺序完成。必须用 user_data 关联,不能假设”先提交先完成”。链式依赖用 IOSQE_IO_LINK
  2. Buffer 生命周期覆盖不足。SQE 提交后 buffer 被异步 I/O 持有,直到 CQE 到达才能释放。栈上 buffer、函数返回后析构的对象都是 UAF 陷阱。
  3. 没调 io_uring_cqe_seen。CQ head 不前进,很快堆满导致 overflow;5.5 前直接丢 CQE,5.5+ 靠 backlog 但 backlog 非无限,SQ 提交会退化为同步等待。
  4. SQE 脏字段io_uring_prep_* 不保证清空所有字段。复用旧 SQE 时残留的 flagsioprio、union 尾部字节会导致 -EINVAL
  5. CQ size 等于 SQE size。默认 CQ = 2×SQ,高扇出场景(multishot、一个 SQE 多 CQE)容易溢出。显式设 p.cq_entries = 4 * entries
  6. SQPOLL 以为是免费的。它消耗一个常驻 CPU 核,空闲 sq_thread_idle ms 后挂起,挂起后你的第一次提交要走 IORING_ENTER_SQ_WAKEUP 再唤醒,延迟反而变大。低负载服务开 SQPOLL 是负优化。
  7. ASYNC_CANCEL 不保证成功。常回 -EALREADY(已进入执行)或 -ENOENT(没找到)。设计必须把”取消失败”当正常分支。
  8. multishot 没处理 F_MORE 消失F_MORE 未置位意味着这条 multishot 已终结(比如 accept 被 shutdown、poll 一次触发结束),必须重新 arm,否则服务直接卡死不再 accept 新连接。
  9. 多线程共享一个 ringio_uring_get_sqe/io_uring_submit 不是无锁线程安全的。最佳实践是一线程一 ring(thread-local),必要时用 MSG_RING 跨线程投递。SETUP_SINGLE_ISSUER(6.0+)让内核校验并解锁 DEFER_TASKRUN 优化。
  10. 容器内直接失败Docker 25.0+ 默认 seccomp profile 屏蔽 io_uring_setup/enter/register(moby#47532, PR #46762);RHEL 默认 io_uring_disabled=2。部署前必确认宿主策略,不然启动就 EPERM
  11. fixed files 的注册时机REGISTER_FILES_UPDATE(5.5+)前只能先 UNREGISTER 再 REGISTER,全量替换代价 O(N)。长连接服务的 fd 集合变化频繁时注册表应按 slot 复用。
  12. 链式超时的逻辑LINK_TIMEOUT 必须紧跟在被它保护的 SQE 之后,不是独立 submit。超时到期会给前一个 SQE 发 -ECANCELED,而不是超时本身成为错误。

9. 对比:io_uring vs epoll vs libaio vs 线程池

维度io_uringepollPOSIX AIOlibaio (io_submit)线程池
模型completionreadinesscompletion(伪)completioncompletion
regular file 异步❌(永远 ready)✅(线程模拟)仅 O_DIRECT
网络异步有限
每 op syscall0(SQPOLL)– 1(batched)≥2(wait + rw)多(线程切换)2(submit + getevents)多(切换)
元数据拷贝mmap 共享 0 拷贝event 结构拷贝用户态线程iocb 入核拷贝全用户态
最低内核5.1(推荐 6.1+)2.5.45任意2.5任意
用户 API 复杂度高(liburing 中)
零拷贝发送SEND_ZC (5.19+)需 MSG_ZEROCOPY需 MSG_ZEROCOPY
可取消ASYNC_CANCEL(弱)close fdaio_cancel(弱)自实现
容器默认可用❌(Docker seccomp 屏蔽)视版本
安全 CVE 风险高(kCTF 占 60% 利用)

选型建议


10. 安全:io_uring 为何被多家大厂禁用

2023 年 Google 披露:提交到 kCTF VRP 的 Linux 内核 exploit 中 60% 利用 io_uring,合计付出约 100 万美元赏金。原因:

禁用/限制 io_uring 的环境

环境策略
ChromeOS完全禁用
Android(应用层)seccomp 禁止
Google 生产服务器全线禁用
Docker 25.0+(默认 seccomp)屏蔽 setup/enter/register
GKE AutoPilot屏蔽
RHEL 9 默认io_uring_disabled=2(完全禁用)

6.6+ 提供 /proc/sys/kernel/io_uring_disabled 三档:0 允许所有、1 仅特权用户、2 禁用。生产容器场景默认取向是 12

结论:基于 io_uring 的服务在部署文档里必须明确”不兼容默认 Docker seccomp”、“要求特定 sysctl”,否则用户会遇到诡异 EPERM


11. 生产 Checklist


参考(authoritative sources)


分享这篇文章:

上一篇
C++ 协程:语言机制、陷阱与实现边界
下一篇
C++ 网络编程:epoll、Reactor 与 one loop per thread