引言:为什么 io_ung 取代了 Linux AIO

Linux 的 I/O 演进历程中,POSIX AIO (libaio) 一直是异步 I/O 的官方解决方案。然而,libaio 自诞生起就饱受诟病:仅支持 O_DIRECT 文件 I/O,不支持网络 I/O,且每次操作都需要复杂的 iocb 结构。2019 年 Linux 5.1 引入的 io_uring 彻底改变了这一局面,它提供了一个统一的、高性能的异步 I/O 接口,不仅解决了 libaio 的诸多缺陷,还将 Linux 异步 I/O 性能推向了新高度。

io_uring 不仅是一个新的系统调用接口,更是一种全新的内核-用户空间协作模式。它通过共享内存环形队列(Submission Queue / Completion Queue)消除了系统调用的开销,支持零拷贝操作,并且可以用于文件 I/O、网络 I/O、事件通知等几乎所有场景。本文将深入剖析 io_uring 的核心机制,并展示如何用它构建生产级的高性能应用。

一、架构总览:io_uring 的三环模型

io_uring 的核心设计是三个环形缓冲区(ring buffer),它们位于内核与用户空间共享的内存区域中:

┌──────────────────────────────────────────────────┐
│                 用户空间 (Userspace)              │
│                                                   │
│  ┌─────────────┐    ┌─────────────────────┐      │
│  │ Submission  │    │   Completion Queue   │      │
│  │   Queue     │    │        (CQ)          │      │
│  │   (SQ)      │    │                      │      │
│  │             │    │  ┌──┐┌──┐┌──┐┌──┐   │      │
│  │  ┌──┐┌──┐  │    │  │CQE││CQE││CQE││..│  │      │
│  │  │SQE││SQE│  │    │  └──┘└──┘└──┘└──┘   │      │
│  │  └──┘└──┘  │    └─────────────────────┘      │
│  └─────────────┘                                  │
│        ↓ io_uring_enter                           │
│  ───────────────────────────────────────          │
│                  内核空间                          │
│  ┌──────────────────────────────────┐             │
│  │     io_uring 内部处理引擎        │             │
│  │  ┌──────────┐  ┌──────────────┐  │             │
│  │  │IOPOLL模式│  │任务执行工作池 │  │             │
│  │  └──────────┘  └──────────────┘  │             │
│  └──────────────────────────────────┘             │
└──────────────────────────────────────────────────┘

SQ (Submission Queue):  用户提交 SQE → 内核消费 SQE
CQ (Completion Queue):  内核写入 CQE → 用户消费 CQE
SQEs: Submission Queue Entries  — 用户准备好的操作描述
CQEs: Completion Queue Entries  — 内核返回的操作结果

关键设计要点:

  • SQ 是单向生产者:只有用户空间向 SQ 写入 SQE(单一生产者),内核从中读取(单一消费者)
  • CQ 也是单向:只有内核向 CQ 写入 CQE(单一生产者),用户从中读取(单一消费者)
  • 无需系统调用即可提交:填充 SQE 后无需调用任何 syscall,只需在批量提交时调用 io_uring_enter
  • 内存屏障保证可见性:通过适当的写读屏障(write/read barrier)保证 SQ/CQ 的顺序一致性

二、初始化与队列配对 (Queue Pair)

每个 io_uring 实例由一个 io_uring_params 结构体配置,它决定了 SQ 和 CQ 的大小、特性和行为模式:

#include <liburing.h>

struct io_uring_params params = {0};

// 配置参数:启用高级特性
params.flags = IORING_SETUP_SQPOLL;  // 内核线程轮询 SQ(无系统调用模式)
params.sq_thread_idle = 2000;         // 内核轮询线程空闲超时 (ms)

struct io_uring ring;
int ret = io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);

// 检查特性支持
if (params.features & IORING_FEAT_SINGLE_MMAP) {
    printf("支持单 mmap 模式\n");
}
if (params.features & IORING_FEAT_NODROP) {
    printf("CQ 不满时不会丢弃完成事件\n");
}

