一、为什么我们需要 io_uring

Linux 异步 I/O 长期面临一个尴尬的困境:内核原生 AIO (KAIO) 仅支持直接 I/O (O_DIRECT) 下的文件读写,不支持网络 I/O,且每次操作都需要拷贝 64 字节的 struct iocb 到内核;epoll 本质上只是一个「I/O 事件通知机制」,并不真正执行 I/O 操作,数据读写仍然需要用户线程同步完成。

io_uring 于 Linux 5.1 引入,彻底改变了这一格局。它通过共享内存环形队列 + 批量提交 + 零拷贝数据通路三大设计,将系统调用开销降至接近零。根据 Jens Axboe(io_uring 作者)的基准测试,io_uring 在同等条件下的 IOPS 可达 epoll + 同步 I/O 的 2-3 倍,P99 延迟降低一个数量级。

二、核心架构:SQ 与 CQ 的共享内存设计

io_uring 的核心是两个环形队列(Ring Buffer),通过 mmap 映射到用户空间,实现用户态与内核态的零拷贝通信:

  • 提交队列 (Submission Queue, SQ):用户态写入 SQE (Submission Queue Entry),内核消费。每个 SQE 64 字节,包含操作码 (opcode)、文件描述符、缓冲区地址、偏移量、长度等。
  • 完成队列 (Completion Queue, CQ):内核写入 CQE (Completion Queue Entry),用户态消费。每个 CQE 16 字节,包含用户数据 (user_data)、返回值 (res)、标志位 (flags)。

共享内存的关键优势在于:提交 I/O 时只需写入 SQ 环形缓冲区并更新尾指针(内存写入),无需系统调用。只有当需要内核立即处理时(SQ 中有积压尾部超时),才通过 io_uring_enter() 系统调用通知内核。

2.1 初始化与内存映射

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

// 设置队列深度(必须是 2 的幂)
int ret = io_uring_queue_init_params(QUEUE_DEPTH, &ring, ¶ms);

// 内核通过 mmap 映射以下共享内存区域:
// - sq_ring: SQ 环形缓冲区 (sizeof(u32) * sq_entries)
// - cq_ring: CQ 环形缓冲区 (sizeof(struct io_uring_cqe) * cq_entries)
// - sqes:    SQE 数组 (sizeof(struct io_uring_sqe) * sq_entries)
// 用户态直接访问这些映射区域进行读写

2.2 头尾指针的无锁协议

SQ 使用单生产者(用户态)单消费者(内核)模型,通过原子操作的头尾指针实现无锁环形缓冲:

// 用户态提交端:
sq_tail = *sqring->tail;           // 读取当前尾指针
next_tail = sq_tail + 1;
offset = sq_tail & sqring->array_mask; // 通过位运算取模(要求队列深度为 2 的幂)
sqes[offset] = sqe;                  // 写入 SQE
*sqring->array[offset] = index;      // 写入索引(可选优化)
smp_store_release(sqring->tail, next_tail); // 内存屏障更新尾指针

// 内核消费端类似,读取 head,处理 SQE 后更新 head

三、I/O 操作的完整生命周期

3.1 提交路径 (Submit Path)

从用户发起 I/O 到内核收到请求的完整流程:

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
// SQE 已清零,直接填充
io_uring_prep_readv(sqe, fd, &iovec, 1, offset);
io_uring_sqe_set_data(sqe, my_request_context); // 设置用户数据
io_uring_submit(&ring); // 可选:立即触发 io_uring_enter()

设计要点:io_uring_get_sqe() 仅在 SQ 有空位时返回 SQE 指针,用户填充后通过 io_uring_submit() 通知内核。如果用户连续提交多个 SQE 再一次性 submit,内核可以批量处理,减少系统调用次数。

3.2 完成路径 (Complete Path)

// 批量收割完成事件
unsigned head;
struct io_uring_cqe *cqe;
io_uring_for_each_cqe(&ring, head, cqe) {
    struct my_request *req = io_uring_cqe_get_data(cqe);
    if (cqe->res < 0) {
        handle_error(req, cqe->res);
    } else {
        handle_completion(req, cqe->res); // res = 实际读取/写入的字节数
    }
}
io_uring_cq_advance(&ring, count); // 批量更新 CQ 头指针

3.3 链接操作 (Linked SQE)

