为什么需要 io_uring

在 io_uring 出现之前,Linux 异步 I/O 领域是一片碎片化的荒野:

  • AIO:只支持 Direct I/O,不支持 buffered I/O、socket,接口丑陋,bug 缠身
  • epoll:只能告诉你"可以读了",不能帮你读——读写还是同步的
  • libaio:需要 O_DIRECT 对齐,使用场景极其受限

io_uring 在 Linux 5.1 版本横空出世,用一个统一、高效、易用的接口终结了这场混乱。它同时支持普通文件、socket、管道、定时器,并且通过共享内存环形队列将系统调用开销降到最低。


一、核心模型:两个环,一辈子

1.1 架构全景

io_uring 由两个内存映射的环形缓冲区驱动:

        用户态                             内核态
   ┌──────────────┐                 ┌──────────────┐
   │   SQ 环       │                 │   SQ 环       │
   │  (Submission  │  ─── 写入 ──→  │  (内核读取     │
   │   Queue)      │                 │   用户提交)    │
   │              │                 │              │
   │  SQE | SQE  │                 │  SQE | SQE  │
   │  SQE | SQE  │                 │  SQE | SQE  │
   └──────────────┘                 └──────────────┘
   
   ┌──────────────┐                 ┌──────────────┐
   │   CQ 环       │                 │   CQ 环       │
   │  (Completion  │  ←── 读取 ──   │  (内核写入     │
   │   Queue)      │                 │   完成结果)    │
   │              │                 │              │
   │  CQE | CQE  │                 │  CQE | CQE  │
   │  CQE | CQE  │                 │  CQE | CQE  │
   └──────────────┘                 └──────────────┘

   两个环都通过 mmap 映射,用户态和内核态共享同一块物理内存。

在这里插入图片描述

核心真相只有一句话:SQ 环和 CQ 环是内核与用户态之间的一对共享内存环形缓冲区,用内存写入代替系统调用

1.2 SQE 和 CQE:通用的任务单与回执单

这是最容易被误解的地方。许多人以为 io_uring_prep_readio_uring_prep_writeio_uring_prep_accept 等函数是在"创建不同类型的结构体"。事实恰好相反:

struct io_uring_sqe {
    __u8    opcode;      // 操作类型:IORING_OP_READ / WRITE / ACCEPT / TIMEOUT ...
    __u8    flags;       // 标志位
    __u16   ioprio;      // IO 优先级
    __s32   fd;          // 文件描述符
    __u64   off;         // 文件偏移(read/write 用)
    __u64   addr;        // 缓冲区地址(read/write)或 socket 地址指针(accept 用)
    __u32   len;         // 缓冲区长度
    __u32   accept_flags;// accept 专用
    __u64   user_data;   // ★ 用户自定义数据,完成时原样返回
    // ... 其他 union 字段
};

struct io_uring_cqe {
    __u64   user_data;   // ★ 来自 SQE,原样返回
    __s32   res;         // 操作结果(成功=字节数,失败=负 errno)
    __u32   flags;       // 标志位
};

SQE 是内核定义好的通用容器,64 字节,固定结构。读文件、写文件、接收网络数据、接受连接、设置定时器——所有这些操作共用同一种 SQE,只是 opcode 字段不同,其余字段的语义随之变化。

io_uring_prep_read(sqe, fd, buf, nr, offset) 内部做的事情非常简单:

// 伪代码,展示核心逻辑
void io_uring_prep_read(struct io_uring_sqe *sqe, int fd, void *buf,
                         unsigned nr, __u64 offset) {
    sqe->opcode = IORING_OP_READ;
    sqe->fd     = fd;
    sqe->off    = offset;
    sqe->addr   = (__u64)buf;
    sqe->len    = nr;
}

它不是"创建一个 read 请求对象",而是往同一张通用任务单上填写"我要读"的相关字段

CQE 同理——所有 I/O 操作的完成都返回同一种 CQE。"读操作完成,读了 42 字节"和"accept 完成,新 fd 是 15"使用同一个结构,区别仅在于 res 字段的语义(读字节数 vs 新 fd)。

1.3 user_data:你的身份证

sqe->user_data = (__u64)(uintptr_t)&my_context;

user_data 是一个 8 字节的不透明字段。你填进去什么,CQE 返回时长什么样子,内核不关心、不修改。它是 io_uring 和你的应用之间的身份传递通道

典型用法:

  • 填一个结构体指针(64 位系统刚好 8 字节,完美契合)
  • 填一个组合值:低 32 位放 fd,高 32 位放操作类型
  • 填一个索引(连接池下标)

