一、为什么我们需要 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 利用率 |
|---|---|---|---|
| 同步 pread | 185K | 210μs | 95%(单核) |
| epoll + read | 310K | 85μs | 98%(单核) |
| libaio (O_DIRECT) | 380K | 62μs | 72%(单核) |
| io_uring (基础) | 420K | 48μs | 68%(单核) |
| io_uring + Fixed | 515K | 31μs | 55%(单核) |
| io_uring + SQPOLL | 580K | 18μs | 70%(含内核线程) |
| io_uring + IOPOLL | 630K | 8μs | 100%(专用核心) |
结论推导:
- 固定缓冲区贡献 +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 范式)转向「异步提交 + 异步完成」。核心优势可归纳为三点:
- 零系统调用开销:SQE/CQE 通过共享内存交换,提交无需 syscall
- 零拷贝数据通路:Fixed Buffers 预注册物理页面,跳过每次的 get/put_user_pages
- 操作链接化:依赖操作在提交侧排序,内核按序执行,避免用户态回调地狱
对于 I/O 密集型应用(KV 存储、高频交易、Web 服务器静态文件服务),io_uring 是当前 Linux 平台的最佳 I/O 抽象层。

发表评论 取消回复