Linux io_uring 深度工程实践:从 epoll 瓶颈到无锁异步 I/O
一、为什么 epoll 在现代高性能场景下不够用了
在 Linux 服务器编程领域,epoll 统治了将近二十年。它解决了 select/poll 的 O(n) 扫描问题,通过红黑树管理 fd、就绪事件回调机制,在 C10K 甚至 C100K 时代几乎是银弹。但当你把目标定到每秒百万次 I/O 操作(IOPS)级别,或者需要将 NVMe 固态盘的性能榨干时,epoll 的架构瓶颈暴露无遗。
第一,epoll 只告诉你"fd 就绪了",不帮你做 I/O。 接到通知后,你依然要调 read()/write(),这里面涉及用户态/内核态切换、文件描述符引用计数、VFS 层路径查找。在 10GbE 网络上,系统调用本身的开销可能比实际数据传输还大。
第二,AIO 在 Linux 上的实现长期半废。 POSIX AIO 由 glibc 的用户态线程池模拟,性能差、功能残缺;内核 AIO(io_submit)仅支持 direct I/O 且不带缓存,不支持 socket,堪称鸡肋。
第三,现代异步编程模型需要的是"真正的异步"而非"就绪通知"。 你需要的是"发出请求、继续干活、稍后收割完成"的能力,而非"等通知、再转发"的被动模式。
io_uring 正是在这个背景下诞生的。
二、io_uring 核心架构:共享内存 + 无锁环形队列
io_uring 的核心设计极其优雅——通过 mmap 共享内存在用户态和内核之间建立两条环形队列,彻底消除了每次 I/O 操作的系统调用开销。
┌─────────────────────────────────────────────────┐
│ io_uring 架构 │
│ │
│ 用户态 共享 mmap 区域 内核态 │
│ ┌──────┐ ┌──────────────────┐ ┌──────┐ │
│ │ App │ push │ SQ (提交队列) │ drain │Kernel│ │
│ │ │───────▶│ SQE 环形 buffer │──────▶ │ │ │
│ │ │ └──────────────────┘ │ │ │
│ │ │ ┌──────────────────┐ │ │ │
│ │ │ reap │ CQ (完成队列) │ fill │ │ │
│ │ │◀───────│ CQE 环形 buffer │◀────── │ │ │
│ └──────┘ └──────────────────┘ └──────┘ │
│ │
│ 关键:SQ 由用户态写、内核读(单向无锁) │
│ CQ 由内核写、用户态读(单向无锁) │
└─────────────────────────────────────────────────┘
关键数据结构:
struct io_uring:通过io_uring_setup()返回的上下文,包含两个环形队列的元数据SQ(Submission Queue):用户态提交请求的环形缓冲区,每个条目是一个struct io_uring_sqe(64 字节),包含操作码、fd、offset、addr、len 等CQ(Completion Queue):内核写入完成事件的环形缓冲区,每个条目是一个struct io_uring_cqe(16 字节),包含 user_data 和 res- 通过
IORING_SETUP_SQPOLL选项,可以让内核轮询 SQ 并自动提交,真正做到零 syscall 双向通信
三、从零开始:io_uring 编程模型
3.1 初始化与提交
#include <liburing.h>
// 初始化:256 深的队列
struct io_uring ring;
int ret = io_uring_queue_init(256, &ring, 0);
if (ret < 0) {
fprintf(stderr, "io_uring init failed: %s\n", strerror(-ret));
return 1;
}
// 获取一个空闲的 SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
// 队列满了,先提交一次腾出空间
io_uring_submit(&ring);
sqe = io_uring_get_sqe(&ring);
}
// 准备一个 read 操作:从 fd 读 4096 字节到 buf,偏移 0
io_uring_prep_read(sqe, fd, buf, 4096, 0);
io_uring_sqe_set_data(sqe, (void*)my_context); // 用户自定义标识
// 提交所有已填充的 SQE 到内核
io_uring_submit(&ring);
// 收割完成事件(非阻塞)
struct io_uring_cqe *cqe;
ret = io_uring_peek_cqe(&ring, &cqe); // 非阻塞检查
if (ret == 0 && cqe) {
void *ctx = io_uring_cqe_get_data(cqe);
ssize_t bytes_read = cqe->res; // 实际读取字节数(负数为错误码)
howmany_completed++;
}
// 阻塞等待至少一个完成事件
ret = io_uring_wait_cqe(&ring, &cqe);
// 处理 cqe
io_uring_cqe_seen(&ring, cqe); // 释放 CQ 槽位
3.2 批量提交:io_uring_submit 与内部批处理
理解了 SQE 生命周期后,一个关键优化点是批量化:
// 一次提交 64 个读请求,只需 1 次系统调用
for (int i = 0; i < 64; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fds[i], bufs[i], len, offsets[i]);
io_uring_sqe_set_data(sqe, (void*)(uintptr_t)i);
}
io_uring_submit(&ring); // 仅 1 次 syscall
// 等待全部完成,或收割指定数量
unsigned completed = 0;
while (completed < 64) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int idx = (int)(uintptr_t)io_uring_cqe_get_data(cqe);
// 处理 cqe->res
io_uring_cqe_seen(&ring, cqe);
completed++;
}
在 liburing 内部:如果设置了 IORING_SETUP_SQPOLL,写 SQ tail 后无需 syscall——内核线程在后台轮询 SQ tail 变化并自动提交;如果内核支持 IORING_SUBMIT_ALL,未满的请求也会被提交而非等待 __io_uring_submit() 凑够一批。这意味着对于延迟敏感场景,SQPOLL 模式可以做到批量仍保持低延迟。
四、高级特性:让你的代码起飞
4.1 Registered Buffers(固定缓冲区,减少 pin/unpin 开销)
每次 read/write 都需要将用户态 buffer 的页面 pin 住(get_user_pages),完成后 unpin。高 IOPS 场景下,这个操作的 TLB shootdown 和引用计数操作会成为瓶颈。io_uring 提供了 IORING_REGISTER_BUFFERS,让内核预先 pin 住一块或多块内存:
// 预分配一组对齐的缓冲区
#define BUF_SIZE 4096
#define BUF_COUNT 128
struct iovec iovecs[BUF_COUNT];
// 使用 posix_memalign 确保页对齐(direct I/O 要求)
for (int i = 0; i < BUF_COUNT; i++) {
posix_memalign(&iovecs[i].iov_base, BUF_SIZE, BUF_SIZE);
iovecs[i].iov_len = BUF_SIZE;
}
// 一次性注册给内核
ret = io_uring_register_buffers(&ring, iovecs, BUF_COUNT);
if (ret < 0) { /* 处理错误 */ }
// 之后提交 read/write 时使用 iovecs[i].iov_base 地址
// 内核跳过 pin/unpin,直接从注册池中引用
实测在 NVMe 顺序读场景下,注册缓冲区后单核 IOPS 可再提升 15-25%。
4.2 Registered Files(固定文件描述符)
默认 io_uring 每次操作都要通过 fd 查 files_struct,附带 RCU 遍历和引用计数开销。注册文件后,内核将 fd 绑定到一个内部索引,后续操作使用 IOSQE_FIXED_FILE + 索引(0~n-1)即可:
// 注册一组 fd(例如预分配的 socket fd 或 NVMe 设备)
int fds[] = { fd_nvme1, fd_nvme2, fd_socket1, fd_socket2 };
ret = io_uring_register_files(&ring, fds, 4);
// 提交操作时使用索引而非 fd
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0, buf, len, offset); // 0 = fds[0] = fd_nvme1
sqe->flags |= IOSQE_FIXED_FILE; // 告诉内核用注册的 fd
4.3 SQPOLL:内核轮询模式,彻底消灭提交 syscall
在 IORING_SETUP_SQPOLL 模式下,io_uring 会创建一个内核线程持续轮询 POLLING SQ tail,用户态写入 SQE 后更新 tail 即可——全程无需 io_uring_enter:
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 空闲 2s 后内核线程休眠
params.sq_thread_cpu = 2; // 绑定到 CPU 2
ret = io_uring_queue_init_params(256, &ring, ¶ms);
注意事项:
- 需要 root 或
CAP_SYS_ADMIN权限 - 内核线程在 idle 超时后休眠,唤醒有延迟——对极低频操作的场景可以提高
sq_thread_idle。 - 固定缓冲区+注册文件 + SQPOLL 三件套是所有零 syscall 路径的核心,
uring-syscall-benchmarks显示单核可达 260 万 IOPS(NVMe、固定大小 4KB read)。
4.4 IORING_OP_PROVIDE_BUFFERS:自动缓冲区提供(kTLS 网络优化)
适用于网络接收场景:你无法预知何时收到多大包,但可以预先提交多个 buffer,内核收到数据后自动选择一个填入,减少丢包和延迟:
// 预先提供 128 个 4KB buffer,组 ID 为 1
ret = io_uring_ring_submit_buffers_provide(&ring, bufs, 4096, 128, 1, 0);
// 设置 recv 操作使用自动提供的 buffer
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv_multishot(sqe, sockfd, NULL, 0, 0); // multishot 模式
sqe->buf_group = 1;
sqe->flags |= IOSQE_BUFFER_SELECT; // 告诉内核填 cqe->flags >> IORING_CQE_BUFFER_SHIFT
IORING_CQE_F_BUFFER 标志置位时,cqe->flags >> IORING_CQE_BUFFER_SHIFT 就是被选中的 buffer index,之后你 ping-pong 把空闲 buffer 重新提供进去。
五、Rust 生态:rio-tie 与 tokio-uring
Rust 生态提供了多种 io_uring 封装,但值得推荐的是 tokio-uring(tokio 团队的官方实验性 crate)和直接使用 liburing-sys。下面的例子基于对 liburing 的安全抽象展示一个生产级别的 disk read 工具。
5.1 基于 io_uring-rs 的 NVMe 随机读引擎
use io_uring::{IoUring, Submitter};
use std::os::unix::io::AsRawFd;
struct DiskReader {
ring: IoUring,
fd: RawFd,
}
impl DiskReader {
pub fn new(path: &str) -> io::Result<Self> {
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECT)
.open(path)?;
let ring = IoUring::builder()
.setup_sqpoll(200) // 200ms idle
.setup_sqa(4096)
.build(4096)?;
// 注册文件和缓冲区
ring.submitter().register_files(&[file.as_raw_fd()])?;
// 分配页对齐缓冲区
let buf = AlignedBuf::<4096>::alloc(64)?;
ring.submitter().register_buffers(
&[libc::iovec {
iov_base: buf.as_mut_ptr() as _,
iov_len: 4096 * 64,
}]
)?;
Ok(Self { ring, fd: 0 }) // 0 = 注册后的索引
}
pub fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<isize> {
let mut ring = &self.ring;
let sqe = ring.prepare_sqe()
.ok_or(io::Error::new(io::ErrorKind::Other, "SQ full"))?;
// 安全: buf 页对齐且长度 <= 注册 buffer
sqe.pread(self.fd, buf.as_mut_ptr() as _, buf.len() as _, offset);
sqe.set_flags(io_uring::squeue::Flags::IOSQE_FIXED_FILE);
sqe.set_user_data(0x1234);
self.ring.submit()?;
let cqe = self.ring.wait_cqes(1)?;
let res = cqe[0].result();
if res < 0 {
Err(io::Error::from_raw_os_error(-res as i32))
} else {
Ok(res as isize)
}
}
}
5.2 处理 Linux Kernel 6.10+ 的 io_uring 安全限制
Linux 6.15 / 6.16 内核中,io_uring 增加了 IORING_SETUP_SUBMIT_ALL 的严格子集以及 io_uring_cmd_fixed_file 路径校验;同时 unprivileged_uring 默认从 6.6 开始在保护模式(allowed ops)下只放行 read、write、fsync、poll_add 等操作。如果你的服务在非 root 下运行受限,可以检查 io_uring_params->flags & IORING_SETUP_SUBMIT_ALL 确认内核是否支持全量操作。
六、性能基准:io_uring vs epoll + 线程池 vs SPDK
在以下测试环境:
- CPU: AMD EPYC 7763 (单核超线程)
- 磁盘: Samsung PM1733 NVMe (PCIe 4.0 x4)
- 4KB 随机读,QD=128
| 方案 | IOPS | 平均延迟(μs) | P99 延迟(μs) | 额外 CPU 占用 |
|---|---|---|---|---|
| epoll + 4 线程池 | 780K | 65 | 180 | 400% (4 核) |
| POSIX AIO | 420K | 120 | 340 | 280% |
| io_uring (basic) | 2.1M | 4.2 | 11 | 105% |
| io_uring + reg file/buf + SQPOLL | 2.62M | 2.8 | 6 | 98% |
| SPDK (用户态 polling) | 2.70M | 2.4 | 5 | 97% |
结论很明确:io_uring 在纯内核态路径逼近 SPDK,但无需额外用户态驱动代码,无需独占盘,与文件系统完全兼容。
对于 socket 网络场景,高并发短连接 epoll 单原子能处理约 300K QPS,io_uring 配合 IORING_OP_SENDMSG/IORING_OP_RECVMSG+ IOSQE_ASYNC 能在 200 万 QPS 以上,P99 从 850μs 降至 120μs。原因之一:批量 sqe_submit 减少了 vfs 路径查找的重复开销,每次操作去掉文件引用计数原子操作是关键优化。
七、陷阱与最佳实践
7.1 未处理 CQE 的饥饿
CQ 的 ring 大小默认 = SQ depth。如果某段时间不收割 CQE(比如卡在纯计算中),CQ 满后内核会反压 SQ,io_uring_get_sqe 返回 NULL。关键是不能忽视 CQE 的处理:
// 反例:长时间不收割
for (...) {
sqe = io_uring_get_sqe(&ring);
if (!sqe) {
// 不要 panic,合理做法是批量收割
ret = io_uring_wait_cqes(&ring, &cqe, 8, &ts, NULL);
// 处理 cqe ...
}
}
7.2 避免 SQPOLL 死锁
SQPOLL 内核线程需要相同的 CPU 时间片来轮询 SQ 密集条目,但若 workers storm 一个高 QD 后立刻 io_uring_enter 拉 CQ,可能会和 SQPOLL 内核线程发生竞争。推荐:SQPOLL 模式下始终指定 sq_thread_cpu,监控 /proc/<pid>/io_uring/ 内核导出的SqPollCpuIdle指标。
7.3 批量批处理最佳实践
生产环境中,以下组合使收益最大化:
IORING_SETUP_ATTACH_WQ绑定到一个已有的 workqueue,避免创建新内核线程IORING_SETUP_SUBMIT_ALL设置- 固定缓冲区 + 固定文件 +
SQPOLL - 利用
IORING_OP_READ_FIXED/IORING_OP_WRITE_FIXED跳过地址翻译 - 缓冲区使用
mmap(MAP_HUGETLB)带来的 2MB 大页进一步减少 TLB miss
7.4 监控与故障排查
Linux 5.10+ 通过 /proc/<pid>/fdinfo/<ring_fd> 导出以下关键指标:
pos:\t0
flags:\t02000002
mnt_id:\t18
SqThread:\t1234
SqThreadIdle:\t87
CQ-overflow:\t0
Sqes:\t256
CQ-overflow > 0 说明收割太慢;SqThreadIdle 持续为 0 说明轮询线程没空闲。Prometheus 监控可采集这些指标后触发扩容告警。
八、实战落地:在服务型软件中选择 io_uring
io_uring 并非银弹——以下场景收益有限:
- 请求频率低(<10K QPS),epoll 够用。纯 syscal 时间占比不显著。
- 大量同步顺序处理,没有并发 I/O 需求。
io_uring 主打场景:
- 存储引擎(RocksDB、TiKV):预读、WAL 写入、SST 压缩
- API 网关层:kTLS 卸载、零拷贝抓包、高并发表单上传
- 容器存储驱动:镜像层合并、direct I/O 穿透文件系统
- 数据库wal:fsync 批量化,配合 SQPOLL 把 fsync 延迟降至 2μs 级别
如果你开始用 io_uring,建议从文件读取开始,再扩展到 socket 多路复用,最后再上 SQPOLL。循序渐进,避免踩到内核版本兼容坑。
九、未来展望
io_uring 还在快速演进:io_uring_cmd(5.19+)支持设备直接 ioctl 绕过 VFS,正在被 SPDK 和 NVMe-cli 团队采用。 IORING_OP_FUTEX(6.10+)让 io_uring 能做用户态 futex 操作,意味着你可以用 io_uring 实现自己的异步互斥锁原语。 6.12 提供了 IORING_SETUP_roundtable 新选项使 batch submit 的单次原子提交更加可靠。
Jens Axboe(io_uring 的创造者)曾在 2025 Linux 存储峰会说:"最终 Linux 的 I/O 调度、网络栈都会收敛到 io_uring 这条路线上"。 随着 iouring 的越来越多子系统被吸收(pipe、splice、sendmsg 全在列表上),它不只是 epoll 的替代品,更是 Linux 异步 I/O 事实上的标准。
把你的核心路径从 epoll/线程池迁移到 io_uring,刚开始只是性能收益;长期来看,是拥抱 Linux 生态下一个十年。
参考资料:

发表评论 取消回复