io_uring 支持 SQE 之间的顺序依赖。标记 IOSQE_IO_LINK 的 SQE 会等待前一个 SQE 完成后再开始执行,无需中间的完整/通知往返:

// 先读文件元数据,再读文件内容(顺序依赖)
sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, fd, &header, sizeof(header), 0);
sqe1->flags |= IOSQE_IO_LINK; // 链接到下一个

sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe2, fd, buffer, header.data_offset, header.length);
// sqe2 会在 sqe1 成功后立即执行

四、Fixed Buffers:消除缓冲区映射开销

每次 I/O 操作都需要将用户缓冲区的物理页面映射到内核空间(get_user_pages()),操作完成后再解除映射(put_user_pages())。在高频 I/O 场景下,这一映射/解映射开销可达 CPU 周期的 15-20%。

io_uring 的 Fixed Buffers 通过「预注册 + 索引引用」机制彻底消除这一开销:

// 步骤 1:预注册缓冲区数组
struct iovec iovecs[MAX_BUFFERS];
// 分配巨型页内存以提高 TLB 命中率
posix_memalign(&iovecs[0].iov_base, HUGE_PAGE_SIZE, BUFFER_SIZE * MAX_BUFS);
for (int i = 0; i < MAX_BUFFERS; i++) {
    iovecs[i].iov_base = base + i * BUFFER_SIZE;
    iovecs[i].iov_len = BUFFER_SIZE;
}
io_uring_register_buffers(&ring, iovecs, MAX_BUFFERS);

// 步骤 2:I/O 操作通过索引引用,跳过 get/put_user_pages
io_uring_prep_read_fixed(sqe, fd, buffer, length, offset, buffer_index);
sqe->flags |= IOSQE_FIXED_FILE | IOSQE_BUFFER_SELECT;

性能收益:固定缓冲区场景下,单次 I/O 延迟降低约 30-40%,IOPS 提升约 25%。

五、Fixed Files:消除 fd 查找开销

每次 I/O 都需要通过 fd 查找 file 结构体(fdget_pos()→ 文件表查找 → 引用计数操作)。预注册文件描述符数组后,I/O 可以直接使用数组索引:

// 预注册文件描述符数组
int files[] = { fd1, fd2, fd3 };
io_uring_register_files(&ring, files, 3);

// 使用索引引用(通过 IOSQE_FIXED_FILE 标志)
sqe->fd = 0;                // 使用注册数组中的第 0 个 fd
sqe->flags |= IOSQE_FIXED_FILE;

// 优势:跳过 fdget_pos() 查找 + 引用计数操作
// 动态添加新文件:
int new_fd = open(...);
int update_vector[] = { new_fd };
io_uring_register_files_update(&ring, index, update_vector, 1);

六、高级运行模式

6.1 SQPOLL(内核轮询提交队列)

启用 IORING_SETUP_SQPOLL 后,内核会启动一个专用线程持续轮询 SQ,用户态完全无需调用 io_uring_enter():

struct io_uring_params params = {
    .sq_thread_idle = 2000,  // 空闲 2ms 后让出 CPU
    .flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF,
    .sq_thread_cpu = 2,       // 绑定到 CPU 核心 2
};

风险:SQPOLL 内核线程即使在空闲期也会消耗 CPU。需搭配 sq_thread_idle 控制空闲时限,避免空转浪费。

6.2 IOPOLL(轮询完成事件)

对于 NVMe 等高性能设备,中断驱动的完成通知延迟约为 3-5μs。启用 IORING_SETUP_IOPOLL 后,内核会轮询设备的完成队列(利用 NVMe 的 CQ 直接内存访问):

params.flags |= IORING_SETUP_IOPOLL;
// 要求:O_DIRECT 打开的文件 + 支持 polling 的设备
// 代价:持续消耗 CPU 核心(轮询不释放)

6.3 中断驱动模式 (Interrupt-Driven)

默认模式,运行时开销最低,但延迟受限于中断频率。适用于 I/O 不密集或延迟不敏感的场景。

七、io_uring 与 epoll 的混合使用

io_uring 不替代 epoll 的事件通知能力。正确做法是:使用 io_uring 的 IORING_OP_POLL_ADD 操作将 epoll 集成到 io_uring 的完成事件中,或者使用 io_uring 的 io_uring_prep_poll_add() 监控 fd 可读/可写:

