epoll 已经很能打了,Linux 干嘛还要再造一个 io_uring?

2026-10-28 12 0

epoll 已经很能打了,Linux 干嘛还要再造一个 io_uring?

先说个场景,我手上有个文件复制服务,逻辑简单到令人发指:读一批、写一批、读下一批。压测一上,CPU 还没满,QPS 先趴了。火焰图一拉,满屏都是 read、write 两个系统调用在刷脸。我拍着桌子说:这不是 epoll 的活吗?结果懂行的同事一句话把我噎住:epoll 解决的是"很多连接里谁有数据",它解决不了"每次都要进内核拿一次数据"。

于是有了 io_uring。这个 Linux 内核里的狠角色,是 5.1 之后登场、被反复雕琢到今天的后端基础设施。它不是 epoll 的替代品,它是把"系统调用"这件事本身,从数据通路上踢了出去。

epoll 让内核告诉你"哪个 fd 能读了";io_uring 让内核在你提交的队列里直接帮你把数据搬到你的缓冲区。前者是通知,后者是代办。

一、epoll 的天花板:IO 路径上的"两次往返"

同步 IO 的老路数是:read(fd, buf, n) 进内核,内核拷贝数据,函数返回。整个过程有两次用户态与内核态的切换,还有一次无法被编译器优化的数据拷贝。

epoll 把"哪个连接有数据"这件事批量化了,一次 epoll_wait 能捞出 N 个就绪 fd。但捞出来之后,你还是得对每一个 fd 单独调 read。一万个连接同时可读,就是一万次系统调用。连接数一多,syscall 开销和上下文切换就成了天花板。

更扎心的是 io_uring 之前,Linux 一直缺一个像 aio 这样的"真异步 IO"。你想要的语义是:提交请求 → 干别的去 → 请求完成时通知我。而现实是:pread 还是阻塞的,你只能丢到线程池里,用线程数量硬扛并发。这就是为什么 Java NIO、Go 的 goroutine 模型、libevent 全都在"用异步接口包装同步系统调用"。

不是说这层包装不行,它确实好使。但它有一个绕不开的税:每次 IO,至少两次系统调用(提交 + 完成通知),数据至少拷贝两次。

二、io_uring 的核心:一对共享环形队列

io_uring 的设计精髓其实只有一句话:把"提交请求"和"收割结果"这两件事,都变成往共享内存里的环形队列写一笔。

内核和用户态各维护两个核心结构:

  • Submission Queue (SQ):用户态往里塞 SQE(Submission Queue Entry),描述"我要对 fd=42 发起一个 READ,缓冲区在这,缓冲区长度 64KB"。
  • Completion Queue (CQ):内核干完活,把 CQE(Completion Queue Entry)写进去告诉你"搞定了,actually 读了 32768 字节"。
  • SQ Ring / CQ Ring:两个真正的 mmap 共享内存区域,挂着 head/tail 指针和数组本体。

于是"系统调用"被降级成了一个可选的优化项。正常情况下,用户态写完 SQE、内存屏障一刷,内核就能看见;只要队列没满,连 io_uring_enter 都不用调。只有需要等待结果、或者队列水位告急时,才需要一次 io_uring_enter 把内核叫醒。

#include <liburing.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>

struct io_uring_params p;
struct io_uring ring;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;

io_uring_queue_init_params(256, &ring, &p);

int fd = open("data.bin", O_RDONLY);
char *buf = aligned_alloc(4096, 65536);

sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, 65536, 0);
io_uring_submit(&ring);          // 提交,可以立刻返回

io_uring_wait_cqe(&ring, &cqe);  // 等待完成
int n = cqe->res;                // 实际读到的字节数
io_uring_cqe_seen(&ring, cqe);

close(fd);
free(buf);
io_uring_queue_exit(&ring);

看起来平平无奇,但关键的收益藏在细节里:你可以一次准备多个 SQE 再提交(io_uring_submit 前连续填 N 个 SQE),内核在一次 io_uring_enter 里批量处理。这就是所谓 submit batching,也是 io_uring 压倒 epoll 的第一杀手锏——一万次 syscall 变成一次。

三、把"性能"榨干的三件武器

1. Fixed Buffers:把注册成本摊到零

每次 read 都要让内核记住"你的缓冲区在哪",这本身就是成本。io_uring 允许你提前注册一批固定缓冲区(io_uring_register_buffers),之后所有请求都引用 buffer 索引(buffer index),完全跳过缓冲区描述的传递与查表。典型场景:读写路径固定、缓冲区形状统一的存储引擎。注册本身有成本,所以它是一次投入、长期收益的买卖。

2. Provided Buffers:内核帮你挑缓冲区

Fixed Buffers 的镜像版:你登记一组 buffer,内核自己挑一个空闲的用,把 index 告诉你。适合"读多少算多少"的场景,省掉"哪个 buffer 空闲"的应用层管理逻辑。

