前阵子在给一个网关服务做压测,单机连接数两万出头,数据包转发链路里 read -> parse -> write 全是同步I/O。压到一定量级之后CPU占用居高不下,延迟抖动也明显。我一开始还想靠调大线程池硬扛,后来仔细算了一笔账:每当一个连接上有数据进来,内核要把线程唤醒、拷贝数据、切回用户态,处理完再睡回去,这一来一回的上下文切换成本,在高并发场景下比业务逻辑本身还高。后来我把I/O路径整体换到 io_uring,CPU占用直接降了三成左右,延迟曲线也平滑了很多。这篇文章就把我从原理到实战、踩坑的完整过程捋一遍。
io_uring 是 Linux 内核 5.1 开始正式引入的异步I/O接口,作者是 Jens Axboe。它通过“用户态与内核态共享环形队列”的方式,把提交 I/O 请求和收割完成结果这两件事都变成了内存读写操作,配合 io_uring_enter 系统调用批量提交,彻底绕开了传统同步I/O那套“发起一次系统调用就阻塞一次”的老路子。如果你在用 epoll + 线程池、libaio 做高并发读写,或者只是好奇新一代异步框架怎么做到百万级 IOPS,这篇内容都值得看完。
1. 传统I/O瓶颈与io_uring的破局点
1.1 同步阻塞、epoll 和 libaio 各自差在哪
先说同步阻塞I/O。read(fd, buf, size) 这行代码一旦执行,如果数据没准备好,线程就去睡了,内核把线程挂到等待队列上,等数据到达再唤醒它。对单个线程来说,这个模型特别直观;但对高并发服务来说,一个线程伺候一个连接,连接数一多,线程数就得跟着涨,然后上下文切换开销就爆炸了。
所以很多人换成了 epoll 加线程池:epoll_wait 只负责告诉你“某个fd可读了”,真正的 read 还是同步的,而且必须在线程里执行。数据量再大一点,你会发现瓶颈从“等待事件”挪到了“执行read时的阻塞”上——如果一个线程从 epoll 里拿到50个连接有数据要读,它也只能一个接一个地 read,任何一个 read 因为没有完整数据到达而阻塞,后面的都得等。
后来有了 libaio(也就是 io_submit/io_getevents),Linux 总算有了真正意义的异步I/O接口。但你只要上手用一下就会明白为什么没人拿它大规模做网络服务:它需要 O_DIRECT 支持,缓冲区要对齐,文件系统支持还参差不齐,稍不注意就变成同步提交;而且 io_getevents 在等待完成事件时是系统调用,批量事件处理也有限制。用起来非常别扭,远不如 io_uring 顺手。
1.2 io_uring 的三板斧:环形队列、批量提交、内核侧轮询
io_uring 解决上述问题的思路很直接:不再让用户态和内核态之间“你一次我一次”地互相调用,而是干脆在 mmap 出来的共享内存里搭好两个环形队列。
- SQ(Submission Queue):用户态往环形队列里写入要执行的操作,比如“从fd 8读取4096字节到buf_A”。
- CQ(Completion Queue):内核完成操作后,把结果写到这个环形队列,用户态消费即可。
- io_uring_enter:用户态提交一批请求后,用这个系统调用通知内核去处理,一次通知对应一批任务,把系统调用次数摊薄。
这三板斧合起来的效果是:用户态不再为每个I/O操作单独发起一次系统调用(传统 read 一次操作一次调用,epoll 模式下至少也要 epoll_wait 加 read 两次调用);内核执行完的完成结果直接在共享内存里可见,用户态可以不通过系统调用就拿到结果。
除此之外,io_uring 还带了一个很关键的可选功能:SQPOLL 模式。在这种模式下,内核里有个专门的内核线程去轮询 SQ 队列,只要发现用户态提交了新请求,立刻就开始干活。也就是说,连 io_uring_enter 都不用调了,用户态只做“往队列里写描述符 + 读完成状态”这两件纯内存操作,系统调用开销理论上可以做到近乎为零。
用生活里的类比:传统同步I/O像你去银行柜台办业务,排队、窗口处理、办完再走,一个窗口同一时间只能服务一个人;epoll 是有个叫号系统,它能告诉你哪几个窗口有人空闲,但具体办事还是要一个个柜台跑;io_uring 则是你直接把单子塞进一个共享的收件箱里,柜台那边自己来拿单子,办完了放回另一个共享箱里,你随时过来取回执就行。工作流程从“排队+等待”变成了“投递+自取”,这个差别在高并发场景下是实打实的性能差距。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心机制拆解:共享内存队列是如何工作的
2.1 SQ/CQ 环形队列与 mmap 的原理
要让用户态和内核态都能读写同一个队列,最干净的办法就是内存映射。io_uring 初始化时调用 io_uring_setup,然后通过 mmap 把内核里分配的 SQ、CQ 以及一个 SQ 数组(里面存放具体请求的索引、优先级等元数据)映射到进程地址空间。
我们稍微看下内核里的数据结构逻辑:io_uring 实例里有一个 struct io_ring_ctx,它包含 SQ ring 和 CQ ring,两者都是环形缓冲区。SQ ring 里的条目指向另一个数组 sqe[],每个 sqe 是 struct io_uring_sqe,里面存的是操作码(OP_READ、OP_WRITE、OP_ACCEPT 等)、fd、缓冲区地址、偏移量、标志位等。用户态要提交任务,实际上是往 sqe[] 里填充一个结构体,然后在 SQ ring 里写入这个 sqe 的数组下标,再把 SQ ring 的 tail 指针往前移。内核看到 head 和 tail 不一致,就知道有活干了。
CQ ring 里放的是 struct io_uring_cqe,包含 res(返回值)和 user_data(用户自定义标识,通常用来关联到具体请求)。用户态消费完成事件时,把 CQ ring 的 head 指针往后移,这个事件就算处理完了。这里有两个头尾指针分别由用户态和内核态控制,谁往哪个方向移动都是有明确约定的,不能写反。
由于这套队列用的是共享内存,就绕开了传统系统调用里“用户态缓冲区拷贝到内核态缓冲区”的拷贝过程。请求描述本身不拷贝,执行结果也不拷贝,数据还在你提供的用户态缓冲区里。内核做 read 时直接把数据写到 mmap 或普通用户态缓冲区(具体取决于是否用了 IORING_SETUP_IOPOLL/注册缓冲区等模式),你拿到回来就能直接用,内存拷贝开销大幅下降。
2.2 io_uring_enter 怎么做到“一次调用,处理一批”
io_uring_enter(fd, to_submit, min_complete, flags) 是驱动整个流程的心脏。这里有三个关键参数:
to_submit:告诉内核,本次调用希望它从 SQ 里取多少个请求去处理。min_complete:告诉内核,本次调用至少等几个完成事件再返回。设为 1 时就相当于传统阻塞读,设 0 则是纯提交不等待。flags:可带IORING_ENTER_GETEVENTS等标识,告诉内核“提交完这批顺便等完成”。
这个设计的巧妙之处在于,它把“提交”和“等待完成”揉进了同一个系统调用。你可以在一个循环里先填好 128 个读请求,然后调用一次 io_uring_enter 传 to_submit=128,内核一股脑全处理掉。如果你的业务允许并行处理多个独立读操作,这个批量提交带来的收益是立竿见影的。
当使用 SQPOLL 模式时,系统调用次数进一步压缩。内核线程会自己盯着 SQ 队列,发现 tail 变了就立刻开始消费,用户态几乎不需要 io_uring_enter。代价是 CPU 得常驻一个poll线程在跑,即使没有请求也有一定CPU占用,所以它更适合持续有大量I/O请求的忙碌场景,不适合空闲时段长的低频服务。
2.3 liburing 帮你封装了什么
直接对着 io_uring_setup 和那几个 mmap 去写代码,不仅繁琐而且容易踩坑。官方配套的 liburing 库把这些底层操作全部包了一层。你不需要自己计算环形队列的槽位索引、不需要手动写内存屏障、不需要管理 mmap 出来的各个内存区域。常用 API 就几个:
io_uring_queue_init:创建并初始化一个 ring。io_uring_get_sqe:从 SQ 里拿一个空闲的请求槽位。io_uring_prep_read/io_uring_prep_write/io_uring_prep_accept:填充sqe的操作类型和参数。io_uring_submit:把当前 SQ 里所有已填充的请求一次性提交给内核。io_uring_wait_cqe/io_uring_peek_cqe:等待/查看完成事件。io_uring_cqe_seen:标记这个完成事件已经处理完,让 CQ 队列可以继续转动。io_uring_queue_exit:清理整个 ring。
我自己在实际项目里一直是 liburing + 直接操作 sqe/cqe 这种组合,既能享受封装带来的省心,又能在需要精细控制(比如设置 IOSQE_IO_LINK 或 flags)时不至于被封装挡住。
3. 从零写一个io_uring异步读程序
3.1 环境准备与编译依赖
先确认内核版本。io_uring 从 5.1 开始支持,但很多高级特性(比如注册缓冲区、IORING_SETUP_SQPOLL 的加强版、IORING_OP_URING_CMD 等)是后来的版本才有的。我用的是 6.1 内核,建议至少 5.19 以上,这样遇到奇奇怪怪的行为差异会少很多。
然后是 liburing。有的发行版自带,没有的话自己编一下也不麻烦:
bash复制git clone https://github.com/axboe/liburing.git
cd liburing
./configure
make -j$(nproc)
sudo make install
sudo ldconfig
编译程序的时候直接 gcc -O2 -o uring_demo uring_demo.c -luring 就行。如果 liburing 装到了非标准路径,记得加 -I 和 -L 参数。
3.2 单请求版本:创建环、提交读、等待完成
下面这个例子是最小可执行的异步读文件流程。它做的事情是打开一个文件,发一个异步读请求,把 4096 字节读进缓冲区,然后等待完成事件打印内容。
c复制#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <liburing.h>
#define BUF_SIZE 4096
int main(void) {
struct io_uring ring;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
char *buf = NULL;
int fd, ret;
if (posix_memalign((void **)&buf, 4096, BUF_SIZE) != 0) {
perror("posix_memalign");
return 1;
}
memset(buf, 0, BUF_SIZE);
fd = open("/tmp/io_uring_demo.txt", O_RDONLY);
if (fd < 0) {
perror("open");
free(buf);
return 1;
}
ret = io_uring_queue_init(64, &ring, 0);
if (ret < 0) {
fprintf(stderr, "io_uring_queue_init: %s\n", strerror(-ret));
close(fd);
free(buf);
return 1;
}
sqe = io_uring_get_sqe(&ring);
if (!sqe) {
fprintf(stderr, "io_uring_get_sqe failed\n");
return 1;
}
io_uring_prep_read(sqe, fd, buf, BUF_SIZE, 0);
io_uring_submit(&ring);
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
fprintf(stderr, "io_uring_wait_cqe: %s\n", strerror(-ret));
return 1;
}
if (cqe->res < 0) {
fprintf(stderr, "async read failed: %s\n", strerror(-cqe->res));
} else {
printf("read %d bytes: %s\n", cqe->res, buf);
}
io_uring_cqe_seen(&ring, cqe);
io_uring_queue_exit(&ring);
close(fd);
free(buf);
return 0;
}
几个关键点解释一下:
io_uring_queue_init(64, &ring, 0)第三个参数0表示默认模式,不开 SQPOLL、不开 IOPOLL。队列深度 64 表示 SQ/CQ 最多同时容纳 64 个请求,这个深度要根据业务模型来调,太大浪费内存,太小容易在突发请求时让io_uring_get_sqe拿不到槽位。io_uring_prep_read(sqe, fd, buf, BUF_SIZE, 0)最后一个0是偏移量。这里必须用posix_memalign做对齐吗?默认模式下不一定,但如果你后面用 O_DIRECT 或注册缓冲区,对齐就不是可选项而是硬性要求。我习惯从一开始就对齐,省得临时改。cqe->res是完成事件的返回值。res >= 0表示实际读到的字节数,res < 0表示错误码(注意是负的errno),拿strerror(-cqe->res)就能翻译成可读的错误信息。io_uring_cqe_seen必须在处理完cqe后调用,否则 CQ ring 的 head 一直不前进,积压的完成事件会越来越多,最终 ring 被撑满,内核没法写入新的完成事件。
这里能看到一个与传统 read 截然不同的事:io_uring_submit 返回后,你的线程完全没有被阻塞,可以继续干别的;等你需要这个数据了,再调用 io_uring_wait_cqe 等结果。
3.3 批量提交版本:把系统调用开销摊薄
如果一个程序同时需要读 100 个文件块,同步模式下就是 100 次系统调用串行等待;io_uring 的正确打开方式是:一次性提交 100 个读请求,然后统一等结果。下面这段是批量提交的核心逻辑,我直接抄了实际项目里摘出来的简化版。
c复制struct io_uring ring;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
struct user_data {
int fd;
char *buf;
size_t len;
} reqs[128];
io_uring_queue_init(128, &ring, 0);
for (int i = 0; i < 128; i++) {
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, reqs[i].fd, reqs[i].buf, reqs[i].len, 0);
io_uring_sqe_set_data(sqe, &reqs[i]);
}
io_uring_submit(&ring);
for (int i = 0; i < 128; i++) {
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
// 处理错误
break;
}
struct user_data *ud = io_uring_cqe_get_data(cqe);
if (cqe->res < 0) {
fprintf(stderr, "read fd=%d failed: %s\n", ud->fd, strerror(-cqe->res));
} else {
// 处理 ud->buf 中的数据
}
io_uring_cqe_seen(&ring, cqe);
}
这里值得说的是,io_uring_sqe_set_data 和后续的 io_uring_cqe_get_data 是请求和完成事件之间关联的桥梁。因为批量提交的完成顺序不一定和提交顺序一致,所以你必须在 sqe 里塞进自己的上下文(比如原始 fd、缓冲区地址、回调函数指针),等 cqe 回来时通过 user_data 找回“这个是哪个请求、要处理什么”。
批量模式下,128 个读操作只产生 1 次 io_uring_enter 系统调用(io_uring_submit 内部会调)。这就是 io_uring 能打高吞吐的直接原因之一:系统调用次数从 O(N) 降到了 O(1) 量级,剩下的全是纯内存写队列操作。
3.4 阻塞等待与轮询等待怎么选
io_uring_wait_cqe 是阻塞等待,适合“当前没有别的活干,就等这批I/O完成”的场景。如果请求特别快,比如本地 SSD 读,阻塞等待反而有一点点系统调用/调度开销。此时可以用 io_uring_peek_cqe 配合短小的自旋循环,先检查 CQ 里有没有现成的完成事件,有就立刻消费,没有再去 wait_cqe 阻塞:
c复制while (1) {
cqe_ptr = NULL;
ret = io_uring_peek_cqe(&ring, &cqe_ptr);
if (ret == 0 && cqe_ptr) {
// 处理完成事件
io_uring_cqe_seen(&ring, cqe_ptr);
processed++;
if (processed == need_count) break;
} else {
io_uring_wait_cqe(&ring, &cqe);
io_uring_cqe_seen(&ring, cqe);
processed++;
if (processed == need_count) break;
}
}
这种“先轮询再看阻塞”的思路,很像 epoll 里的 epoll_wait(timeout=0) 加忙轮询的组合。它适合在低延迟场景里减少阻塞唤醒的损耗,但也要注意别在轮询里耗费太多 CPU。实际项目里我会只在延迟敏感路径上用这个模式,普通场景直接 wait_cqe 更省事。
4. 性能调优:让io_uring真正“跑起来”
4.1 IORING_SETUP_SQPOLL:内核轮询与CPU占用权衡
如果你把 io_uring_queue_init 的 flags 改成 IORING_SETUP_SQPOLL,内核会为这个环创建一个专门的 io_uring-sq 内核线程,它不停轮询 SQ 队列,消费用户态塞进来的请求。这样用户态甚至不必调用 io_uring_enter,只需把 sqe 写进队列、刷新 tail,内核线程自己会看见。
我实际压测中的经验是,SQPOLL 在持续高负载下的收益非常可观,尤其适合上万 IOPS 的场景。比如同样是批量小文件读,我测试里默认模式的CPU占用率明显高于SQPOLL模式,因为默认模式频繁切换内核线程去做队列消费,而SQPOLL把消费行为固定在一个常驻线程上,业务线程可以腾出精力处理别的。
但它有副作用:
- 占用一整颗 CPU 核心,即使业务空闲也在轮询。
- 需要 CAP_SYS_ADMIN 权限(或者通过
/sys/kernel/io_uring/sq_thread_pid之类的机制去调整某些参数),没有权限时初始化会失败。 - 如果应用本身请求频率很低,常驻轮询线程的浪费比省下的系统调用还大。
所以我的结论是:不是所有场景都适合 SQPOLL。低延迟、高吞吐、请求密集的场景可以开;低频请求、事件驱动型场景用默认模式反而更稳。
4.2 注册文件与缓冲区:减少元数据拷贝
还有一个很能提升吞吐量的点:io_uring_register_files 和 io_uring_register_buffers。
先说注册文件。默认每次提交读/写请求时,内核都要根据 fd 做文件引用计数等处理;而通过注册表把一堆 fd 一次性批量注册给 ring 之后,sqe 里的 fd 字段就可以填注册表索引而非真实 fd,提交路径上就少了查找引用等开销。这对频繁读写多个 fd 的服务(比如网络代理、文件网关)很实用。
c复制int reg_fds[2] = {fd1, fd2};
io_uring_register_files(&ring, reg_fds, 2);
// 之后提交请求时:
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0, buf, len, 0); // 这里的0不是fd,是注册表下标
sqe->flags |= IOSQE_FIXED_FILE;
注册缓冲区也是一样的思路。默认模式下内核在做 I/O 时可能要处理用户的页表、固定内存等;而注册缓冲区会让内核提前知道用户提供的内存区域有哪些、对应哪些页,并把它们钉在内存里。之后提交读/写请求时用 io_uring_prep_read_fixed,内核直接使用这个固定映射,省去反复做页表映射的功夫。
c复制struct iovec iov = {
.iov_base = buf,
.iov_len = BUF_SIZE,
};
io_uring_register_buffers(&ring, &iov, 1);
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, buf, BUF_SIZE, 0, 0);
注意,注册缓冲区意味着这些内存区域会被锁定在物理内存里,不能随便换页出去,所以 RLIMIT_MEMLOCK(内存锁定额度)不够时 register_buffers 会直接失败。生产环境需要适当调高该限制值。千万不要在固定缓冲区上频繁重复注册/注销,这个操作本身就有点贵。
4.3 从存储扩展到网络场景
很多人一提到 io_uring 就只想到文件读写,其实它最惊艳的地方是把网络I/O也统一进来了。IORING_OP_ACCEPT、IORING_OP_RECV、IORING_OP_SEND、IORING_OP_CONNECT 这些操作码都支持。你在 sqe 里填 socket fd、填收发缓冲区,提交给内核后它就异步地等数据、收发数据,完成后往 CQ 里写 res。
对于代理类服务、网关类服务、自定义协议服务器,这意味着一个线程可以同时管理上千条连接的所有收包行为,不再需要 epoll 事件循环外加线程池去处理什么可读/可写回调。发出多个 recv 请求后,哪个先完成就处理哪个,这种模型天然支持异步和流控。
实际操作上,我踩过的一个小坑是:io_uring_prep_recv 如果提前给每个连接分配固定缓冲区,连接数一多内存开销会很大。更好的方案是用 IO_URING_PREP_PROVIDE_BUFFERS(也就是 IORING_OP_PROVIDE_BUFFERS)把一批缓冲区预供给 ring,内核收到网络数据后自动选择其中一个装数据,然后通过 cqe 里的 flags 字段告诉你用的是哪个 buffer。这套机制非常适合网络服务。内核 6.0 之后还进一步增强了 IORING_OP_RECV 的 multishot 模式,一次提交可以循环接收多次数据,减少重复提交请求的开销。
5. 实战中的坑与排查清单
5.1 RLIMIT_MEMLOCK 导致 mmap 失败
我第一次写 io_uring 示例程序时,io_uring_queue_init 就报错 Cannot allocate memory。排查了半天发现不是系统内存真的不够,而是每个 ring 实例都会通过 mmap 锁定一部分内存,内核会计入进程的 RLIMIT_MEMLOCK。容器环境或某些发行版默认这个值很小。
解决办法是提高限制,临时用 ulimit -l unlimited,长期建议在 systemd unit 或 sysctl 配置里调大。需要留意的是,如果程序里还调了 io_uring_register_buffers,锁页需求会成倍增长,限制得更宽裕些。
5.2 cqe->res 返回 -EAGAIN 与内核版本兼容
如果你的 res 里跑出来一个 -EAGAIN,含义是“这个请求这次没办成,但可以重试”。在普通阻塞 I/O 里 EAGAIN 很少见,但 io_uring 里可能是以下原因:
- 使用了 O_DIRECT 但数据没对齐。
- 用
IORING_SETUP_IOPOLL但设备不支持轮询。 - 内核内部某些资源暂时不足(比如固定缓冲区不够用)。
- 请求需要操作的文件不支持异步。
处理方式通常是重新 io_uring_prep_* 再提交一次,或者降级到普通路径重试。我在代码里一般会写一个重试计数器,连续失败多次就退回同步路径,避免死循环。
另一个经验是,同一种操作在不同内核版本上的支持度差异很大。比如旧内核里 IORING_OP_URING_CMD 可能直接返回 EOPNOTSUPP。上线前一定先确认目标内核版本,最好在程序里用 io_uring_queue_init 成功与否、以及针对特定 opcode 的 io_uring_prep_* 返回值来判断特性是否存在。
5.3 提交后缓冲区生命周期与内存屏障
用 io_uring 时,一个很常见的错误是:提交读请求之后,业务代码马上去读写那个缓冲区。你要知道这是一个“异步”操作,内核什么时候往这个缓冲区里写数据完全不确定。你只能在对应的 cqe 回来之后访问缓冲区。如果提前用,轻则读到乱数据,重则内存竞争导致程序崩溃。同样,如果请求是 write,缓冲区内容在 cqe 回来之前都不能修改或释放。
至于内存屏障,liburing 内部已经帮你做了必要的 smp_mb() 之类的同步,你不需要自己手工加,但理解原理很重要:SQ 的 tail 更新和 CQ 的 head 更新必须保证跨 CPU 可见,否则用户态写了一半内核就开始读,会出现半初始化请求。用 liburing 的 API 就好,别自己去动那些内部队列指针。
5.4 清理与关闭的注意事项
io_uring_queue_exit 会销毁 ring,释放相关资源。这里要注意的是,坏掉或没有等待的请求也一起丢弃了。如果你还有未等待的 cqe 没消费,强行 exit 属于未定义行为,可能让内核处于不一致状态。我在生产代码里都是先循环消费所有 cqe,再 exit。
还有一个细节:io_uring_queue_exit 只是清理 ring,不会自动关闭你在 sqe 里引用的那些 fd。fd 的关闭必须自己做,顺序上建议先等所有 I/O 完成、再退出 ring、再 close fd,以免 I/O 还在进行时 fd 被复用。我曾因为提前 close fd 导致内核读到了另一个复用 fd 的数据,排查了半小时。
5.5 常见错误速查表
| 现象 | 可能原因 | 快速解法 |
|---|---|---|
io_uring_queue_init 返回 -ENOMEM |
RLIMIT_MEMLOCK 太小 |
调大限制,或减小 ring 深度 |
io_uring_register_buffers 失败 |
内存锁定额度不足 | ulimit -l 增大,或减少注册缓冲区大小 |
cqe->res == -EINVAL |
opcode 在当前内核不支持,或参数不合法 | 检查内核版本,尝试去掉高级 flags |
cqe->res == -EAGAIN |
资源暂时不足或 O_DIRECT 对齐问题 | 对齐缓冲区,重试,或退同步路径 |
| 程序运行一段时间内存涨很多 | 注册缓冲区内存被锁住 | 复用注册缓冲区,不要频繁注册/注销 |
| SQPOLL 模式下 CPU 高 | 常驻内核轮询线程占用 | 改为默认模式,或用空闲低负载的阈值判断 |
| 数据文件明明有内容但 read 返回 0 | 用 O_DIRECT 但偏移/对齐没弄好 | 检查对齐和偏移,或者去掉 O_DIRECT |
io_uring_wait_cqe 死等 |
请求没被提交,或堵在了 SQ 队列里 | 确认调用过 io_uring_submit,检查 SQ tail |
从我个人经验来看,io_uring 带来的性能提升虽然是核心卖点,但它更大的价值是让 I/O 编程模型变得更清晰:你只管把要做的事描述好交给队列,然后组织好返回的数据处理逻辑,不需要到处和“线程阻塞”“事件回调”较劲。当然,它也不是没有学习成本,底层原理不懂的话,遇到 EAGAIN、内存锁页、IOSQE_IO_LINK 链接请求这类东西会非常懵。
如果现在让我回到那台网关服务器的优化现场,我会把方案做得更彻底——不只是把 read/write 换成异步,还应该把所有 accept、close、收发操作的 sqe 统一走同一个 ring,甚至把比较敏感的超时控制也通过 IORING_OP_TIMEOUT 挂上去,让整个服务的主循环真正变成“投递请求 + 消费完成”的单一模型。这也正是 io_uring 最值得反复玩味的地方:它不仅是一次接口升级,而是一整套 I/O 处理思路的更新换血。