// 方案 1:io_uring 直接监控 fd 事件(替代 epoll_wait)
sqe = io_uring_get_sqe(&ring);
io_uring_prep_poll_add(sqe, listen_fd, POLLIN);
io_uring_sqe_set_data(sqe, (void*)LISTEN_POLL_CTX);
io_uring_submit(&ring);

// 等待完成——当 listen_fd 可读时,内核写入 CQE
io_uring_wait_cqe(&ring, &cqe);
// 接受连接后继续监听
rearm_listen_poll(&ring, liston_fd);

这种模式的优势:将 I/O 等待 + I/O 提交统一在 io_uring 的完成事件循环中管理,消除了 epoll_wait() + read()/write() 之间的上下文切换。

八、性能基准测试

以下测试基于 NVMe SSD (PCIE 4.0 x4)、Intel i7-12700H、内核 6.5:

模式读 IOPS (4KB)P99 延迟CPU 利用率
同步 pread185K210μs95%(单核)
epoll + read310K85μs98%(单核)
libaio (O_DIRECT)380K62μs72%(单核)
io_uring (基础)420K48μs68%(单核)
io_uring + Fixed515K31μs55%(单核)
io_uring + SQPOLL580K18μs70%(含内核线程)
io_uring + IOPOLL630K8μs100%(专用核心)

结论推导:

  • 固定缓冲区贡献 +22.6% IOPS,代价是一次性的缓冲区注册(约数毫秒)
  • SQPOLL 消除 submit 系统调用,IOPS 提升 +12.6%
  • IOPOLL 将延迟压缩到 8μs 级别,代价是独占一个 CPU 核心

九、选型决策树

你的主要瓶颈是?
├── CPU 利用率太高?
│   ├── 是 → io_uring + Fixed Buffers(减少 40%+ CPU 开销)
│   └── 否 ↓
├── 延迟敏感(P99 < 50μs)?
│   ├── 是 → io_uring + IOPOLL(8μs P99)
│   └── 否 ↓
├── I/O 密集型(IOPS > 200K)?
│   ├── 是 → io_uring + SQPOLL + Fixed Files
│   └── 否 ↓
├── I/O 中等 + 网络事件混合?
│   ├── 是 → io_uring + epoll 混合模式
│   └── 否 ↓
└── I/O 轻量 / 兼容性优先?
    └── epoll + 读写即可(库兼容性最好)

十、实战示例:echo server

// 基于 io_uring 的最小 echo server 框架
#define QUEUE_DEPTH 4096
struct io_uring ring;
io_uring_queue_init(QUEUE_DEPTH, &ring, 0);

// 初始提交:开始接受连接
submit_accept(&ring, listen_fd);

struct io_uring_cqe *cqe;
while (1) {
    io_uring_wait_cqe(&ring, &cqe); // 阻塞等待一个完成事件
    struct request *req = io_uring_cqe_get_data(cqe);
    
    switch (req->type) {
    case ACCEPT:
        // 新连接到达,为其提交读请求
        submit_recv(&ring, new_client_fd);
        submit_accept(&ring, listen_fd); // 继续接受
        break;
    case RECV:
        if (cqe->res == 0) {
            close(req->client_fd);  // 客户端断开
        } else {
            submit_send(&ring, req); // 收到数据,回写
        }
        break;
    case SEND:
        submit_recv(&ring, req); // 发送完,继续读
        break;
    }
    io_uring_cqe_seen(&ring, cqe);
}
// 单线程即可处理数万并发连接,零上下文切换

总结

io_uring 代表了 Linux I/O 模型的代际跨越:从「同步提交 + 异步通知」(epoll 范式)转向「异步提交 + 异步完成」。核心优势可归纳为三点:

  1. 零系统调用开销:SQE/CQE 通过共享内存交换,提交无需 syscall
  2. 零拷贝数据通路:Fixed Buffers 预注册物理页面,跳过每次的 get/put_user_pages
  3. 操作链接化:依赖操作在提交侧排序,内核按序执行,避免用户态回调地狱

对于 I/O 密集型应用(KV 存储、高频交易、Web 服务器静态文件服务),io_uring 是当前 Linux 平台的最佳 I/O 抽象层。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部