3. Poll Mode:把"等 IO"也变成队列操作

IOSQE_IO_POLL 让内核在提交时就同步轮询文件描述符,没数据就返回 EAGAIN,而不是挂起等待。这对网络设备(尤其是支持多队列的网卡)非常香,但要注意:它只支持特定类型的 fd,普通文件不行。

四、诚实的代价:io_uring 不是银弹

我见过太多"看到 io_uring 就上"的现场,然后被现实按在地上摩擦。翻车点如下:

  • 内核版本门槛:功能最全的是 5.15 及以后,6.x 才是完整形态。生产环境的内核版本不是你想升就能升。
  • io-wq 线程池会偷偷吃掉你的 CPU:对于 IORING_OP_OPENAT、IORING_OP_READ 这类可能阻塞的操作,内核会把请求丢进 io-wq 工作线程池执行。线程数默认等于 CPU 数,一旦你用 io_uring 做大量小文件 IO,工作线程和你的业务线程互相抢核,延迟直接飞起来。这个坑非常隐蔽,因为它在压测短时看不出,长跑才崩。
  • Fixed Buffers 的生命周期:注册了就要注销,进程崩溃还可能留下内核态资源。写长期运行的服务时,这套管理逻辑本身就是你要写的代码。
  • 网络文件系统/O_DIRECT 常掉链子:NFS、FUSE、部分块设备对 io_uring 的支持参差不齐,异步语义可能退化成同步,然后你在 profile 里看到"异步框架跑出了同步性能"。
  • 调试工具链还在成长:strace 看不见 io_uring 的内部行为,perf 采样点也有限。这东西出问题时,你会怀念 epoll 那种"一目了然"的朴素。

五、语言生态:谁在真用,谁在观望

  • C / Rust:生态最成熟。liburing 是事实标准封装,Rust 侧有 tokio-uring 和 io-uring 这类 async 运行时集成。
  • Go:libgoio 曾经尝试过,但被官方明确"实验性、不建议生产使用"的态度劝退了。Go 社区更倾向于用 goroutine + netpoller 这套已经足够好的模型,真要上 io_uring,一般得走 cgo 写扩展——生态里这类尝试少得可怜。
  • Python:有 liburing 绑定,但 GIL 和解释器开销会把 io_uring 省下来的 syscall 收益又吃掉一部分,属于"知道原理、用起来别扭"的典型。
  • Java:JDK 内部仍然走 epoll,虚拟线程把线程成本压下来后,很多场景不再需要用 io_uring 换性能了。

六、落地三步(别跳步)

  1. 先量 syscall 密度:用 strace -c -f 统计 syscall 占比。如果你的 QPS 涨 10 倍而 syscall 数也涨 10 倍,说明你在 syscall 上烧钱,io_uring 有戏;如果 CPU 已经在业务逻辑里打满,换什么都白搭。
  2. 先做混合模式:不要全量切。选一条最热的路径(比如日志落盘或大文件顺序读)做 io_uring,其余仍走 epoll,A/B 对比 p99 而不是看平均吞吐——io_uring 的收益恰恰藏在尾延迟里。
  3. 盯住 io-wq:上线前把 io-wq 的线程数、任务数作为核心指标监控起来,别等 CPU 被抢了才发现。也顺手检查一下你的 fd 类型是不是真的支持异步语义。

写在最后

io_uring 是 Linux 二十年来对 IO 子系统最彻底的一次重写,它把"系统调用"从数据通路上摘了下来,这是实打实的架构级进步。但它不是"更高级的 epoll",它是另一套需要你重新理解代价模型的东西。

如果你的系统并发高、IO 密集、syscall 密度大,io_uring 值得你专门研究;如果你只是想"让服务快一点",先把那个 for 循环里的字符串拼接和 N+1 查询干掉,收益比换 IO 模型大十倍,还不用重写一行架构。

技术选型最怕的从来不是选错,而是被"新"三个字晃住了眼,然后花两周重写,最后发现当初的瓶颈根本不在那儿。


关于本文:本文讨论的都是通用技术原理,不涉及任何具体时间点或版本号承诺,读者可自行对照自己环境的内核能力做验证。

相关文章

别一上来就 Kafka:消息队列的五个真坑,和你必须算清的那笔一致性账
生成一个 ID 而已,怎么就变成了事故现场:分布式 ID 的五层坑
Rust 三大痛点,其实只有一个:所有权。读完你会想删掉所有 GC
NULL 不是空,是”未知”:一次凌晨两点的订单失踪,揪出 SQL 三值逻辑的真凶
TCP 被 QUIC 撕碎重写:HTTP/3 到底快在哪,又在哪儿翻车
你的 SQL 为什么这么慢?——从一条慢查询开始的性能破案

发布评论