io_uring_params 重要字段解析:

字段含义典型值
sq_entriesSQ 条目数256 - 4096
cq_entriesCQ 条目数(通常为 SQ 的 2-4 倍)512 - 16384
flagsio_uring 行为标志位IORING_SETUP_SQPOLL / IORING_SETUP_IOPOLL
sq_thread_cpuSQPOLL 线程绑定的 CPU 核0-NR_CPUS
features内核支持的特性位(只读输出)运行时检测

SQPOLL 模式 vs 传统模式

SQPOLL (Submission Queue Poll) 是 io_uring 最强大的特性之一。它创建一个内核线程(io-wq-worker)专门轮询 SQ,用户填写 SQE 后完全不需要调用 io_uring_enter 就能让内核执行操作。

// 传统模式:用户显式提交
// prepare SQE → io_uring_submit() → 等待 CQE

// SQPOLL 模式:内核自动轮询
// prepare SQE → 等待 CQE(零系统调用!)

// SQPOLL 的取消与休眠
// 当 SQ 中没有待处理任务时,内核线程会休眠 sq_thread_idle 毫秒后停止
// 新任务到来时会唤醒线程

SQPOLL 的代价与权衡:

  • ✅ 零系统调用开销(仅首次唤醒时有 syscall)
  • ✅ 更低延迟(内核线程预消费)
  • ⚠️ 占用一个 CPU 核心(轮询时持续消耗 CPU)
  • ⚠️ 需要 root 或 CAP_SYS_NICE 权限
  • ⚠️ 不适合偶尔提交任务的应用

三、Submission Queue Entry (SQE) 详解

SQE 是 io_uring 的基本操作单元,64 字节对齐的结构体包含所有操作所需的信息:

struct io_uring_sqe {
    __u8   opcode;      // 操作码:IORING_OP_READV, IORING_OP_WRITEV 等
    __u8   flags;       // IOSQE_* 标志
    __u16  ioprio;      // I/O 优先级(类似 ioprio_set)
    __s32  fd;          // 目标文件描述符(负数表示固定文件索引)
    union {             // 操作数据偏移或用户指针
        __u64 off;
        ...
    };
    union {
        __u64 addr;     // 缓冲区地址(readv/writev)
        ...
    };
    __u32  len;         // 字节长度
    union {
        __kernel_rwf_t rw_flags;  // RWF_* 标志
        ...
    };
    __u64  user_data;   // 用户标识,回传到 CQE
    __u16  buf_index;   // 注册缓冲区组索引(fixed buffer)
    __u16  personality; // 注册 personality 索引
    union {
        __s32  splice_fd_in;
        __u16  file_index;  // 固定文件索引
    };
    __u64  __pad2[2];   // 保留
} __attribute__((packed, aligned(8)));

核心操作码一览:

  • IORING_OP_READ / IORING_OP_WRITE — 基本读写
  • IORING_OP_READV / IORING_OP_WRITEV — 散布/聚集读写 (readv/writev)
  • IORING_OP_READ_FIXED / IORING_OP_WRITE_FIXED — 使用注册缓冲区的读写
  • IORING_OP_FSYNC — 文件同步
  • IORING_OP_SENDMSG / IORING_OP_RECVMSG — 网络消息收发
  • IORING_OP_ACCEPT / IORING_OP_CONNECT — TCP 连接操作
  • IORING_OP_EPOLL_CTL — epoll 控制操作
  • IORING_OP_FALLOCATE / IORING_OP_FADVISE — 文件预分配和建议
  • IORING_OP_OPENAT / IORING_OP_CLOSE — 文件打开/关闭
  • IORING_OP_PROVIDE_BUFFERS — 提供预注册缓冲区缓冲池

四、Completion Queue Entry (CQE) 与事件处理

当 SQE 执行完成后,内核会在 CQ 中写入 CQE。用户通过检查 CQE 获取操作结果:

