大家好,我是内核喵。
在很多后端面试或者架构分享中,大家张口闭口就是「epoll 性能天花板」「Reactor 模式解千愁」。每次听到这个我都很想较真:epoll 充其量只是个事件通知机制(Notification Engine),它根本不算真正的异步 I/O!
当请求量被推到数十万 QPS,或者面对磁盘 I/O 场景时,传统的同步系统调用带来的 Context Switch 开销和内核数据搬运,早就成了 CPU 上的「热点钉子户」。
今天我们不聊概念上的假大空,直接从内核调用链路、Ring Buffer 设计以及 perf / strace 的真实观测出发,聊聊 Linux I/O 是如何一步步进化到现在的终极形态——io_uring 的。
一、 演进之路:epoll 的荣光与 Disk I/O 的硬伤
回顾 Linux 的网络与存储模型演进,本质上是在解决两个问题:
- 别让 CPU 傻等(Non-blocking)
- 减少无意义的上下文切换与系统调用开销(Syscall Overhead)
1. epoll 治好了网络,但治不好磁盘
在 Linux 中,epoll 属于 Readiness-oriented(就绪通知) 模型:它告诉你「Fd 准备好读了」,你依然得亲自调用一次 read(2) 或 recv(2) 把数据从内核空间拷到用户空间。
这意味着每次 I/O 至少两次系统调用:一次等通知(epoll_wait),一次做操作(read)。在 Spectre / Meltdown 熔断幽灵补丁引入 KPTI(内核页表隔离)后,每一次 Syscall 的 CPU 惩罚更加严重。
更致命的是 Disk I/O。在 Linux VFS 层,普通文件的 read/write 根本不支持 epoll。因为内核设计者认为普通文件随时可以从 Page Cache 读写,默认认为它「永远就绪」。一旦发生 Page Fault 需要读盘,主线程就会在内核中被生生卡死(D 状态)。
2. Linux AIO (io_submit) 的历史烂摊子
有人说,不是还有 POSIX AIO 和原生 Linux AIO(libaio)吗?
了解内核的同学都知道,老 Linux AIO 简直是一言难尽:
- 必须指定
O_DIRECT绕过 Page Cache(对普通文件不友好); - 某些场景(比如文件扩容、元数据更新锁
i_rwsem)仍会退化为同步阻塞; - 依然需要每次显式调用
io_submit与io_getevents,Syscall 开销一个不少。
直到 Linux 5.1 引入、并在 5.4+ 逐步完善的 io_uring 登场,才算真正补齐了这块拼图。
二、 降维打击:io_uring 核心架构解析
Jens Axboe 搞出 io_uring 时,核心哲学非常清晰:真正的 Proactor(完成通知) + 零系统调用的极限吞吐。
1. 双无锁环形队列 (SQ & CQ)
io_uring 在用户态与内核态之间建立了两个基于 mmap 的无锁环形缓冲区(Ring Buffer):
- SQ (Submission Queue):提交队列。用户态把想执行的操作(Read, Write, Accept 等)打包成 SQE (Submission Queue Entry) 写入这里。
- CQ (Completion Queue):完成队列。内核处理完任务后,将结果封装成 CQE (Completion Queue Entry) 写回这里,用户态消费即可。
+-------------------------------------------------------------+
| User Space |
| [SQE] -> 写入 Submission Queue |
| 读取 CQE <- [CQE] |
+------------------------------+------------------------------+
| mmap (Zero-Copy Ring Buffer)
+------------------------------+------------------------------+
| Kernel Space |
| 内核消费者 <- 处理 SQE |
| 写回 CQE -> [CQE] |
+-------------------------------------------------------------+
由于两块 Ring Buffer 是共享内存映射的,提交任务和获取结果完全不需要内核与用户态之间的内存复制,指针更新仅依赖内存屏障(Memory Barrier)。
2. 杀手锏:SQPOLL (Submission Queue Polling)
在传统模式下,你把 SQE 推入队列后,依然需要调用 io_uring_enter(2) 告诉内核一声。
但如果开启了 IORING_SETUP_SQPOLL 标志,内核会专门拉起一个内核线程(io_uring-sq)死循环轮询 SQ。
这意味着什么?你在用户态塞任务,内核态默默吃任务,完全不需要任何一次系统调用(Syscall = 0)!
三、 实战:基于 liburing 的高并发读取
原生使用 io_uring_setup(2) 比较繁琐,生产中强烈推荐使用作者亲自封装的 liburing。下面给出一个最简但具有工业意味的并发读文件示例:
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <liburing.h>
#define QUEUE_DEPTH 32
#define BLOCK_SZ 4096
int main(int argc, char *argv[]) {
struct io_uring ring;
int ret;
// 1. 初始化 Ring,深度为 32
ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
if (ret < 0) {
fprintf(stderr, "Queue init failed: %s\n", strerror(-ret));
return 1;
}
int fd = open("test.log", O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}
char buf[BLOCK_SZ];
struct iovec iov = {
.iov_base = buf,
.iov_len = BLOCK_SZ
};
// 2. 从提交队列拿一个空闲 SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
fprintf(stderr, "SQ is full!\n");
return 1;
}
// 3. 准备 Readv 任务 (Offset = 0)
io_uring_prep_readv(sqe, fd, &iov, 1, 0);
// 自定义数据标记,用于在 CQE 识别
io_uring_sqe_set_data(sqe, (void *)0xdeadbeef);
// 4. 提交任务到内核
io_uring_submit(&ring);
// 5. 等待完成队列返回 CQE
struct io_uring_cqe *cqe;
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
fprintf(stderr, "Wait cqe failed: %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 successfully! UserData: %p\n", cqe->res, io_uring_cqe_get_data(cqe));
}
// 6. 标记 CQE 已被消费,释放 Ring Slots
io_uring_cqe_seen(&ring, cqe);
close(fd);
io_uring_queue_exit(&ring);
return 0;
}
四、 刨根问底:perf 与 strace 视角下的降维打击
光吹不上数据不是「较真」的态度。我们来对比一下传统同步系统调用和 io_uring 开启 SQPOLL 处理海量 I/O 时的底层开销。
1. strace 视角下的系统调用次数对比
如果用 strace -c 压测跑 100,000 次数据读取:
- 传统模型 (read):
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
100.00 0.342110 3 100000 read
------ ----------- ----------- --------- --------- ----------------
100.00 0.342110 100000 total
每一次操作都得踏踏实实走一次 sys_enter 和 sys_exit。
- io_uring (SQPOLL):
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
100.00 0.000045 45 1 io_uring_setup
0.00 0.000008 8 1 mmap
------ ----------- ----------- --------- --------- ----------------
100.00 0.000053 2 total
在整个读操作期间,除了启动时的初始化,业务主线程没有产生任何一次系统调用。
2. perf 性能热点采样
执行 perf record -g 并分析:
- 传统模型下,你的 CPU 火焰图上会被大量的
entry_SYSCALL_64、switch_to、vfs_read占满,大部分 CPU 资源耗费在保护现场、切换页表上; - 而在
io_uring下,用户态火焰图纯净得只剩下内存拷贝或业务逻辑处理,内核态的工作全异步交给了内核工作线程io_wqe_worker或io_uring-sq。
五、 避坑指南:内核喵的踩坑备忘录
作为后端调优工程师,在准备把生产环境切到 io_uring 之前,有几个坑必须先看清楚:
- 内核版本要求极其苛刻: 虽然 5.1 就合并了,但 千万别在 5.10 之前的内核搞大事情。早期版本内存泄露、Cancel 逻辑不完善等 Bug 较多。生产落地推荐直接上 Linux 5.15 LTS 甚至 6.x。
RLIMIT_MEMLOCK内存锁定限制: 由于 Ring Buffer 需要通过pin_user_pages锁定物理内存,防止内核访问时发生 Page Fault。老版本内核中如果不调高ulimit -l,会导致io_uring_queue_init报-ENOMEM。- SQPOLL 的 CPU 消耗:
开启
SQPOLL相当于让一个内核线程死循环,它会吃掉整整一颗核心。如果 I/O 并不密集,或者没有正确配置sq_thread_idle超时挂起,这反而是极大的 CPU 浪费。 - 安全团队的警惕: 由于底层代码复杂,过去两年 io_uring 报出过多个本地提权 CVE(比如与 seccomp 的交互问题)。部分金融/容器云团队在生产环境甚至用 seccomp 禁用了该系统调用,上线前务必和安全团队报备。
总结
io_uring 不仅仅是代替了 epoll,它统一了 Linux 下的网络、磁盘、定时器甚至进程间通信模型。把「事件驱动」推进到了「完成驱动」,彻底消除了系统调用这层高昂的过路费。
下一代的高性能网络和存储组件(如 RocksDB 的持久化层、Netty 的孵化传输模块、Tokio 生态)都在全面拥抱 io_uring。建议大家在调优时多打开 bpftrace 和 perf 看看,它带给你的性能震撼绝不止是数字上的提升。
License: CC BY-NC 4.0
Updated 13 hours ago
Was this article helpful? Give it a like.
0 comments