这是你理解 “user_data 是不是上传结构体” 的答案:不是。它只是一个 8 字节的口袋,你塞什么进去,CQE 还你什么


二、通用使用流程:6 步万能模板

无论是读文件、收发网络数据、还是设定时器,io_uring 的使用流程完全一致:

┌────────────────────────────────────────────────┐
│  ① 初始化 io_uring 实例                        │
│     io_uring_queue_init(QD, &ring, 0)          │
│                                                │
│  ② 获取一个空闲 SQE                            │
│     sqe = io_uring_get_sqe(&ring)              │
│     if (!sqe) → SQ 满,先 submit 再重试        │
│                                                │
│  ③ 填充 SQE                                   │
│     io_uring_prep_xxx(sqe, ...)                │
│     sqe->user_data = 你的身份标记               │
│                                                │
│  ④ (可选) 继续获取更多 SQE,形成批量提交        │
│     goto ②                                    │
│                                                │
│  ⑤ 提交所有 SQE 到内核,并等待完成             │
│     io_uring_submit_and_wait(&ring, wait_nr)   │
│                                                │
│  ⑥ 收割 CQE,处理完成结果                     │
│     io_uring_peek_batch_cqe(&ring, cqes, N)   │
│     遍历每个 CQE,根据 user_data 和 res 处理   │
│     io_uring_cq_advance(&ring, nready)         │
│     goto ② 继续提交新的任务                    │
│                                                │
│  ⑦ 程序结束时清理                              │
│     io_uring_queue_exit(&ring)                 │
└────────────────────────────────────────────────┘

最简示例:异步读文件

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

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

    // 准备缓冲区
    char buf[4096];
    int fd = open("/etc/hostname", O_RDONLY);
    
    // ② 获取 SQE
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    
    // ③ 填充:异步读取文件前 4096 字节
    io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
    sqe->user_data = 42;  // 身份标记,随便填
    
    // ⑤ 提交并等待至少 1 个完成
    io_uring_submit_and_wait(&ring, 1);
    
    // ⑥ 收割 CQE
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);  // 单次收割的简便方法
    
    if (cqe->res > 0) {
        printf("读取了 %d 字节: %.*s\n", cqe->res, cqe->res, buf);
    }
    printf("user_data 回来了: %llu\n", cqe->user_data);  // 42
    
    io_uring_cqe_seen(&ring, cqe);  // 标记 CQE 已处理
    close(fd);
    
    // ⑦ 清理
    io_uring_queue_exit(&ring);
    return 0;
}

编译:

gcc -o async_read async_read.c -luring

三、两种初始化方式的选择逻辑

方式一:默认初始化(推荐 90% 的场景)

struct io_uring ring;
io_uring_queue_init(1024, &ring, 0);

第三个参数 flags 传 0,代表什么高级特性都不启用。这是最稳定、最通用的模式,没有任何坑。适用于:

  • 文件 I/O
  • 网络 I/O(TCP/UDP)
  • 定时器
  • 所有混合场景
  • 开发阶段、学习阶段

方式二:定制初始化(追求极致性能)

struct io_uring ring;
struct io_uring_params params;
memset(&params, 0, sizeof(params));   // ★ 必须清零!
params.flags = IORING_SETUP_SQPOLL;    // 启用内核轮询线程
params.sq_thread_idle = 2000;          // 空闲 2 秒后休眠
io_uring_queue_init_params(1024, &ring, &params);

第三个参数变成 &params,flags 不再是 0。io_uring_params 提供了对高级特性的精细控制:

字段 作用
params.flags 启用高级模式:IORING_SETUP_SQPOLLIORING_SETUP_IOPOLL
params.sq_thread_cpu 将 SQ 轮询线程绑定到指定 CPU 核心
params.sq_thread_idle SQ 轮询线程空闲多久后休眠(毫秒)
params.cq_entries 自定义 CQ 环大小(必须大于等于 SQ 环大小)
params.features 初始化后内核回填,告诉你支持哪些特性

为什么不总是用 init_params 因为最简单的方式就是最好的方式。init_params 只是为了给你一个入口来启用高级特性。如果你不需要 SQPOLL/IOPOLL 这些高级特性,传 0 给 init 就够了。init 内部其实也是调用 init_params

// io_uring_queue_init 的简化实现
int io_uring_queue_init(unsigned entries, struct io_uring *ring, unsigned flags) {
    struct io_uring_params p;
    memset(&p, 0, sizeof(p));
    p.flags = flags;
    return io_uring_queue_init_params(entries, ring, &p);
}