struct io_uring_cqe {
    __u64  user_data;   // 与 SQE 中的 user_data 一致(用于匹配请求/响应)
    __s32  res;         // 操作结果(类似 syscall 返回值,负数表示错误)
    __u32  flags;       // CQE 标志(如 IORING_CQE_F_BUFFER, CQE_F_MORE)
};

// 批量等待和收割完成事件
struct io_uring_cqe *cqe;
unsigned head;
int completed = 0;

io_uring_for_each_cqe(&ring, head, cqe) {
    // cqe->user_data 帮助识别哪个请求已完成
    // cqe->res >= 0 表示成功(返回实际读写字节数)
    // cqe->res < 0 表示错误(如 -EAGAIN, -EINVAL)
    
    handle_completion(cqe->user_data, cqe->res);
    completed++;
}

// 批量提交完成确认(一次性标记已消费)
io_uring_cq_advance(&ring, completed);

// 阻塞等待(最少等待 min_wait 个事件)
int ret = io_uring_wait_cqe(&ring, &cqe);

CQE flags 标志位解读:

  • IORING_CQE_F_BUFFER — 此 CQE 使用了 provide buffer 机制(buf 包含实际缓冲区索引)
  • IORING_CQE_F_MORE — 后续还有更多相关 CQE(链式通知)
  • IORING_CQE_F_NOTIF — 这是 send_zc 的完成通知,数据已真正写入对端
  • IORING_CQE_F_BUF_MORE — 缓冲区中还有更多数据(用于流式接收)

五、高级优化技术

5.1 注册缓冲区 (Registered Buffers / Fixed Buffers)

每次 io_uring 操作都需要在内核中 pin 住用户内存页,这是固定开销。通过预注册缓冲区,可以消除每次操作的开销:

// 1. 准备缓冲区
char buffer[BUFFERS_COUNT][BUFFER_SIZE];
int ret = io_uring_register_buffers(&ring, iovecs, BUFFERS_COUNT);

// 2. 在 SQE 中使用固定缓冲区
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, buffer[idx], len, offset, buf_idx);
sqe->flags |= IOSQE_FIXED_FILE | IOSQE_BUFFER_SELECT;

// 结果:零内存 pin/unpin 开销

性能影响:注册缓冲区可以将单次操作开销降低约 1-2μs,在高频率小 I/O 场景下提升显著。

5.2 固定文件 (Registered Files)

类似注册缓冲区,预注册文件描述符可以避免每次操作的 fd 查找:

// 预注册常用文件
int fds[] = {file_fd_1, file_fd_2, eventfd, epollfd};
io_uring_register_files(&ring, fds, 4);

// 在 SQE 中使用文件索引(索引 = fds 数组下标)
sqe->fd = 0;  // 直接用索引,不是真正的 fd
sqe->flags |= IOSQE_FIXED_FILE;

// 优点:消除 fdtable 查找、引用计数管理

5.3 Linked SQE(链接操作链)

通过 IOSQE_IO_LINK 标志可以将多个 SQE 串联为原子操作链:

// 链接操作:先 read → 然后 write(前一个完成后才执行下一个)
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe1, fd_src, &iov_src, 1, src_offset);
sqe1->user_data = OP_READ;

struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_writev(sqe2, fd_dst, &iov_dst, 1, dst_offset);
sqe2->user_data = OP_WRITE;

// IOSQE_IO_LINK 链接两个操作
sqe1->flags |= IOSQE_IO_LINK;
// 如果 read 失败,write 会自动失败为 -ECANCELED
sqe2->flags |= IOSQE_IO_HARDLINK;  // 即使前一个失败也执行

io_uring_submit(&ring);

5.4 SQE 批处理与 Drain

通过 IOSQE_IO_DRAIN 标志可以将之前提交的所有操作与后续操作隔离:

