io_uring 入门¶
标签:linux, io, async, kernel, syscall
面向零基础读者。从磁盘读取一个字节说起,逐步推导到 io_uring 的核心机制、内存布局、提交/收割流程与性能优势。
1. 一切的开始:程序想读一个文件¶
最简单的 C 程序:
int fd = open("hello.txt", O_RDONLY); // 打开文件
char buf[100];
int n = read(fd, buf, 100); // 读 100 字节
printf("%s\n", buf);
这三行背后隐藏着大量你看不见的机制。
1.1 用户态 vs 内核态¶
CPU 执行程序时,有两种权限级别:
| 概念 | 含义 | 类比 |
|---|---|---|
| 用户态(User Space) | 你的程序(main、printf 等)运行在这里,权限受限 | 普通员工 |
| 内核态(Kernel Space) | 操作系统核心(文件系统、网络、进程调度)运行在这里,能直接操作硬件 | 公司管理层 + 后勤 |
普通员工不能直接去机房拔硬盘——你必须提交申请给后勤部门。这种"申请",在操作系统里叫系统调用(System Call)。
open、read、write、close 全都是系统调用。它们不是普通的函数——它们会让 CPU 从用户态切换到内核态,让内核替你去访问磁盘、网卡等硬件。
1.2 文件描述符(File Descriptor, fd)¶
open() 返回的那个整数 fd,是一个句柄——你拿着这个数字,内核就知道你要操作哪个"打开的文件"。
类比:你在柜台填表,柜台给你一个取件号码。下次你拿这个号码,就能领到你要的"文件对象"。
每个进程默认有三个:
open() 会返回当前可用的最小号码(通常是 3)。
1.3 read() 实际做了什么¶
调用 read(fd, buf, 100) 时:
- CPU 切到内核态(保护现场、记下"我刚才执行到哪")
- 内核找到 fd 对应的文件对象(文件在磁盘哪个位置、权限是什么)
- 内核向磁盘控制器发指令:"请把 hello.txt 的前 100 字节读上来"
- 磁盘开始转动、磁头开始寻道——这是机械动作,可能几毫秒到几十毫秒
- 磁盘把数据放到内存,内核把数据拷贝到你的
buf - CPU 切回用户态,返回实际读到的字节数
第 4 步是关键:CPU 在磁盘转动时什么都干不了。这叫阻塞 I/O(Blocking I/O)——你的程序"卡住"了,傻等。
2. 为什么需要异步 I/O¶
阻塞 I/O 在单任务里没问题。但如果你的程序要同时处理一万个网络连接呢?
for (int i = 0; i < 10000; i++) {
char buf[1024];
int n = read(fd_array[i], buf, sizeof(buf)); // 阻塞
process(buf);
}
第 0 个客户端不返回,第 1~9999 个就永远等不到。不可行。
2.1 早期解法:多线程 / 多进程¶
每来一个连接,开一个线程处理:
问题:
- 每个线程占用 8 MB 左右栈空间。1 万连接 = 80 GB 内存,崩。
- 线程切换(context switch)开销不小——CPU 要保存/恢复寄存器、刷新缓存。
- 内核要维护每个线程的状态。
2.2 改进:select / poll / epoll¶
聪明的工程师们想到:别一个 fd 一个线程,让一个线程轮询多个 fd。
select(1980 年代)¶
fd_set readfds;
FD_ZERO(&readfds);
for (int i = 0; i < 10000; i++) FD_SET(fd_array[i], &readfds);
struct timeval tv = {5, 0};
int n = select(10001, &readfds, NULL, NULL, &tv); // 阻塞直到有 fd 就绪
工作机制:
- 把所有"想监听"的 fd 放进一个集合
select()把整个集合从用户态拷贝到内核态- 内核轮询每一个 fd,看数据到了没
- 没有就绪就阻塞
- 有任何一个就绪,就返回,把整个集合再拷回用户态
- 你遍历集合找出哪些 fd 就绪了
select 的问题:
fd_set大小有限制(通常 1024)- 每次都要把整个集合在用户态和内核态之间来回复制
- 内核每次都要线性扫描所有 fd——O(N) 复杂度
poll(1997)¶
把"位图"换成了"链表结构 pollfd",突破了 1024 限制,但仍然要把整个数组拷来拷去 + 线性扫描。
epoll(Linux 2.6, 2002 前后)¶
epoll 是质的飞跃:
int epfd = epoll_create1(0); // 创建一个 epoll 实例
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = fd_array[0];
epoll_ctl(epfd, EPOLL_CTL_ADD, fd_array[0], &ev); // 注册 fd(只拷一次)
while (1) {
struct epoll_event events[100];
int n = epoll_wait(epfd, events, 100, -1); // 等就绪事件
for (int i = 0; i < n; i++) {
// 处理 events[i].data.fd
}
}
关键改进:
| 改进 | 含义 |
|---|---|
| 注册一次,长期有效 | epoll_ctl 添加的 fd 在内核的红黑树里,不需要每次都传整个集合 |
| 就绪列表(ready list) | 内核把"已经就绪"的 fd 放进链表,epoll_wait 只返回就绪的 |
| 就绪回调(callback) | 当某个 fd 数据到达时,内核通过回调把它加入就绪链表,O(1) |
epoll 至今仍是 Linux 高并发网络编程的事实标准。Redis、Nginx 都用它。
但 epoll 仍然有缺点:
epoll_wait本身还是系统调用——每次都要进内核、出内核epoll只是"通知机制",告诉你 fd 就绪了,你还得自己再调一次read()——又一次系统调用- 数据要拷贝两次:磁盘 → 内核页缓存 → 用户缓冲区
- 缓冲区管理复杂:每个连接要自己维护收发缓冲区
3. io_uring 的诞生¶
2019 年,Linux 5.1 引入 io_uring,由 Jens Axboe(也是 fio、blk-mq 的作者)设计。目标:
彻底消除系统调用次数 + 消除数据拷贝 + 共享内存通信
核心思想一句话:
让用户态和内核态共享一块环形缓冲区(ring buffer),通过它提交 I/O 请求和收割 I/O 结果,全程不需要系统调用介入数据传递。
3.1 三大核心组件¶
| 组件 | 缩写 | 作用 |
|---|---|---|
| 提交队列(Submission Queue) | SQ | 你把想做的 I/O 请求("读这个文件"、"写那块内存")写到这里 |
| 完成队列(Completion Queue) | CQ | 内核把做完的 I/O 结果("读到了 100 字节")放到这里 |
| 环形缓冲区(Ring Buffer) | Ring | SQ 和 CQ 各自的存储结构,"环形"指首尾相接、可复用 |
环形缓冲区是经典的并发数据结构:一个固定大小的数组,有两个指针——head(写位置)和 tail(读位置)。tail 追上 head 就满了,head 追上 tail 就空了。空出来的位置可以继续复用。
3.2 与传统 I/O 的对比¶
| 维度 | 阻塞 I/O / epoll | io_uring |
|---|---|---|
| 每次操作需要系统调用次数 | read = 1,epoll = 1~2 | 提交和收割各 1 次(甚至 0 次,启用 SQPOLL 后) |
| 数据从内核到用户态 | 拷贝 | 不拷贝(共享内存) |
| 内核到用户态的"通知"方式 | 事件回调 + 二次系统调用 | CQ 直接写结果到共享内存 |
| 批量提交 | 需多次系统调用 | 一次提交多个 SQE |
4. io_uring 的内存布局(核心中的核心)¶
io_uring 通过 mmap() 把内核缓冲区直接映射到用户进程的虚拟地址空间。这样:
- 用户程序读写这段内存,不需要系统调用——CPU 还是用户态,但访问的是同一块物理内存
- 内核往这段内存写完成结果,也不需要"通知"用户态——它已经写好了,你随时能读到
io_uring 的"完整内存布局"通过三次 mmap 得到:
进程虚拟地址空间
┌────────────────────────────────────┐
│ │
│ 1. sq_ring(提交队列头) │ ← io_uring_setup 返回的 mmap
│ - SQ head, tail, ring_mask │
│ - 各数组长度 │
│ - flags │
│ │
│ 2. sqes(SQE 数组) │ ← 第二次 mmap
│ - 每个元素是一个 io_uring_sqe │
│ - 长度 = SQ entries × sizeof(sqe)
│ │
│ 3. cq_ring(完成队列) │ ← 第三次 mmap
│ - CQ head, tail, ring_mask │
│ - 实际 CQE 数据 │
│ │
└────────────────────────────────────┘
为什么要分三个?历史遗留 + 灵活性——让 SQ 和 CQ 可以独立大小、独立对齐。一般使用者不必深究,库(liburing)会帮你算好偏移。
4.1 关键字段:head、tail¶
这是 io_uring 最精妙的设计:
- SQ 的
head和tail:用户态只写tail,内核只读tail、写head - CQ 的
head和tail:内核只写tail,用户态只读tail、写head
规则(简化版):
提交一个 SQE:
sqe_array[tail & mask] = 你的请求
写内存屏障(store barrier)
SQ.tail++ ← 让内核看到"我又加了一个"
收割一个 CQE:
读内存屏障(load barrier)
while (head != CQ.tail) {
cqe = cq_array[head & mask]
处理 cqe
head++ ← 让内核看到"我处理完了"
}
CQ.head = head
这就是无锁环形队列(lock-free ring buffer)的经典做法:单生产者单消费者场景下,靠 head/tail 指针 + 内存屏障,不需要任何锁。
内存屏障(memory barrier / fence)的作用:防止 CPU / 编译器把你的写入"重排"到读取之前。先记住一句话:它是"告诉 CPU 不许乱序"的指令。具体为什么需要,到 6.4 节再展开。
5. SQE 与 CQE:你要"告诉"内核做什么、内核"告诉"你做了什么¶
5.1 SQE(Submission Queue Entry,提交条目)¶
你想让内核做什么,就填一个 SQE:
struct io_uring_sqe {
__u8 opcode; // 操作类型:读、写、accept、send...(IORING_OP_*)
__u8 flags; // 额外标志
__u16 ioprio; // I/O 优先级
__s32 fd; // 文件描述符(如果是文件 I/O)
__u64 off; // 文件内偏移
void *addr; // 缓冲区地址
__u32 len; // 长度
__u64 user_data; // 【关键】用户自定义 ID,收 CQE 时能认回
// ... 其他字段(链接、地址、splice 等)
};
最重要的字段:
opcode:操作类型。例如IORING_OP_READV= 读 scatter/gather,IORING_OP_ACCEPT= 接受连接user_data:你设的标签。提交时设 0x12345,完成时内核会原样回填。是"识别是哪个请求完成了"的关键fd/addr/len:操作参数
5.2 CQE(Completion Queue Entry,完成条目)¶
内核完成一个 I/O 后,在 CQ 写一个 CQE:
struct io_uring_cqe {
__u64 user_data; // 对应 SQE 里设的 user_data
__s32 res; // 结果:成功是字节数,失败是 -errno
__u32 flags;
};
res 是关键:
- 成功:
res = 实际读到的字节数(例如 1024) - 失败:
res = -EAGAIN、-EBADF、-EIO等
注意 user_data 是 64 位的,你可以把指针塞进去——把请求的内存地址写进去,CQE 回来时直接 cast 回原类型。
6. 完整工作流程¶
6.1 五步走¶
步骤 1: io_uring_setup(entries, params)
↓
内核创建 io_uring 实例,返回 fd
步骤 2: 三个 mmap() 拿到 SQ/CQ 共享内存地址
步骤 3: 准备 SQE → 放到 SQ 数组 → 更新 SQ.tail
步骤 4: io_uring_enter(ring_fd, to_submit, min_complete, flags, sig)
↓
【提交】告诉内核:"请处理 SQ 里的 N 个请求"
【收割】告诉内核:"我等至少 M 个完成"
↓
内核处理请求,结果写入 CQ
步骤 5: 遍历 CQ → 处理每个 CQE → 更新 CQ.head
6.2 一个具体例子:读文件¶
// 伪代码:io_uring 版的 "读 100 字节"
struct io_uring ring;
io_uring_queue_init(8, &ring, 0); // 创建,最多 8 个 in-flight 请求
int fd = open("hello.txt", O_RDONLY);
char buf[100];
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); // 拿一个空 SQE
io_uring_prep_read(sqe, fd, buf, 100, 0); // 填好 opcode=READ, fd, buf, len, off
sqe->user_data = 0xCAFE; // 打标签
io_uring_submit(&ring); // 步骤 4:提交
// 收割
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe); // 阻塞等至少 1 个完成
if (cqe->res > 0) {
// 读到了 cqe->res 字节,buf 里就是数据
}
io_uring_cqe_seen(&ring, cqe); // 步骤 5:告诉内核"我处理完了"
io_uring_queue_exit(&ring); // 清理
close(fd);
看起来和 read() 差不多?区别在哪?
io_uring_submit()可以一次提交多个 SQE——内核批处理它们,效率比多次read()高得多- 关键性能技巧:提前准备好一批 SQE,最后只调用一次
io_uring_submit(),内核一口气做完 - 启用 SQPOLL 模式(kernel polling)后,甚至不需要
io_uring_submit()——内核有专门的 worker 线程轮询 SQ 数组
6.3 一个更实际的例子:echo 服务器¶
echo 服务器 = 把客户端发来的数据原样发回去。
io_uring 版本思路:
while (1) {
// 1) 收割所有完成的 CQE
// 2) 对每个 CQE:
// - 如果是 ACCEPT 完成 → 拿到新 fd,注册一个 READ SQE
// - 如果是 READ 完成 → 处理数据,注册一个 WRITE SQE 把数据发回去
// - 如果是 WRITE 完成 → 注册下一个 READ SQE
// 3) 一次性提交所有准备好的 SQE
// 4) io_uring_wait_cqe_timeout() 等一会儿
}
每条连接的状态可以用 user_data 编码(高位是连接 id,低位是状态码),或者单独维护一张表。
6.4 关于内存屏障(更细致一点)¶
为什么 SQ.tail++ 之前需要 smp_store_release,CQ.head = ... 之后需要 smp_store_release?
考虑现代 CPU 的乱序执行:
时间线(理想顺序):
CPU0(用户态):写 SQE 数据 → 写 SQ.tail++
CPU1(内核态):读 SQ.tail → 读 SQE 数据
如果 CPU 乱序:
CPU0 把 SQ.tail++ 先做了,SQE 数据还没写完
CPU1 看到 SQ.tail 变了,去读 SQE,但读到的是旧数据或垃圾
store-release / load-acquire 指令保证:
- release 之后的写入,一定在 release 之前对其他 CPU 可见
- acquire 之后的读取,一定能看到 acquire 之前其他 CPU 的写入
先记住:io_uring 的无锁设计依赖内存屏障,否则会读到脏数据。具体怎么用 liburing 会帮你封装好。
7. 进阶特性(让你理解 io_uring 为什么强)¶
7.1 链表化 SQE(Linked SQEs)¶
默认每个 SQE 独立完成。Linked SQEs 让你可以把多个 SQE 串成一条链:
内核原子地执行这一组——要么全做完,要么全不做。典型场景:
- 文件拷贝:先 read 到临时缓冲区,再 write 出去
- 协议处理:先 read 头,再 read body,再 write 响应
7.2 固定文件 / 固定缓冲区¶
普通 read(fd, buf, len):内核每次都要查 fd 表、可能要刷新 TLB。
io_uring 提供 fixed files 和 fixed buffers:
- 提前把 fd 注册到 io_uring 内部表里 → SQE 里直接用"index"代替 fd
- 提前把缓冲区注册到 io_uring 内部 → SQE 里用 index 代替地址
- 内核可以预先 map,运行时省掉查找和映射开销
7.3 内核轮询(SQPOLL)¶
默认模式下,没有 I/O 请求时,内核不做事。要等用户态 io_uring_enter() 提交后,内核才开始处理。
SQPOLL 模式下,内核会启动一个 worker 线程,持续轮询 SQ 数组。看到新 SQE 立刻执行。
代价:内核多一个 100% CPU 占用的线程(可以绑定到一个空闲核)。
收益:完全消除 io_uring_enter() 系统调用——用户态提交 SQE 后不需要任何系统调用,内核已经主动在做。
7.4 IORING_OP_* 操作码¶
io_uring 不只是文件 I/O。它支持几十种操作:
| 操作码 | 含义 |
|---|---|
IORING_OP_READV / WRITEV |
scatter/gather I/O |
IORING_OP_ACCEPT |
接受连接 |
IORING_OP_CONNECT |
主动连接 |
IORING_OP_SEND / RECV |
网络收发 |
IORING_OP_POLL_ADD |
等某个 fd 就绪(类似 epoll_ctl) |
IORING_OP_OPENAT / CLOSE |
打开/关闭文件 |
IORING_OP_FSYNC / FDATASYNC |
刷盘 |
IORING_OP_SPLICE / TEECOPY |
零拷贝(在两个 fd 之间搬数据,不经用户态) |
IORING_OP_READ / WRITE |
普通读/写 |
基本上 read/write/send/recv/accept/connect/open/fsync 都能用 io_uring 提交。
8. 性能优势从何而来¶
| 优化点 | 节省了什么 |
|---|---|
| 共享内存 SQ/CQ | 省掉系统调用中参数拷贝(传统方式每次 read/write 都要把参数从用户栈拷到内核栈) |
mmap 共享内存 |
省掉 read 的数据拷贝(传统方式:磁盘→页缓存→用户缓冲区,io_uring 可以直接给内核页缓存指针) |
| 一次提交多个 SQE | 省掉多次系统调用(epoll + 多个 read/write = 多次进内核,io_uring 一次进内核批处理) |
| 链表化 SQE | 省掉内核态的逻辑切换(read/write 不再独立,预先编排好) |
| 固定文件 / 缓冲区 | 省掉 fd 查找 + TLB 刷新 |
| SQPOLL | 省掉所有提交系统调用 |
| 内核侧批处理 | 省掉锁竞争(内核可以一次性认领多个请求) |
实测:在 NVMe SSD + 高并发场景下,io_uring 可以达到传统 epoll + read/write 2~5 倍的 IOPS(每秒 I/O 数)。
9. 代码实践(最小可运行示例)¶
用 liburing(C 库)写一个最简示例:把 hello.txt 内容读出来打印。
9.1 准备¶
9.2 完整代码(40 行)¶
#include <stdio.h>
#include <fcntl.h>
#include <liburing.h>
int main(void) {
struct io_uring ring;
char buf[64];
// 1. 初始化 io_uring(队列深度 = 8)
if (io_uring_queue_init(8, &ring, 0) < 0) {
perror("io_uring_queue_init");
return 1;
}
// 2. 打开文件
int fd = open("hello.txt", O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
// 3. 拿一个空 SQE,填好"读"操作
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0); // 偏移 0
sqe->user_data = 0x1234; // 打个标签
io_uring_submit(&ring); // 提交
// 4. 收割
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe); // 等完成
if (cqe->res < 0) {
fprintf(stderr, "read 失败: %s\n", strerror(-cqe->res));
} else {
buf[cqe->res] = '\0'; // res = 实际读到的字节数
printf("读到 %d 字节: %s\n", cqe->res, buf);
}
io_uring_cqe_seen(&ring, cqe); // 告诉内核"我处理了"
// 5. 清理
close(fd);
io_uring_queue_exit(&ring);
return 0;
}
9.3 逐步执行过程¶
| 步骤 | 内存里发生什么 |
|---|---|
io_uring_queue_init |
内核分配 ring,mmap 出 SQ/CQ 共享内存 |
io_uring_get_sqe |
返回 SQ 数组里下一个空闲位置的指针 |
io_uring_prep_read |
填好 SQE 的 opcode/fd/addr/len/off |
io_uring_submit |
更新 SQ.tail,调用一次 io_uring_enter() |
| 内核 | 看到新 SQE,从磁盘读数据,把结果写到 CQ |
io_uring_wait_cqe |
看 CQ.tail 有变化没,没有就阻塞 |
| 拿到 CQE | cqe->user_data == 0x1234,cqe->res == 实际字节数 |
io_uring_cqe_seen |
更新 CQ.head,通知内核 |
9.4 预期输出¶
9.5 动手练习¶
- 改成写文件:用
io_uring_prep_write把一个字符串写入out.txt - 批处理:循环提交 5 个读不同文件的 SQE,只调用一次
io_uring_submit(),然后用io_uring_wait_cqe收割 5 个 - 混合操作:提交 1 个
READ和 1 个FSYNC,看 CQE 回来的顺序是否对应 user_data
9.6 常见坑¶
| 症状 | 原因 | 修复 |
|---|---|---|
| 读到的内容是脏数据 / 全 0 | 没等 CQE 就访问 buf | 必须在 io_uring_wait_cqe 之后才读 buf |
提交报错 EBUSY / EAGAIN |
SQ 满了(in-flight 太多) | 增大 queue depth,或先收割几个 CQE 再提交 |
收割时 cqe->res == -ECANCELED |
内核取消了请求(多见于 syscall 中断) | 检查请求合法性;如果是中断导致,可重提 |
io_uring_queue_init 返回 EFAULT |
内核版本太老(< 5.6 缺 io_uring) | 升级内核 |
| 性能提升不明显 | 每次只提交 1 个 SQE | 关键在批处理:攒一批再提交 |
10. 与邻近概念的关系¶
| 概念 | 与 io_uring 的关系 |
|---|---|
| epoll | 通知机制(哪个 fd 就绪了),仍需要 read/write 系统调用;io_uring 是完整 I/O 提交接口 |
| libaio(Linux AIO) | 老的异步 I/O 接口,只支持 direct I/O 的 O_DIRECT 文件,不支持网络;io_uring 是它的现代替代 |
POSIX AIO (aio_read) |
用户态线程模拟的"伪异步",性能差;io_uring 是真正的内核异步 |
| Windows IOCP | 思路类似(共享队列 + 内核通知),Windows 专有;io_uring 是 Linux 的等价方案,且 API 更现代 |
| DPDK / SPDK | 旁路内核、用户态驱动,极致性能(数百万 IOPS),但要独占硬件、专用驱动;io_uring 仍走内核,适合通用场景 |
11. 常见误解¶
| 误解 | 事实 |
|---|---|
| io_uring 一定比 epoll 快 | 不一定。单次小 I/O 场景差距不大;io_uring 在批处理 + 高并发下优势明显 |
| io_uring 会让应用代码更简单 | 也不一定。复杂应用仍要自己管理 SQ/CQ 状态机;好处是性能上限更高 |
| io_uring 完全不需要系统调用 | 默认需要 io_uring_enter;启用 SQPOLL 模式后才真正"零系统调用" |
| io_uring 只支持文件 I/O | 也支持 socket、accept/connect、open、fsync、poll 等,范围很广 |
| io_uring 是"魔法"自动并行 | 并行由内核决定。批处理只是把"多个独立 I/O"打包提交,让内核一起做 |
12. 总结¶
io_uring 用三个核心设计解决了 Linux 异步 I/O 的痛点:
- 共享内存(mmap):用户态和内核态共享 SQ/CQ 缓冲区,省掉参数和数据拷贝
- 环形队列 + 无锁协议:用 head/tail 指针 + 内存屏障实现高效的请求/完成匹配
- 一次系统调用处理多个请求:批处理 + 内核轮询,最大化吞吐
知识要点卡片:
io_uring
├─ 数据结构:SQ(提交队列) + CQ(完成队列) + ring buffer
├─ 通信方式:mmap 共享内存,无锁单生产者单消费者协议
├─ 接口:io_uring_setup / io_uring_enter / io_uring_register
├─ 关键 SQE 字段:opcode / fd / addr / len / off / user_data
├─ 关键 CQE 字段:user_data / res(结果)
├─ 进阶:Linked SQE / 固定文件 / 固定缓冲区 / SQPOLL
└─ 性能优势:减少系统调用、零数据拷贝、批处理、内核侧并行
13. 探索路径¶
- 直接延伸 — liburing 高级 API:学
io_uring_prep_accept/io_uring_prep_send/io_uring_prep_splice,写一个完整的 echo 服务器 - 横向拓展 — io_uring 在数据库/存储引擎中的应用:例如 RocksDB、TiKV 用 io_uring 替换 libaio 获得性能提升
- 理论深挖 — 无锁环形队列的实现:研究
kernel/io_uring.c里 SQ/CQ 的内存屏障细节,理解为什么单生产者单消费者能避免锁 - 实践项目 — 用 io_uring 重写你的 epoll 程序:把现有的网络服务从 epoll 迁移到 io_uring,对比 IOPS 和延迟
- 开放问题 — io_uring 与 io_uring-cmd 的边界:Linux 内核仍在扩展 io_uring,未来可能覆盖更多子系统(如网络协议栈 bypass)