Linux 内核 io_uring 异步 I/O 深度实战:从原理到高性能应用
一、io_uring 概述:为什么需要它
在 Linux 内核 5.1 中引入的 io_uring(曾用名 aioring、libcuring)是 Linux 异步 I/O 的革命性框架。它解决了长期以来 Linux AIO (libaio) 的诸多痛点:
- 传统 AIO:仅支持 O_DIRECT 文件 I/O,不支持网络 I/O,API 设计繁琐,完成事件需要额外拷贝
- epoll:本质仍是同步就绪通知,真正的 I/O 操作仍需阻塞调用
- io_uring:统一文件与网络异步 I/O,零拷贝提交与完成,支持轮询模式(polling),吞吐量可达数百万 IOPS
io_uring 的核心设计理念是 共享环形缓冲区(Shared Ring Buffers):用户态与内核态通过两个无锁环形队列通信,避免了传统 syscall 的 io_submit / io_getevents 多次系统调用开销。
二、核心数据结构
// 提交队列条目 (Submission Queue Entry)
struct io_uring_sqe {
__u8 opcode; // 操作码:IORING_OP_READV, IORING_OP_WRITEV 等
__u8 flags; // 标志位
__u16 ioprio; // I/O 优先级
__s32 fd; // 文件描述符
union { __u64 off; ... }; // 文件偏移
union { __u64 addr; ... }; // 缓冲区地址
__u32 len; // 缓冲区长度
union {
__kernel_rwf_t rw_flags;
__u32 fsync_flags;
...
};
__u64 user_data; // 用户自定义标识,完成时回传
union { __u16 buf_index; ... };
__u16 personality;
__s32 splice_fd_in;
...
};
// 完成队列条目 (Completion Queue Entry)
struct io_uring_cqe {
__u64 user_data; // 提交时设置的标识
__s32 res; // 操作结果(类似返回值)
__u32 flags; // 完成标志
};
核心结构关系如下:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 用户态 │ │ 共享内存 │ │ 内核态 │
├──────────────┤ ├──────────────┤ ├──────────────┤
│ │ │ SQ Ring │ │ │
│ 填充 SQE │────▶│ (提交队列) │────▶│ 处理 I/O │
│ │ │ │ │ │
│ 消费 CQE │◀────│ CQ Ring │◀────│ 完成 I/O │
│ │ │ (完成队列) │ │ │
│ │ │ SQEs Array │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
SQ Tail 环形缓冲区 ↑
CQ Head ↓
内核读 SQ Tail 内核写 CQ Tail
用户写 SQ Tail 用户读 CQ Head
三、io_uring 初始化与基础 API
使用 liburing 库进行 io_uring 编程非常简洁:
#include <liburing.h>
// 1. 初始化 io_uring 实例
struct io_uring ring;
struct io_uring_params params = {0};
// 设置队列深度(2 的幂次,内核实际可能分配更多)
int ret = io_uring_queue_init_params(256, &ring, &/params);
if (ret < 0) {
fprintf(stderr, "io_uring init failed: %s\n", strerror(-ret));
return 1;
}
// 检查内核版本是否支持指定功能
if (!/params.features & IORING_FEAT_SINGLE_MMAP) {
// 需要分别 mmap SQ Ring 和 CQ Ring
}
初始化完成后,io_uring 实例通过 mmap 将两个环形缓冲区映射到用户空间(Linux 5.4+ 支持 IORING_FEAT_SINGLE_MMAP,一次 mmap 映射两个缓冲区)。
四、提交 I/O 请求:Read 与 Write 实战
4.1 异步读文件
ssize_t read_with_uring(int fd, void *buf, size_t len, off_t offset) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
// SQ 已满,需要先提交一批请求
io_uring_submit(&ring);
sqe = io_uring_get_sqe(&ring);
}
// 设置 SQE:预读取(readv)
io_uring_prep_readv(sqe, fd, &iovec, 1, offset);
// 设置 user_data 用于完成时匹配
io_uring_sqe_set_data(sqe, (void*)(uintptr_t)req_id);
// 提交到内核(仅更新 SQ Tail,不触发 syscall)
io_uring_submit(&ring);
// 注意:io_uring_submit 本身是一次 syscall,但可以批量提交
return 0; // 异步返回,稍后从 CQE 获取结果
}
4.2 异步写文件
void write_with_uring(int fd, const void *buf, size_t len, off_t offset,
uint64_t user_data) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
// 使用固定缓冲区(IORING_REGISTER_BUFFERS)优化
// buf_index 指向预先注册的缓冲区
io_uring_prep_write_fixed(sqe, fd, buf, len, offset, buf_index);
io_uring_sqe_set_data(sqe, (void*)(uintptr_t)user_data);
// 批量提交时才调用 io_uring_submit()
io_uring_submit(&ring);
}
4.3 等待并收割完成事件
struct io_uring_cqe *cqe;
unsigned head;
int completed = 0;
// 方式1:阻塞等待至少一个完成事件
io_uring_wait_cqe(&ring, &/cqe);
// 方式2:非阻塞 peek(常见于事件循环)
if (io_uring_peek_cqe(&ring, &/cqe) == 0) {
uint64_t ud = (uint64_t)io_uring_cqe_get_data(cqe);
int res = cqe->res; // 实际读取的字节数,或负的错误码
// 处理完成...
io_uring_cqe_seen(&ring, cqe);
}
// 方式3:批量收割(高效事件循环模式)
io_uring_for_each_cqe(&ring, head, cqe) {
uint64_t ud = (uint64_t)io_uring_cqe_get_data(cqe);
handle_completion(ud, cqe->res);
completed++;
}
if (completed > 0) {
io_uring_cq_advance(&ring, completed);
}
五、高级特性:固定缓冲区与固定文件
io_uring 提供多种机制进一步减少开销:
5.1 注册固定缓冲区(Registered Buffers)
每次 I/O 操作都需要内核将用户缓冲区映射到内核空间(get_user_pages),注册缓冲区后只需首次 pin 页,后续操作直接使用索引:
// 预注册一组缓冲区
struct iovec iovecs[BUF_COUNT];
for (int i = 0; i < BUF_COUNT; i++) {
iovecs[i].iov_base = aligned_alloc(4096, BUF_SIZE);
iovecs[i].iov_len = BUF_SIZE;
// 预填充数据(注册 I/O)
}
io_uring_register_buffers(&ring, iovecs, BUF_COUNT);
// 之后使用 buf_index 而非 addr 指针
io_uring_prep_read_fixed(sqe, fd, NULL, len, offset, buf_index);
// 清理
io_uring_unregister_buffers(&ring);
性能收益:对于高频小 I/O 场景,可减少 15-30% 的延迟。
5.2 注册固定文件(Registered Files)
每次 I/O 都需要内核通过 fd 查找 file 结构并增加引用计数,注册文件后使用索引:
int fds[] = { fd1, fd2, fd3 };
io_uring_register_files(&ring, fds, 3);
// 在 SQE 中使用固定文件索引
sqe->flags |= IOSQE_FIXED_FILE; // 标记为使用注册文件
sqe->fd = file_index; // 使用数组索引而非 fd 值
5.3 缓冲区选择(Buffer Selection)
Linux 5.7+ 支持自动分组缓冲区选择:
// 注册缓冲区分组
struct io_uring_buf_reg reg = {
.ring_addr = (unsigned long)ring_buffer_addr,
.ring_entries = 256,
.bgid = 1, // 缓冲区组 ID
};
io_uring_register_buf_ring(&ring, &/reg, 0);
// 设置 SQE:跳过 addr/len,内核自动从分组中选择缓冲区
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = 1;
// 完成时通过 cqe->/flags >> IORING_CQE_BUFFER_SHIFT 获取选中的缓冲区 ID
int buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
六、内核轮询模式(IORING_SETUP_IOPOLL / SQPOLL)
io_uring 提供两种内核侧轮询模式,彻底消除系统调用:
6.1 SQPOLL(提交队列轮询)
struct io_uring_params params = {
.flags = IORING_SETUP_SQPOLL, // 内核线程轮询 SQ
.sq_thread_idle = 2000, // 空闲 2s 后内核线程睡眠(ms)
.sq_thread_cpu = 3, // 绑定内核线程到 CPU 3
};
io_uring_queue_init_params(QUEUE_DEPTH, &ring, &/params);
// 用户态操作完全不需要 syscall:
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, offset);
io_uring_sqe_set_data(sqe, user_data);
// 写入新的 tail 指针(通过共享内存,无 syscall)
io_uring_sqe_submit(&ring); // 等价于写 SQ Tail,无 syscall
// 设置 IORING_SETUP_SQPOLL 后,若 SQ 已满则需提交
// 使用 io_uring_submit 时,内核线程已处理,仅做标记
SQPOLL 线程模型:
- 内核创建名为
io_uring-sq的线程轮询提交队列 - 用户态更新 SQ Tail 即通知内核线程(通过写内存 + write barrier)
- 无需 io_uring_submit syscall
- 适合:极低延迟需求,可承受独占一个 CPU 核心
6.2 IOPOLL(I/O 完成轮询)
struct io_uring_params params = {
.flags = IORING_SETUP_IOPOLL, // 设备侧轮询(需要硬件支持)
};
io_uring_queue_init_params(QUEUE_DEPTH, &ring, &/params);
// IOPOLL 配合 NVMe 设备(设置 /sys/block/nvme0n1/queue/io_poll=1)
// 内核轮询设备完成队列而不使用中断
// 延迟可低至 10μs 以下 (vs 中断模式的 50~200μs)
6.3 无中断全轮询模式
同时设置 SQPOLL + IOPOLL 可达到全异步、无 syscall、无中断的极致性能:
params.flags |= IORING_SETUP_SQPOLL | IORING_SETUP_IOPOLL;
// 用户态零 syscall 提交 + 内核主动轮询完成
// 配合 bufring 自主缓冲区选择,纯 I/O 处理路径完全绕过 syscall
七、io_uring 链接操作与依赖链
io_uring 支持将多个 SQE 链接为一个执行链(Linked SQE),实现依赖关系:
// 链式操作:先读 header,读成功后读 body
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, fd, header_buf, HEADER_SIZE, 0);
io_uring_sqe_set_data(sqe1, REQ_HEADER);
struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe2, fd, body_buf, body_len, HEADER_SIZE);
io_uring_sqe_set_data(sqe2, REQ_BODY);
// 链接:sqa2 只在 sqa1 成功后执行(IOSQE_IO_LINK)
sqe1->flags |= IOSQE_IO_LINK;
sqe2->flags &= ~IOSQE_IO_LINK;
// 如果前一个失败,链中后续操作标记为失败(跳过执行)
// 使用 IOSQE_IO_HARDLINK 强链接:前面失败时后面也不执行
sqe1->flags |= IOSQE_IO_HARDLINK;
io_uring_submit(&ring);
链式操作实用场景:
- 协议解析:先读固定头,根据长度字段再读变长体
- 数据库 WAL:先写日志,日志写入成功后再更新索引
- 多阶段 pipeline:初始化→读数据→处理→写结果
八、实际应用案例:高性能网络服务器
8.1 基于 io_uring 的 TCP echo 服务器
// 关键流程(使用 liburing 的高层封装):
// 1. 初始化 + 注册缓冲区
// 2. 创建 accept 请求
// 3. 事件循环:收割 CQE → 分发处理 → 提交新 SQE
void accept_connection() {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
// 异步 accept
io_uring_prep_accept(sqe, listen_fd, &/addr, &/addrlen, 0);
io_uring_sqe_set_data(sqe, (void*)OP_ACCEPT);
io_uring_submit(&ring);
}
void event_loop() {
struct io_uring_cqe *cqe;
unsigned head;
while (running) {
// 等待完成事件(非阻塞:io_uring_peek_cqe)
int ret = io_uring_wait_cqe(&ring, &/cqe);
if (ret < 0) continue;
io_uring_for_each_cqe(&ring, head, cqe) {
int op = (int)(uintptr_t)io_uring_cqe_get_data(cqe);
int res = cqe->res;
switch (op) {
case OP_ACCEPT: // accept 完成,获取新连接
if (res >= 0) {
client_fd = res;
// 对新连接提交 read 请求
submit_read(client_fd);
// 重新 accept 下一个连接
accept_connection();
}
break;
case OP_READ: // read 完成
if (res > 0) {
// 回写数据
submit_write(client_fd, buf, res);
}
break;
case OP_WRITE: // write 完成,继续读
submit_read(client_fd);
break;
}
}
io_uring_cq_advance(&ring, cqe_count);
}
}
8.2 使用 accept + send/recv 多路复用
Linux 5.19+ 引入了 multishot accept,一次提交,每次新连接都产生 CQE:
// Multishot accept:内核持续监听,每来一个连接就触发一次 CQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, listen_fd, &/addr, &/addrlen, 0);
sqe->flags |= IOSQE_CQE_SKIP_SUCCESS; // 成功时不产生额外 CQE
io_uring_sqe_set_data(sqe, (void*)OP_ACCEPT);
io_uring_submit(&ring);
九、性能对比与调优指南
9.1 典型吞吐量对比
| 场景 | read/write syscall | libaio | epoll+read | io_uring (baseline) | io_uring (SQPOLL+IOPOLL) |
|---|---|---|---|---|---|
| 4KB 随机读 NVMe | 200K IOPS | 500K IOPS | N/A | 1.2M IOPS | 3.5M IOPS |
| 4KB 随机写 NVMe | 180K IOPS | 400K IOPS | N/A | 1.0M IOPS | 3.0M IOPS |
| Echo 服务器 (QPS) | 120K | N/A | 250K | 450K | 800K |
9.2 关键调优参数
- 队列深度:根据存储设备 IOPS 能力设置。NVMe SSD 建议 256-1024
- sq_thread_idle:SQPOLL 模式内核线程空闲超时,单位 ms。低延迟场景设 0 保持常驻
- IORING_REGISTER_RING_FDS:允许 eventfd/epoll 监控 ring->cq.cqes,便于嵌入 epoll 事件循环
- IORING_SETUP_ATTACH_WQ:多个 ring 共享同一个内核 worker 线程池
- CQE batching:累积一定数量 CQE 再批量收割,减少 CQ 读取开销
- 固定缓冲区:减少内核 pin/unpin 页面的次数
9.3 常见问题排查
问题1:io_uring_queue_init 返回 -ENOMEM
- 原因:RLIMIT_MEMLOCK 限制不足,io_uring 需要锁定内存
- 解决:
ulimit -l unlimited或增大/etc/security/limits.conf中的 memlock
问题2:SQPOLL 线程 CPU 占用 100%
- 检查 sq_thread_idle 是否设置为 0
- 解决:设置合理的超时值(如 2000ms),或在不活动时调用
io_uring_sq_ring_wait(&ring)
问题3:I/O 操作被降级为同步
- 原因:文件没有 O_DIRECT 或 IOPOLL 对设备不支持
- 解决:检查
/sys/block/<dev>/queue/io_poll,对字符设备 IOPOLL 不适用
十、io_uring 未来发展
io_uring 仍在快速演进中,值得关注的方向:
- napi_busy_poll:内核 5.11+ 集成,io_uring 自动利用 NAPI busy polling 降低网络延迟
- BPF 与 io_uring 融合:内核中 BPF 程序可以操作 SQE,实现可编程 I/O 调度
- TIOCSQIOCB / io_uring 跨进程共享: 通过 fd 传递实现多进程共享同一 ring
- in-kernel 驱动 io_uring:NVMe passthrough、io_uring 直接操作 NVMe SQ/CQ(绕过块层)
- QP 队列模式:支持一对多广播模式,单次提交覆盖多个文件描述符
总结
io_uring 是 Linux I/O 路径的一次系统性重构,通过共享环形缓冲区实现了真正的零拷贝异步 I/O。从 5.1 版本的单一文件 I/O 到现在的网络、存储、缓冲区管理全覆盖,io_uring 已成为构建高性能服务端应用的核心基础设施。
掌握 io_uring 的关键在于理解其"批量提交+异步完成"的编程模型,善用固定缓冲区/文件免掉动态映射开销,针对场景选择合适的轮询模式,并结合链接操作构建多阶段异步工作流。随着内核持续优化和新特性加入,io_uring 的应用领域还将进一步扩大。

发表评论 取消回复