// 场景:必须等所有Pending I/O 完成后才能执行后续操作
sqe_drain->opcode = IORING_OP_NOP;  // NOP 作为 drain barrier
sqe_drain->flags |= IOSQE_IO_DRAIN;

// 提交 drain 后,drain 操作及其后的操作必须等待之前所有操作完成

六、io_uring 与 epoll 的关系

很多人问:「io_uring 会取代 epoll 吗?」答案是:io_uring 不是替代品,而是互补层。

// io_uring 内部使用 eventfd + epoll 来管理事件通知
// 常见集成模式:

// 模式1: io_uring 提交 + epoll_wait 等待
int ret = io_uring_submit_and_wait(&ring, 1);
// 类似 submit + wait_cqe,但底层可以用 eventfd + epoll 通知

// 模式2: io_uring 替代 accept + read/write 循环
// 传统 epoll server: epoll_wait → accept → recv/send
// io_uring server: 批量提交 ACCEPT → RECV → SEND 链接操作链

io_uring 在网络 I/O 的优势:

  • 批量操作:不是一次 accept 一个连接,而是先提交 N 个 ACCEPT SQE
  • 零拷贝网络:IORING_OP_SEND_ZC 支持真正零拷贝发送
  • 超时控制:IORING_OP_TIMEOUT SQE 可以为等待设置精确超时
  • 连接池管理:注册 eventfd、注册 fd,全异步生命周期

七、高性能 Web Server 实战案例

一个基于 io_uring 的最小化 HTTP 服务器骨架,展示如何构建生产级服务:

#include <liburing.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define BACKLOG 4096
#define BUF_SIZE 4096
#define MAX_CONNECTIONS 16384

struct conn_info {
    int fd;
    unsigned type;       // CONN_TYPE_ACCEPT / CONN_TYPE_READ / CONN_TYPE_WRITE
    unsigned buflen;
    char buf[BUF_SIZE];
};

enum { CONN_TYPE_ACCEPT, CONN_TYPE_READ, CONN_TYPE_WRITE };

struct io_uring ring;

void server_loop(int listen_fd) {
    io_uring_queue_init(16384, &ring, IORING_SETUP_SQPOLL | IORING_SETUP_COOP_TASKRUN);
    
    // 1. 先提交一个 ACCEPT 操作
    submit_accept(listen_fd);
    
    while (1) {
        // 2. 等待完成事件
        struct io_uring_cqe *cqe;
        io_uring_submit_and_wait(&ring, 1);
        io_uring_peek_cqe(&ring, &cqe);
        
        // 3. 分发完成事件
        struct conn_info *info = (void*)(uintptr_t)cqe->user_data;
        
        switch (info->type) {
        case CONN_TYPE_ACCEPT:
            // 新连接到达
            submit_accept(listen_fd);  // 重新提交 ACCEPT
            submit_read(new_conn_fd);   // 读取客户端请求
            break;
        case CONN_TYPE_READ:
            // HTTP 请求读取完成
            if (cqe->res > 0) {
                // 解析请求并生成响应
                submit_write(info->fd, response_buf, response_len);
            } else {
                close_connection(info->fd);
            }
            break;
        case CONN_TYPE_WRITE:
            // 响应发送完成
            submit_read(info->fd);  // 读取下一个请求(keep-alive)
            break;
        }
        
        io_uring_cqe_seen(&ring, cqe);
    }
}

void submit_accept(int listen_fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    struct conn_info *info = malloc(sizeof(*info));
    
    info->fd = 0;
    info->type = CONN_TYPE_ACCEPT;
    
    // 预分配新连接的 conn_info
    struct conn_info *new_info = malloc(sizeof(*new_info));
    new_info->fd = 0;
    new_info->type = CONN_TYPE_READ;
    
    io_uring_prep_accept(sqe, listen_fd, &new_info->peer_addr, 
                         &new_info->peer_len, 0);
    sqe->user_data = (uint64_t)info;  // 完成事件关联到 accept info
}

