引言:Linux I/O 的演进与 io_uring 的诞生
Linux I/O 接口经历了从 read()/write() 阻塞调用,到 epoll() 事件通知,再到 aio_read()/aio_write() 原生异步 I/O 的演进。然而 Linux AIO 自诞生起就充满了限制:仅支持 O_DIRECT 文件描述符、不支持阻塞操作、提交/完成系统调用开销大、与 epoll 配合困难。
2019 年 Linux 5.1 内核合并了 io_uring,由 Jens Axboe(Linux 块设备层维护者)设计。它并非简单地修补 AIO,而是重新设计了一套全新的异步 I/O 编程模型,采用共享内存环形队列(Submission Queue / Completion Queue)实现用户态与内核态的零系统调用通信,真正做到了高性能、全功能、可扩展的异步操作。
1. 核心架构:SQ、CQ 与 SQE/CQE
io_uring 的核心是两个环形缓冲区:
- Submission Queue (SQ):用户态写入 SQE(Submission Queue Entry),内核消费
- Completion Queue (CQ):内核写入 CQE(Completion Queue Entry),用户态消费
- Submission Queue Entries (SQEs) 数组:所有 SQE 预分配的连续内存
关键创新在于:SQ 和 CQ 的尾部指针映射到用户态内存。在常规使用模式下,用户态提交 I/O 请求只需写入共享内存并更新尾指针,完全不需要 io_uring_enter() 系统调用(批量提交时只需一次)。
这意味着在稳态下:每百万 I/O 操作 0 次系统调用。这是 io_uring 相比 epoll+AIO 的根本优势。
2. 代码实战:最小化 io_uring 程序
#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>
int main() {
// 1. 初始化 io_uring,队列深度 8
struct io_uring ring;
io_uring_queue_init(8, &ring, 0);
// 2. 获取一个 SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
// 3. 准备一个 pread 操作
int fd = open("/tmp/test.dat", O_RDONLY);
char buf[4096];
io_uring_prep_pread(sqe, fd, buf, sizeof(buf), 0);
io_uring_sqe_set_data(sqe, (void*)"read_request");
// 4. 提交(批量模式可多次 get_sqe 后一次性提交)
io_uring_submit(&ring);
// 5. 等待完成事件
struct io_uring_cqe *cqe;
int ret = io_uring_wait_cqe(&ring, &cqe);
if (cqe->res >= 0)
printf("[+] Read %d bytes, user_data=%s\n",
cqe->res, (char*)io_uring_cqe_get_data(cqe));
// 6. Seen CQE
io_uring_cqe_seen(&ring, cqe);
io_uring_queue_exit(&ring);
return 0;
}
编译运行:
$ gcc -o demo demo.c -luring
$ ./demo
[+] Read 4096 bytes, user_data=read_request
3. 高级特性深度解析
3.1 Fixed Buffers(预注册缓冲区)
每次 read() 需要将用户缓冲区 pin 住、映射到内核、再解除,这个 pin/unpin 操作在大 I/O 压力下会消耗大量 CPU。io_uring 允许预先注册一组缓冲区(IORING_REGISTER_BUFFERS),之后提交 I/O 时只需指定 buffer index:
// 预注册 32 个 4KB 缓冲区
struct iovec iovecs[32];
for (int i = 0; i < 32; i++)
posix_memalign(&iovecs[i].iov_base, 4096, 4096),
iovecs[i].iov_len = 4096;
io_uring_register_buffers(&ring, iovecs, 32);
// 提交时使用_fixed variant
io_uring_prep_read_fixed(sqe, fd, NULL, 4096, 0, 0/*buf_index*/);
效果:消除每次 I/O 的 get_user_pages/put_user_pages 开销,随机读 IOPS 提升可达 15-20%。
3.2 Fixed Files(预注册文件描述符)
类似缓冲区,IORING_REGISTER_FILES 可以预注册一组 fd。提交 I/O 时使用 index 而非 fd,避免每次的 fget/fput 原子操作:
int fds[] = {fd1, fd2, fd3};
io_uring_register_files(&ring, fds, 3);
// 提交时设置 IOSQE_FIXED_FILE 标志
io_uring_sqe_set_flags(sqe, IOSQE_FIXED_FILE);
sqe->fd = 0; // index into registered files array
3.3 Polling Mode(轮询模式)
对于 NVMe 设备(延迟 < 10μs),系统调用开销甚至中断开销本身成为瓶颈:
- IORING_SETUP_IOPOLL:内核使用轮询方式检查 NVMe 完成队列,完全消除了中断开销
- IORING_SETUP_SQPOLL:内核启动一个内核线程主动轮询 SQ,用户态完全不需要调用
io_uring_enter()
实测数据(Samsung PM983 NVMe,4K 随机读):
- read() 同步:~340K IOPS
- AIO 异步:~410K IOPS
- io_uring buffered:~600K IOPS
- io_uring IOPOLL:~800K+ IOPS(接近内核 limits)
3.4 Linked SQEs(操作链)
通过 IOSQE_IO_LINK 标志可以将多个 SQE 链接成一个原子操作链。链中前一个操作失败则后续操作全部跳过:
// 原子操作:先 read header,再 read body
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, fd, header_buf, HDR_SIZE, 0);
io_uring_sqe_set_data(sqe1, header_ctx);
sqe1->flags |= IOSQE_IO_LINK; // 链接下一个
struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe2, fd, body_buf, body_size, HDR_SIZE);
io_uring_sqe_set_data(sqe2, body_ctx);
io_uring_submit(&ring); // 一次提交,两个操作按序执行
4. io_uring vs epoll vs AIO 深度对比
| 特性 | epoll + 线程池 | Linux AIO | io_uring |
|---|---|---|---|
| 系统调用频率 | 1 次 / IO(提交+等待) | 2 次 / IO | 0 次(批量提交稳态) |
| O_DIRECT 强制 | 否 | 是(普通文件) | 否 |
| Buffered I/O | 是 | 不支持 | 完美支持 |
| TCP 网络 | 完美 | 不支持 | 5.5+ 支持 connect/accept/send/recv |
| NUMA 感知 | 否 | 否 | SQPOLL 绑定 NUMA 节点 |
| Cancelled 语义 | pthread_cancel 不可靠 | 部分支持 | 完善的 cancel 接口 |
5. 生产实践:用 io_uring 重构 KV 存储引擎
下面是一个简化版 KV 存储的 get() 操作对比,展示 io_uring 如何将延迟从 50μs+ 降至 8μs:
// 传统 epoll + 线程池模型
// worker_thread: pread() → 阻塞 50μs → 返回结果
// 问题:线程上下文切换 5-10μs + 阻塞等待 = 高尾延迟
// io_uring 模型(单线程事件循环)
void handle_get_request(struct io_uring *ring, conn_t *conn) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
// 直接发送 disk I/O,无线程阻塞
io_uring_prep_pread(sqe, conn->fd, conn->buf, conn->val_len, conn->offset);
io_uring_sqe_set_data(sqe, conn); // 完成时通过 user_data 找到连接
io_uring_submit(ring); // 批量场景下一次提交数千个
}
// 事件循环
void event_loop(struct io_uring *ring) {
while (1) {
// 1. 网络事件处理(epoll/kevent 或 io_uring 自己的 timeout)
// 2. 收割完成事件
struct io_uring_cqe *cqe;
unsigned head;
io_uring_for_each_cqe(ring, head, cqe) {
conn_t *conn = (conn_t*)io_uring_cqe_get_data(cqe);
send_response(conn, cqe->res);
io_uring_cqe_seen(ring, cqe);
}
}
}
生产环境关键配置参数:
- 队列深度:设备队列深度的 2-4 倍(NVMe 建议 256-1024),过大浪费内存,过小限制吞吐
- SQPOLL_IDLE:设置 20ms,空闲时内核线程睡眠节省 CPU
- SetupFlags:IO_SQ_AFF | SQ_POLLTICK 在需要低延迟时必须启用
6. 陷阱与最佳实践
6.1 SQE 饥饿
如果应用总是耗尽 SQE(队列深度不足),提交线程将阻塞在 io_uring_get_sqe() 上。解决方案:适当增大 ring size,或在 io_uring_enter() 时使用 IORING_ENTER_GETEVENTS 参数让内核部分收割。
6.2 内存序问题
SQ Tail 更新必须使用 release 语义,确保 SQE 写入对内核可见。liburing 已经处理了这些 memory order 问题,但自行实现 io_uring 时务必注意。
6.3 超时处理
// io_uring 原生支持高精度超时
struct __kernel_timespec ts = { .tv_sec = 0, .tv_nsec = 500000000 }; // 500ms
io_uring_prep_timeout(sqe, &ts, 1, 0);
// 链接到 I/O 操作:I/O 超时自动取消
sqe->flags |= IOSQE_IO_LINK | LINK_TIMEOUT;
6.4 与 cgroup v2 的集成
io_uring 操作会统计到 io.stat 中。在高密度容器环境中需注意 BPF 拦截(BPF_CGROUP_RUN),某些 K8s 安全策略会限制 io_uring 使用。
7. 内核态实现原理
理解 io_uring 的内核实现对调优至关重要:
- io_uring 上下文:
io_ring_ctx结构体管理所有 SQ/CQ 的映射、引用计数、任务状态 - Workqueue 机制:SQPOLL 模式下内核线程
io-wq-kthreadd主动 poll SQ 并提交到块层 - Defer 模式:非 SQPOLL 模式下,
io_uring_enter()会触发 workqueue 提交操作 - Completion 路径:NVMe IRQ → CQ 写入 → eventfd notify(或 SQPOLL 直接填充 CQ)
- BUG 历史:CVE-2023-2598(OOB write in CQ ring)提醒我们 CQE 处理需要边界检查
8. 最新进展与未来方向
- 6.x 内核:send_zc(零拷贝网络发送)、fuse 支持 io_uring、TCP_RXDROP
- 网络子系统:io_uring 的
IORING_OP_SENDMSG/IORING_OP_RECVMSG+ multishot accept 让网络+磁盘统一事件循环成为可能 - 恶意使用检测:FireEye 报告显示攻击者已开始使用 io_uring 绕过 syscall-based 监控(因为不触发 syscall hook 的 io_uring 对传统 EDR 不可见),需借助 eBPF 的
tracepoint/io_uring:*链
总结
io_uring 是 Linux 系统编程中最重要的 I/O 接口之一。它解决了 AIO 的种种限制,将 Linux 异步 I/O 性能提升到接近 SPDK 内核旁路的水平。对于构建高性能网络服务、KV 存储引擎、数据库系统等场景,掌握 io_uring 已成为 Linux 系统工程师的必备技能。
关键记住三条原则:批量提交减少 syscall、预注册 buff/fd 消除映射开销、SQPOLL 模式实现零上下文切换。其余的都是工程细节。

发表评论 取消回复