一、为什么 epoll 不够用?——I/O 模型的历史债务
在深入 io_uring 之前,我们必须先理解它要解决的问题。过去二十年,Linux 异步 I/O 的演进史就是一部"补丁叠补丁"的历史。
1.1 从阻塞 I/O 到 epoll 的困境
Linux 的传统异步方案是 epoll(2.5.44 引入),它的核心假设是通知就绪而非完成 I/O。
// epoll 典型事件循环
while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN)
read(events[i].data.fd, buf, sizeof(buf));
}
}
这意味着每次 I/O 至少产生2 次系统调用:epoll_wait + read/write。在高 IOPS 场景下,仅系统调用本身的开销(上下文切换、TLB 刷新)就占 CPU 的 30% 以上。
1.2 libaio 的失败尝试
Linux 的 POSIX AIO(libaio)在设计上存在根本缺陷:仅支持 O_DIRECT 文件访问、不支持 sockets、不支持 buffered I/O、且链式提交(io_submit + io_getevents)的延迟不可控。这使得它长期只能作为"块设备专用"工具存在。
1.3 io_uring 的设计目标
Jens Axboe(io_uring 作者,也是 Linux 块设备层维护者)在设计 io_uring 时提出了三个核心目标:
- 零系统调用开销:通过用户态/内核态共享的环形队列,在稳态下可以做到 0 次系统调用
- 统一 I/O 接口:一个 API 处理文件、网络、定时器、事件通知等所有 I/O
- 可扩展性:从单核 IoT 设备到多路服务器都能高效工作
二、io_uring 的核心数据结构——两个环形队列
io_uring 的核心是共享内存中的两个环形队列(Ring Buffer),这是用户态进程与内核之间零拷贝传递 I/O 请求的通道。
2.1 提交队列(Submission Queue, SQ)
SQ 是一个生产者-消费者队列:用户态是生产者(放入 SQE),内核是消费者(取出并执行 SQE)。
struct io_uring_sqe {
__u8 opcode; // 操作码:IORING_OP_READV, IORING_OP_WRITEV
__u8 flags;
__u16 ioprio; // I/O 优先级
__s32 fd; // 目标文件描述符
__u64 off; // 目标偏移
__u64 addr; // 用户态缓冲区地址
__u32 len; // 缓冲区长度
__u32 rw_flags; // 读写标志
__u64 user_data; // 用户数据,会在 CQE 中原样返回
__u16 buf_index; // 缓冲区组索引
__u16 personality; // 连接 persona
...};
关键字段:user_data 是用户自定义的 64 位标识符,内核处理完成后会在 CQE 中原样返回,用于匹配请求与完成事件。这是异步 I/O 中"上下文绑定"的核心机制。
2.2 完成队列(Completion Queue, CQ)
CQ 是内核生产-用户态消费的队列,内核将完成的 I/O 请求写入 CQE。
struct io_uring_cqe {
__u64 user_data; // 与 SQE 中的 user_data 对应
__s32 res; // 操作结果
__u32 flags; // 完成标志(如 IORING_CQE_F_MORE)
};
res 字段的语义与具体系统调用对齐:读操作成功时为读取字节数,失败时为负的错误码(如 -EINVAL、-EAGAIN)。
2.3 mmap 共享内存布局
两个队列都通过 mmap 映射到用户态地址空间。io_uring 初始化时执行 3 次 mmap:SQ 数组、SQ 元数据、CQ 数组。这种共享内存设计意味着:在 IORING_SETUP_SQPOLL 模式下,完全可以做到零系统调用——用户态直接写入 tail,内核线程直接读取 tail。
三、liburing API 详解
强烈推荐使用 liburing 库,它提供了与内核版本同步的友好封装。
3.1 初始化与参数调优
#include <liburing.h>
struct io_uring ring;
struct io_uring_params params = {0};
params.flags |= IORING_SETUP_SQPOLL; // 启用内核提交线程
params.sq_thread_cpu = 2; // 绑定到 CPU 2
params.sq_thread_idle = 200; // 空闲 200ms 后休眠
int ret = io_uring_queue_init_params(QUEUE_DEPTH, &ring, ¶ms);
io_uring_queue_exit(&ring); // 退出
QUEUE_DEPTH:每增加一个 SQE 多 64 字节内存占用,典型数据库场景 256~1024,网络应用 32~128。
3.2 提交 I/O 请求的完整流程
// 获取一个 SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
// 填充 SQE
io_uring_prep_readv(sqe, fd, &iov, 1, offset);
io_uring_sqe_set_data(sqe, my_context);
// 批量填充多个 SQE...
// 提交(摊销系统调用)
int submitted = io_uring_submit(&ring);
// 收割完成事件
struct io_uring_cqe *cqe;
unsigned head;
io_uring_for_each_cqe(&ring, head, cqe) {
handle_completion(cqe->user_data, cqe->res);
}
io_uring_cq_advance(&ring, completed);
3.3 等待完成事件的两种模式
// 模式 A:忙等待——延迟最低
while (!done) {
struct io_uring_cqe *cqe;
int ret = io_uring_peek_cqe(&ring, &cqe);
if (ret == 0) break;
asm volatile("pause");
}
// 模式 B:阻塞等待——CPU 占用最低
struct __kernel_timespec ts = { .tv_sec = 1, .tv_nsec = 0 };
io_uring_wait_cqe_timeout(&ring, &cqe, &ts);
四、高级特性——真正的杀手锏
4.1 链接操作(IOSQE_IO_LINK)
链接操作让多个操作在同一个 submit 批次中顺序执行,不需要等待中间结果。IOSQE_IO_HARDLINK(硬链)表示前一个失败则后续全部跳过。
4.2 固定文件(Fixed Files)
通过预注册 fd 集合消除每次 submit 的内核文件表查找开销。
int fds[] = { fd1, fd2, fd3 };
io_uring_register_files(&ring, fds, 3);
sqe->flags |= IOSQE_FIXED_FILE; // 使用注册索引而非真实 fd
4.3 固定缓冲区(Fixed Buffers)
预先 pin 住用户内存,避免 get_user_pages 的重复映射开销。
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;
}
io_uring_register_buffers(&ring, iovecs, BUF_SIZE);
io_uring_prep_read_fixed(sqe, fd, NULL, len, 0, buffer_index);
零拷贝缓冲区池:在 multishot accept + recv 中,注册的缓冲区在内核与网卡之间共享,用户态通过 CQE 中的 buf_index 映射数据。
4.4 Multishot 操作
5.19+ 引入:一个 SQE 产生多个 CQE,完美解决"每事件需新 SQE"的问题。
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, listen_fd, addr, &addrlen, flags);
// 内核自动接受连接并持续产生 CQE,直到取消
4.5 零拷贝发送(IORING_OP_SEND_ZC)
io_uring 5.19+ 内置零拷贝发送,用户态写入缓冲区后无需等待内核的 send 完成确认,直接通过 CQE 完成通知回收缓冲区。
五、性能调优实战
5.1 模式选择
| 模式 | 适用场景 | CPU 开销 | 延迟 |
|---|---|---|---|
| 传统无 flags | 突发 I/O | 高(系统调用) | 中等 |
| SQPOLL | 稳态高 IOPS | 高(内核线程) | 低 |
| IOPOLL | NVMe 存储 | 极高(专用 CPU 核) | 极低 |
5.2 SQPOLL 线程配置
- sq_thread_cpu:绑定到独立核(配合 isolcpus)
- sq_thread_idle:突发 I/O 降低(100ms),间歇性 I/O 保持默认(2000ms)
- CQ 大小:设置为 SQ 的 2~4 倍防溢出
5.3 文件系统配合
- io_uring 支持 40+ 种 ops:read/write/fallocate/fsync/splice/statx/madvise/sync_file_range 等
- XFS 对 io_uring 的平滑写入优于 ext4;ext4 在小文件随机写场景更优
IORING_OP_FSYNC在 5.7+ 完整支持(含 fdatasync)
六、生产环境陷阱与最佳实践
6.1 内存屏障
环形队列依赖正确的内存屏障。始终使用 io_uring_smp_store_release() 而非直接写 tail 指针。
6.2 CQ 溢出处理
内核参数 IORING_FEAT_NODROP 确保 CQ 溢出时阻塞 submit 而非丢弃事件。监控 io_uring_enter 的返回值获取 overflow 计数。
6.3 线程安全
io_uring 不是线程安全的。多线程模式下应每个线程一个 io_uring 实例。IORING_SETUP_SINGLE_ISSUER 下可省略部分同步。
6.4 内核 6.x 新特性
- 6.8+:IORING_SETUP_PRE_KERNEL,用户态预提交逻辑
- 6.10+:IORING_REGISTER_FILE_ALLOC,内核按需分配 fd 表
- IORING_OP_URING_CMD:扩展至支持用户态 SPDK 驱动
七、生产案例
7.1 Redis 8.0 io_uring 集成
Redis 8.0 将 io_uring 作为可选 I/O 后端,实测数据:随机读 IOPS 提升 60~80%,P99 延迟降低 40~50%,CPU 利用率降低 30%。采用固定缓冲区 + 多缓冲池 + 批量提交 + Multishot 的混合策略。
7.2 RocksDB 实验性集成
LSM 树 Compaction 阶段改用 io_uring 批量提交预读,吞吐提升约 40%。WAL 同步写入使用固定缓冲区,fsync 延迟降低 25%。
八、总结:为什么 io_uring 是"未来十年的 I/O 接口"
io_uring 是 Linux 内核设计哲学的范式转变:从"通知就绪"到"完成 I/O"。它的独特之处:
- 零系统调用:稳态下无需 syscall 即可提交或收割 I/O
- 零数据拷贝:固定缓冲区 + 零拷贝发送实现用户态→设备端的直达数据路径
- 上下文传递:CQE 的 user_data 机制实现真正的异步非阻塞流程
- 持续演进:从 5.1 的 17 种 ops 到 6.x 的 40+ 种 ops
在 2026 年的今天,如果你仍在使用 epoll + read/write 处理高并发 I/O,你可能已经落后了一个时代。io_uring 不只是 I/O——它是用户态与内核态协作的新基石,是 Linux 性能完全体的最后一块拼图。
建议行动清单:
- 升级到内核 6.8+,体验 SQPOLL 稳定性改进
- 评估存储密集型业务,用固定缓冲区 + 批量 submit 优化热点
- 网络高吞吐场景研究 multishot accept + recv + send_zc 完整链路
- 建立 io_uring 的 CQE 处理延迟与 SQ 线程活跃时长监控

发表评论 取消回复