void submit_read(int fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    struct conn_info *info = get_conn_info(fd);
    
    info->type = CONN_TYPE_READ;
    io_uring_prep_recv(sqe, fd, info->buf, BUF_SIZE, 0);
    sqe->user_data = (uint64_t)info;
}

void submit_write(int fd, const void *buf, unsigned len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    struct conn_info *info = get_conn_info(fd);
    
    info->type = CONN_TYPE_WRITE;
    memcpy(info->buf, buf, len);
    info->buflen = len;
    io_uring_prep_send(sqe, fd, buf, len, 0);
    sqe->user_data = (uint64_t)info;
}

八、Provide Buffers 机制(类似 Linux 的 mmap 网络接收)

Provide Buffers 是 io_uring 的高级特性,允许用户预分配缓冲区池,内核在收到数据时自动从池中取出一个缓冲区使用,避免传统 recv() 中数据被截断或需要多次调用的问题:

// 1. 注册 Provide Buffers 池
struct iovec iovecs[BUFFERS_COUNT];
for (int i = 0; i < BUFFERS_COUNT; i++) {
    iovecs[i].iov_base = aligned_alloc(4096, BUFFER_SIZE);
    iovecs[i].iov_len = BUFFER_SIZE;
}

struct io_uring_provide_buf pb = {
    .addr = (uint64_t)iovecs[idx].iov_base,
    .len  = BUFFER_SIZE,
    .bid  = idx,       // buffer ID
};
io_uring_prep_provide_buffers(sqe, pb.addr, pb.len, 1, bgid, idx);

// 2. 在网络接收 SQE 中启用缓冲区选择
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = bgid;  // 指定缓冲区组(buffer group ID)

// 3. 完成后从 CQE 获取实际使用的缓冲区
if (cqe->flags & IORING_CQE_F_BUFFER) {
    int buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
    // iovecs[buf_id] 中就是接收到的数据
    // cqe->res 表示实际接收的字节数
}

优势:

  • 避免两次 recv 调用(先 peek 获取大小,再读数据)
  • 支持自动选择空闲缓冲区
  • 适合高并发 Web 代理和反向代理场景

九、Send-ZC:真正的零拷贝网络发送

Linux 6.1+ 引入的 send_zc 是 io_uring 中一个里程碑式特性,它允许用户数据在不经过内核缓冲区的情况下直接发送到网络,实现真正的零拷贝:

// send_zc 骨架代码
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_zc(sqe, fd, data_ptr, data_len, 0, IOSQE_ASYNC);
sqe->user_data = (uint64_t)op_ctx;

io_uring_submit(&ring);

// 关键:send_zc 产生两个 CQE
// 1. 快速通知:IO 已提交(写回 CQE res = data_len)
// 2. 真正完成:数据已离开内核缓冲区写入网卡

// 通过 CQE flags == IO_CQE_F_NOTIF 区分"释放缓冲区"的 CQE
// 收到 NOTIF CQE 之后才能重用用户缓冲区

send_zc 的缓冲区管理:

  • 提交 send_zc 后用户缓冲区仍然被占用
  • 收到 faster CQE(完成通知)后不能立即重用
  • 只有当 NOTIF CQE 到来(标志位 CQE_F_NOTIF)时,缓冲区才被释放
  • 这个设计确保了即使 kernel sendfile 的错误回退缓冲区复用场景下也安全

十、io_uring 性能基准测试

在典型的硬件环境下(NVMe SSD, 10GbE 网卡, Linux 6.2),io_uring 与各种 I/O 模型的性能对比:

模型随机读(IOPS)延迟(p99)CPU占用
同步 read()~150K~120μs高
libaio (O_DIRECT)~280K~45μs中
io_uring (basic)~350K~30μs中
io_uring (fixed buf + SQPOLL)~420K~8μs低
io_uring (iopOLL polled)~550K~3μs最低