四、两种高级轮询模式的本质差异

这是最容易被搞混的两个概念,但它们轮询的对象完全不同。

4.1 SQPOLL:内核轮询软件提交队列

传统模式(无 SQPOLL):
  用户态                   内核态
    │                        │
    │── 写入 SQE 到 SQ 环 ──→│  (只是写共享内存,不是系统调用)
    │                        │
    │── io_uring_enter() ──→│  ★ 系统调用:告诉内核"有新任务了"
    │                        │  内核开始处理 SQE
    │←─ io_uring_enter 返回 ─│
    │                        │  内核执行 I/O...
    │                        │  内核将 CQE 写入 CQ 环
    │←── 读取 CQ 环 ────────│  (只是读共享内存)
    │                        │

启用 SQPOLL 后:
  用户态                   内核态
    │                        │
    │── 写入 SQE 到 SQ 环 ──→│  ★ 没有系统调用!
    │                        │  内核轮询线程不断检查 SQ 环
    │                        │  ┌──────────────┐
    │                        │  │ SQ 轮询线程   │
    │                        │  │ while(1) {    │
    │                        │  │   if (有新SQE) │
    │                        │  │    处理它      │
    │                        │  │ }             │
    │                        │  └──────────────┘
    │                        │  内核执行 I/O...
    │                        │  内核将 CQE 写入 CQ 环
    │←── 读取 CQ 环 ────────│  (只是读共享内存)

SQPOLL 的代价:内核中一个 CPU 核心 100% 忙轮询(可配置空闲休眠),耗电但不耗用户态系统调用。

适用场景:高并发网络服务(每秒数千到数万次 I/O 提交),省掉每次提交的系统调用开销。

注意:需要在 /etc/security/limits.conf 中配置 memlock 权限。

4.2 IOPOLL:内核轮询硬件设备

传统模式(无 IOPOLL):
  内核提交 I/O 给硬件设备
    │
    ▼
  硬件设备处理...
    │
    ▼
  硬件设备发出中断 ──→ CPU 响应中断 ──→ 内核得知 I/O 完成 ──→ 写 CQE
  (中断延迟:数微秒到数十微秒)

启用 IOPOLL 后:
  内核提交 I/O 给硬件设备
    │
    ▼
  内核不停轮询硬件设备的完成队列  ←──── 没有中断,消除中断延迟
    │
    ▼
  发现完成 ──→ 立即写 CQE
  (延迟:亚微秒级)

IOPOLL 的代价:内核 CPU 核心 100% 忙轮询硬件,只支持 Direct I/OO_DIRECT),不支持 buffered I/O。

适用场景:高速 NVMe SSD、100Gbps 网卡等低延迟硬件。普通 SATA SSD 或 HDD 用 IOPOLL 是浪费。

4.3 区别一览

维度 SQPOLL IOPOLL
轮询对象 SQ 环(软件) 硬件设备的完成队列
消除什么 io_uring_enter 系统调用 硬件中断延迟
内核开销 一个内核线程忙轮询 内核 CPU 忙轮询硬件
存储要求 必须是 O_DIRECT 打开的文件
适用设备 任何(文件/socket/定时器) 高速 NVMe SSD、高端网卡
组合使用 可以!SQPOLL | IOPOLL 两者轮询对象不同,不冲突

组合使用的典型场景:一个专用的高性能存储服务器,绑一个 CPU 给内核做 SQPOLL | IOPOLL,用户态线程完全不需要任何系统调用就完成全部 I/O。


五、io_uring vs epoll:从 Reactor 到 Proactor 的跨越

这不是性能高低的简单对比,而是两种根本不同的 I/O 编程模型

5.1 Reactor(epoll)

epoll_wait(fds) ──→ 返回可读的 fd 列表
   │
   └── for each ready fd:
         recv(fd, buf, len, 0)   ← 同步调用,你在做 I/O
         process(buf)
         send(fd, resp, len, 0)   ← 同步调用,你在做 I/O

你问内核:“哪些 fd 可以读/写了?” 内核给你一个名单。然后你自己动手去读/写。每个 recvsend 都是同步系统调用。

5.2 Proactor(io_uring)

io_uring_prep_recv(sqe, fd, buf, len)  ← 提交"我要读"的任务
io_uring_prep_send(sqe, fd, buf, len)  ← 提交"我要写"的任务
io_uring_submit(&ring)                  ← 一次性告诉内核所有任务

