Linux io_uring 深度解析:从原理到实践
为什么需要 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_read、io_uring_prep_write、io_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(¶ms, 0, sizeof(params)); // ★ 必须清零!
params.flags = IORING_SETUP_SQPOLL; // 启用内核轮询线程
params.sq_thread_idle = 2000; // 空闲 2 秒后休眠
io_uring_queue_init_params(1024, &ring, ¶ms);
第三个参数变成 ¶ms,flags 不再是 0。io_uring_params 提供了对高级特性的精细控制:
| 字段 | 作用 |
|---|---|
params.flags |
启用高级模式:IORING_SETUP_SQPOLL、IORING_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/O(O_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 可以读/写了?” 内核给你一个名单。然后你自己动手去读/写。每个 recv 和 send 都是同步系统调用。
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 次(如果有批量请求,摊还后可能更少)
性能提升的根本原因不是魔法,而是两个设计决策:
- 共享内存:SQ/CQ 环通过 mmap 映射,提交和收割大部分时候不涉及系统调用
- 批量提交:多个 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, ¶ms);
// 正确
struct io_uring_params params;
memset(¶ms, 0, sizeof(params)); // ★ 永远先清零
params.flags = IORING_SETUP_SQPOLL;
io_uring_queue_init_params(1024, &ring, ¶ms);
内核使用 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 的核心设计可以用三句话概括:
- 两个环,一张单:SQ 环提交任务,CQ 环返回结果。所有 I/O 操作共用同一个 SQE 结构,
io_uring_prep_*只是往上面填不同字段。 - 共享内存是核心:提交和收割大部分时候不经过系统调用,这是性能优势的根源。
- 一统异步江湖:文件、socket、定时器——用同一套 API 处理所有 I/O 类型。没有 AIO 的限制,没有 epoll 的同步读写开销。
对于网络服务开发者,io_uring 是最接近"完美异步 I/O"的 Linux 接口。但它不是银弹——学习曲线、调试复杂性、内核版本要求都是真实成本。在合适的地方使用它,在不需要的地方拥抱简单的 epoll。正确的工具用在对的地方,就是最好的工程决策。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)