关键发现:

  • io_uring + SQPOLL 在 IOPS 上比 libaio 提升 ~50%
  • 固定缓冲区可额外降低约 30% 的延迟方差
  • IOPOLL 模式(轮询完成而非中断)可将 p99 延迟降至个位数μs
  • io_uring 网络场景下(sendmsg/recvmsg),连接密集型 workload 可减少 40% 系统调用次数

十一、工程最佳实践

11.1 错误处理与资源管理

io_uring 编程容易出现资源泄漏问题(SQE 未释放、CQE 未 seen 等):

// ❌ 最常见的错误:忘记 io_uring_cqe_seen
while (1) {
    io_uring_wait_cqe(&ring, &cqe);
    process(cqe);          // 处理完成事件
    // 忘记 io_uring_cqe_seen → CQ 阻塞 → 后续 CQE 不会被写入
}

// ✅ 正确做法:批量消费 CQE 后 cq_advance
unsigned head;
io_uring_for_each_cqe(&ring, head, cqe) {
    process(cqe);
}
io_uring_cq_advance(&ring, completed_count);
// cq_advance 安全地通知内核 CQ slot 已释放

11.2 使用 eventfd 与外部事件循环集成

当 io_uring 与 select/poll/select 等共存时,需要 eventfd 桥接:

// 注册 eventfd 用于 io_uring 通知
int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
io_uring_register_eventfd(&ring, efd);

// 在其他 select/poll/epoll 循环中监听 efd
struct epoll_event ev = { .events = EPOLLIN, .data.fd = efd };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, efd, &ev);

// 当 io_uring 有完成事件时,efd 变为可读
// 读取 efd 后就绪后调用 io_uring_peek_cqe 检查实际事件

11.3 内存屏障注意事项

io_uring 依赖弱序内存模型,不正确的数据可见性会导致微妙 bug:

// Shop: 必须写入 SQE 数据 → 写屏障 → 更新 SQ tail
sqe->opcode = IORING_OP_READ;
sqe->fd = fd;
sqe->off = offset;
sqe->addr = (uint64_t)buf;
sqe->len = len;
sqe->user_data = id;

// 写 barrier 确保数据可见
smp_wbarrier();
*sqring->tail = sqring->tail_shadow + 1;

// 然后 io_uring_enter 或 wait 触发内核处理

11.4 任务分发架构

在生产环境,推荐使用「io_uring + worker pool」模式替代纯轮询服务器:

// 架构: 主线程(io_uring) → 工作线程(epoll) → 工作队列
//
// 主线程:
//   - 负责 io_uring 初始化、网络 accept、批量收发
//   - 将收到的请求推入无锁 mpsc 队列
//
// 工作线程:
//   - 从 mpsc 队列取任务
//   - 执行业务逻辑(DB 查询、模板渲染、文件读写)
//   - 将响应通过 io_uring 或 eventfd 通知主线程发送
//
// 分离关注点:IO 密集型操作走 io_uring,计算密集型操作走工作线程池

十二、总结与展望

io_uring 是 Linux 内核在异步 I/O 领域二十年来最重要的设计革新,它解决了 libaio 长期存在的痛点,引入了共享内存环形队列的新范式。掌握 io_uring 不仅需要理解其 API,更要理解其核心设计理念——将内核与用户空间的协作模式从「请求-响应」转化为「生产-消费」。

展望未来:

  • io_uring 将继续扩展操作类型:文件监视 (inotify/fanotify)、设备控制 等都已在开发中
  • 与 eBPF 深度集成:io_uring 操作可以在 BPF 程序中发起,实现可编程 I/O 流水线
  • 跨机器 io_uring:分布式存储系统中 io_uring 操作跨节点的协议正在探索中
  • 语言绑定生态成熟:tokio-uring (Rust)、python-uring 等高级语言封装日益完善

对于任何需要极致 I/O 性能的 Linux 应用——无论是 Web 服务器、数据库、消息队列还是存储系统——掌握 io_uring 已经从「锦上添花」变成了「必备技能」。理解它,用它,你应用的 IOPS 上限将重新定义。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部