... 内核异步执行所有 I/O,完成后写 CQE ...

for each CQE:
    if CQE is recv completion:
        process(buf)
        submit new send task
    if CQE is send completion:
        submit new recv task

你告诉内核"请帮我读这个 fd"。内核做完后告诉你"读完了,这是结果"。你不需要亲自做 I/O

5.3 根本区别

epoll(Reactor):
  同步感知:epoll 通知你"就绪了" → 你调用 recv/send 同步执行 I/O
  数据移动:用户态 buffer ← 内核通过 recv/send 系统调用拷贝
  状态驱动:内核事件通知驱动你的状态机变化

io_uring(Proactor):
  异步操作:你提交需要的 I/O 操作 → 内核完成后通知你"做完了"
  数据移动:内核直接将数据写入你提供的 buffer(异步 DMA/内存拷贝)
  意图驱动:你主动声明下一步要做什么

5.4 系统调用开销对比

以一次接收 100 字节并回复 10 字节的请求为例:

epoll 路径(最少 3 个系统调用):
  epoll_wait(epfd, events, max, timeout)  → 1 次系统调用
  recv(fd, buf, 100, 0)                   → 1 次系统调用
  send(fd, resp, 10, 0)                   → 1 次系统调用
  合计: 3 次(批量 epoll_wait + 紧循环 recv/send 可以合并部分)

io_uring 路径(1 个系统调用):
  io_uring_submit_and_wait(ring, 1)       → 1 次系统调用
   ↳ 提交了 recv SQE + submit
   ↳ 等待 recv 完成 (CQE)
   ↳ 用户态处理 + 提交 send SQE (在 SQ 环中,不需要系统调用)
   ↳ 等待 send 完成 (CQE)
  合计: 1 次(如果有批量请求,摊还后可能更少)

性能提升的根本原因不是魔法,而是两个设计决策:

  1. 共享内存:SQ/CQ 环通过 mmap 映射,提交和收割大部分时候不涉及系统调用
  2. 批量提交:多个 SQE 可以一次 submit 全部告知内核,一个 CQE 收割循环可以处理所有完成

epoll 仍然是好技术吗?

是的,绝对是的。 epoll 在以下场景仍然优秀:

  • 简单性:心智模型简单,bug 少
  • 兼容性:Linux 2.6+ 均可使用,io_uring 需要 5.1+
  • 调试友好:没有共享内存的复杂语义,strace 能清晰看到所有行为

io_uring 的性能优势在高吞吐场景下才显著。对于连接数适中(数百到一千)、请求量不大(每秒数千)的应用,epoll 完全足够。


六、其他重要操作速查

定时器

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
struct __kernel_timespec ts = { .tv_sec = 1, .tv_nsec = 0 };
io_uring_prep_timeout(sqe, &ts, 0, 0);
sqe->user_data = TIMEOUT_TAG;
io_uring_submit(&ring);

接受 TCP 连接

struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, server_fd, (struct sockaddr*)&client_addr,
                      &addr_len, SOCK_NONBLOCK);
sqe->user_data = ACCEPT_TAG;

Linked SQE(任务链)

让多个 SQE 形成依赖链:前一个操作完成后才执行下一个。

struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, fd, buf, 4096, 0);
sqe1->flags |= IOSQE_IO_LINK;  // ★ 链接到下一个 SQE
sqe1->user_data = 1;

struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe2, fd, buf, 4096, 4096);
sqe2->user_data = 2;
// sqe2 只会在 sqe1 成功完成后才执行

批量收割 CQE

struct io_uring_cqe *cqes[128];
int n = io_uring_peek_batch_cqe(&ring, cqes, 128);
for (int i = 0; i < n; i++) {
    // 处理 cqes[i]
}
io_uring_cq_advance(&ring, n);  // 批量确认

七、新手避坑指南

坑 1:params 未清零

// 错误
struct io_uring_params params;
params.flags = IORING_SETUP_SQPOLL;  // 其他字段是垃圾值!
io_uring_queue_init_params(1024, &ring, &params);

// 正确
struct io_uring_params params;
memset(&params, 0, sizeof(params));  // ★ 永远先清零
params.flags = IORING_SETUP_SQPOLL;
io_uring_queue_init_params(1024, &ring, &params);

内核使用 params 的未初始化字段作为输入参数(如 sq_thread_cpu),垃圾值可能导致不可预测的行为。

坑 2:user_data 塞了超过 8 字节的东西

// 危险
struct my_big_context ctx;
sqe->user_data = (__u64)&ctx;   // 64 位系统 OK
// 但是:
sqe->user_data = ctx.fd | (ctx.event << 32);  // OK,两个 int 刚好 8 字节

// 错误思维
struct conn_info { int fd; int event; void *ptr; };  // 16 字节
memcpy(&sqe->user_data, &info, sizeof(info));        // ★ 溢出!踩了 SQE 的其它字段

user_data__u64恰好 8 字节。打包进去的结构体必须不超过 8 字节。超过的部分会写入 SQE 的内存区域(SQE 共 64 字节,user_data 在特定偏移),导致任务参数被破坏。

正确做法:要么保证结构体 ≤ 8 字节,要么用它存索引或指针(64 位系统上指针是 8 字节)。

坑 3:队列深度过大或过小

// 过小
io_uring_queue_init(4, &ring, 0);   // 4 个 SQE,高并发下频繁堵塞

// 过大
io_uring_queue_init(32768, &ring, 0);  // 消耗大量锁定内存,可能失败

合理选择:

  • 文件 I/O 场景:64–256
  • 网络服务:512–2048
  • 混合场景:1024 是安全的默认值

坑 4:memlock 权限不足

# 症状
io_uring_queue_init: Cannot allocate memory

# 检查
ulimit -l

# 修复:在 /etc/security/limits.conf 中添加
username  hard  memlock  65536
username  soft  memlock  65536

io_uring 使用锁定内存(不能被 swap 出去),memlock 限制必须大于 entries × SQE 大小 + CQE 大小 + 寄存器大小。保守估计至少需要 entries × 1KB

坑 5:忘记 sqe->flags 的继承规则

// IOSQE_IO_LINK 的链接链中,如果中间某个 SQE 失败,
// 后续链接的 SQE 会被取消,但已提交的非链接 SQE 不受影响。
// 对 LINK + 错误处理的语义要有清晰理解。

坑 6:在 SQ 满时继续获取 SQE

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
    // SQ 已满!必须先 submit 一些 SQE,然后再重试
    io_uring_submit(&ring);
    sqe = io_uring_get_sqe(&ring);
}

io_uring_get_sqe 返回 NULL 表示 SQ 环中暂时没有空闲槽位。这通常意味着你提交太快(远快于内核处理速度)。需要先 submit 一批,等内核消费掉一些 SQE 槽位后,再获取新的。


八、选型速查

你要做什么?
│
├─ 读/写单个大文件(一次几 MB)
│   → 普通同步 I/O 即可,io_uring 优势不大
│
├─ 读/写大量小文件(日志、数据库)
│   → io_uring,默认模式,队列深度 256
│
├─ TCP 服务器,每秒数千请求
│   → io_uring 或 epoll 均可,epoll 更简单
│
├─ TCP 服务器,每秒数万请求
│   → io_uring + SQPOLL,队列深度 1024+
│
├─ 高速 NVMe 存储服务器(< 10μs 延迟要求)
│   → io_uring + SQPOLL + IOPOLL,绑核
│
├─ 需要同时支持文件 I/O + 网络 I/O
│   → io_uring(统一模型),epoll 不支持普通文件
│
├─ 内核版本 < 5.1(老 Linux / 容器)
│   → epoll,io_uring 不可用
│
├─ 需要可移植到非 Linux(macOS、BSD)
│   → epoll 也不行。用 libuv 或 boost.asio
│
├─ 快速原型、调试友好
│   → epoll,strace 行为清晰
│
└─ 学习异步 I/O、追求极限性能
    → io_uring,一次性解决所有异步 I/O 问题

总结

io_uring 的核心设计可以用三句话概括:

  1. 两个环,一张单:SQ 环提交任务,CQ 环返回结果。所有 I/O 操作共用同一个 SQE 结构,io_uring_prep_* 只是往上面填不同字段。
  2. 共享内存是核心:提交和收割大部分时候不经过系统调用,这是性能优势的根源。
  3. 一统异步江湖:文件、socket、定时器——用同一套 API 处理所有 I/O 类型。没有 AIO 的限制,没有 epoll 的同步读写开销。

对于网络服务开发者,io_uring 是最接近"完美异步 I/O"的 Linux 接口。但它不是银弹——学习曲线、调试复杂性、内核版本要求都是真实成本。在合适的地方使用它,在不需要的地方拥抱简单的 epoll。正确的工具用在对的地方,就是最好的工程决策。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