io_uring 与 Rust 异步的世纪错配:为什么 Future 模型天生接不住完成式 I/O——从手写 mini 运行时到 monoio 生产落地的完全拆解
一句话预警:如果你打算把线上的 Tokio 服务换成 io_uring 运行时来「提升性能」,读完这篇你大概率会改主意;而如果你真的属于那 10% 该换的场景,这篇会告诉你每一个坑在哪。
一、从一次「换了更快的 API 反而更慢」说起
先讲个让人清醒的场景。
某网关服务,Tokio 写的,四核容器,QPS 十一万左右,p99 延迟 0.6ms。有人做了性能剖析,发现 70% 以上的 CPU 周期花在了用户态之外——系统调用开销、内核态与用户态之间的数据拷贝。结论听起来非常顺理成章:epoll 太老了,换 io_uring,那是内核 5.1 引入的、Jens Axboe 亲自设计的真异步接口,共享内存环形队列、零拷贝、批量提交,还能 polling 模式免中断。
于是有人把服务改成了 tokio-uring。压测结果:性能几乎没变,某些连接数区间甚至更差。
这不是孤例。公开的横向压测里(Tokio / tokio-uring / monoio / glommio 四家对比,4 核 GCP 机器 + k6 打流),在 ping-pong 这种小包一问一答的场景下,基于 io_uring 和 thread-per-core 模型的三家,并没有表现出对 Tokio 的显著优势;glommio 甚至垫底。更早的 io_uring 权威评测也说过类似的话:如果你的应用已经在正确使用 Linux AIO,切到 io_uring 哪怕打开高级特性,收益也就 5% 左右。
那 io_uring 为什么还被叫做革命性技术?为什么字节跳动要专门写一个 monoio?为什么 PostgreSQL 18 要把 io_method=io_uring 塞进异步 I/O 子系统?
因为io_uring 的价值从来不是「把 epoll 的数字提高 30%」,而是三件更根本的事:
- 它统一了 Linux 的异步 I/O。Linux AIO 只支持 direct I/O 的存储文件,基本只有数据库在用;io_uring 同时覆盖存储文件、socket,还把
accept/openat/statx/splice这类几十种系统调用都做成了异步操作码。 - 它是设计上真正的异步。Linux AIO 名义异步,实际在某些路径上照样阻塞(走 pagecache、文件系统元数据、submit 时排队),行为不可预测。
- 它把「一次 I/O = 一次系统调用」这个前提彻底打掉了。你可以攒一百个请求提交一次,甚至配上
SQPOLL之后一次系统调用都不用。
而对 Rust 来说,这件事有一个额外的、几乎没人在入门文章里讲清楚的严重后果:
io_uring 的完成式(completion-based)语义,和 Rust Future 的「随时可取消」模型,存在一个无法用糖衣包住的根本冲突。这个冲突直接决定了 tokio-uring 至今仍是实验品、没能合并进 Tokio 主干,也决定了 monoio 为什么必须发明一套 AsyncReadRent 而不是复用 AsyncRead。
这篇文章就是把这条线从内核一路拆到应用层:先讲清两种 I/O 模型的第一性差异,再讲透 Rust 的取消语义为什么会引发内存不安全,然后手写一个能跑 echo server 的 mini 完成式运行时,接着横评生态四家,最后给出「什么时候 io_uring 才真的赚」的判据和 15 条生产踩坑清单。
二、第一性原理:就绪式 vs 完成式,差的不只是性能
2.1 epoll 的真实账单
绝大多数人对 epoll 的心智模型是「高性能异步 I/O」。严格来说这句话有两个错。
第一,epoll 不是异步 I/O,它是 I/O 多路复用。它告诉你的是「这个 fd 现在可以读了」,真正的 read() 还得你自己去做。用一个老比喻:你请人帮你烧开水,人家喊你一声水开了,泡茶还得你自己动手。
第二,它的开销结构常被低估。一次完整的「读一个请求」在 epoll 模型下是这样:
epoll_wait() -> 系统调用 #1,返回就绪 fd 列表
read(fd, buf) -> 系统调用 #2,内核把数据从内核缓冲区拷到 buf
(数据没读完/EAGAIN)-> 可能还要再来一轮
write(fd, buf)-> 系统调用 #3
在小包高频场景下,每个请求 3~5 次系统调用,每次系统调用都是一次用户态/内核态切换。Spectre/Meltdown 之后加了各种缓解措施,syscall 的固定成本比十年前更贵了。再叠加数据拷贝,你就得到了开头那个「70%~80% CPU 花在用户态之外」的剖析结果。
这是**就绪式(readiness-based)**模型的天花板:内核只负责通知就绪,实际操作必须由用户态发起,于是每个操作至少一次系统调用。
2.2 io_uring 的双环共享内存模型
io_uring 的做法是把「操作」本身也异步化:你把「我要读 fd 5 的 1024 字节到地址 X」这个请求描述写进队列,内核自己去做,做完了把结果写到另一个队列。
核心是两个通过 mmap 在用户态和内核之间共享的无锁环形队列:
- SQ(Submission Queue):用户写入,内核读取。元素叫 SQE(submission queue entry),描述一个 I/O 操作。
- CQ(Completion Queue):内核写入,用户读取。元素叫 CQE(completion queue entry),携带结果和你自己塞进去的
user_data。
配套只有三个系统调用:
| 系统调用 | 作用 |
|---|---|
io_uring_setup | 创建 ring,返回 ring fd,用户 mmap 出 SQ/CQ |
io_uring_enter | 提交 SQE 并(可选)等待 CQE |
io_uring_register | 预注册资源:buffer、file、eventfd、buf ring |
关键点在于:共享内存意味着提交请求本身不需要系统调用,写内存 + 内存屏障即可;io_uring_enter 只是「敲一下门」通知内核,而且可以一次敲门提交 N 个请求。再往上走一步,开启 IORING_SETUP_SQPOLL 之后,内核会起一个轮询线程盯着 SQ,你连门都不用敲——稳态下零系统调用。
这就是数据层面差异的来源。看一组公开的 NVMe(Optane 5800)随机读实测数据,能非常直观地看到 SQPOLL 的价值:
| 模式 | 队列深度 | IOPS | 平均延迟 |
|---|---|---|---|
| AIO randread 4K | 32 | ~129 万/s | 109µs |
| io_uring randread 4K | 32 | ~130 万/s | 106µs |
| io_uring + sq_poll | 32 | ~149 万/s | 64µs |
| AIO randread 4K | 256 | ~129 万/s | 881µs |
| io_uring + sq_poll | 256 | ~149 万/s | ~520µs |
注意看:裸 io_uring 对比 AIO 几乎没有提升(129 → 130 万),真正的跃升来自 sq_poll(149 万,延迟腰斩)。这句话值得抄在笔记本上——它同时解释了「io_uring 很快」和「我换了 io_uring 没变快」这两个看似矛盾的事实。io_uring 的性能不是默认送你的,是你把高级特性一个个开出来的。
2.3 先摸一遍裸接口
Rust 侧的底层封装是 tokio-rs 维护的 io-uring crate,它完全绕开了 C 的 liburing,直接在系统调用上从零封装了 SQ / CQ / Submitter。先看最小可运行的读文件:
use io_uring::{opcode, types, IoUring};
use std::fs::File;
use std::os::unix::io::AsRawFd;
fn main() -> std::io::Result<()> {
// 1. 创建 ring,队列深度 8(会向上取整到 2 的幂)
let mut ring = IoUring::new(8)?;
let file = File::open("/etc/hostname")?;
let mut buf = vec![0u8; 1024];
// 2. 构造一个 read 操作的 SQE
let read_e = opcode::Read::new(
types::Fd(file.as_raw_fd()),
buf.as_mut_ptr(),
buf.len() as _,
)
.build()
.user_data(0x42); // 这个值会原样回到 CQE,是我们做请求路由的唯一凭据
// 3. 推入提交队列。unsafe 的原因:buf 的生命周期由我们自己保证
unsafe {
ring.submission()
.push(&read_e)
.expect("submission queue is full");
}
// 4. 提交并等待至少 1 个完成事件
ring.submit_and_wait(1)?;
// 5. 取结果
let cqe = ring.completion().next().expect("completion queue is empty");
assert_eq!(cqe.user_data(), 0x42);
let n = cqe.result();
if n < 0 {
return Err(std::io::Error::from_raw_os_error(-n));
}
println!("read {} bytes: {}", n, String::from_utf8_lossy(&buf[..n as usize]));
Ok(())
}
请死死盯住第 3 步那个 unsafe。它不是形式主义。它在说一件极其严肃的事:
从
push成功的那一刻起,到对应的 CQE 被取出之前,内核随时可能往buf.as_mut_ptr()指向的内存里写数据,而 Rust 编译器对此一无所知。你必须自己保证这块内存在整个期间保持有效、且没有别人在读写它。
这一行 unsafe 就是后面所有麻烦的源头。
三、致命错配:Rust 的「取消 = Drop」撞上完成式语义
3.1 先复习 Rust 异步的三件套
Rust 的异步是「无栈协程 + 轮询」:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
三个关键事实:
- Future 是惰性的。不
poll就什么都不发生。 poll返回Pending时,必须先把Waker存好,等条件满足再wake()让执行器重新poll。- Future 可以在任意
await点被丢弃。select!的落败分支、timeout超时、JoinHandle::abort()、上层 task 被取消……全都是直接Drop掉这个 Future。这就是 Rust 的取消模型:取消 = 同步 Drop,没有 async Drop,Drop 里不能 await。
3.2 就绪式模型下,这套设计天衣无缝
看 AsyncRead 的签名(简化):
// 借用式 API:buf 只在这一次调用期间被借出
async fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> io::Result<usize>;
为什么这个签名在 epoll 下是安全的?因为 Reactor 线程只负责等待和通知,从来不碰你的内存。整个流程里,buf 始终在用户态控制下:
poll() -> 注册 fd 到 epoll,存好 Waker,返回 Pending // 内核不知道 buf 存在
(此时把 Future 丢掉)-> 只需从 epoll 摘掉 fd,零风险
wake() -> 再 poll -> 此时才真正 read(fd, buf) // 同步完成,一次性
任何时刻取消都是安全的,因为在真正的 read 系统调用发生之前,随时可以撤销一个等待中的请求,撤销后没有任何一方持有 buf 的指针。所以 Rust 生态顺理成章地把「借用」写进了所有 I/O trait。
3.3 完成式模型下,这套设计会写坏内存
现在把 epoll 换成 io_uring,同样的签名:
async fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> io::Result<usize> {
// 第一次 poll:把 buf 的裸指针塞进 SQE 提交给内核
submit_sqe(opcode::Read::new(fd, buf.as_mut_ptr(), buf.len()));
// 返回 Pending,等 CQE
}
然后写一段再普通不过的业务代码:
// ⚠️ 在完成式运行时上,这段代码是内存不安全的
let mut buf = [0u8; 1024];
tokio::select! {
r = socket.read(&mut buf) => handle(r),
_ = tokio::time::sleep(Duration::from_millis(100)) => {
// 超时了,read future 被 drop
}
}
// buf 的栈帧在这里就可以被复用了
do_something_else(); // 新的局部变量可能正好落在原来 buf 的位置
灾难链条是这样的:
readfuture 已经把buf的裸指针交给了内核,内核已经受理了这个读请求。- 超时触发,
readfuture 被Drop。Rust 认为「取消完成了」,buf的借用结束。 - 但内核完全不知道你取消了。它还在等网卡来数据。
- 200ms 后数据到了,内核往那个裸指针指向的地址写了 500 字节。
- 那块内存现在是
do_something_else()的栈变量 / 已被 free 的堆块 / 别人的Vec。
这就是一个教科书级的 use-after-free,而且是内核帮你写的,ASan 都不一定抓得住。
更麻烦的是:这个洞没法在库层面用 unsafe 封装掉。你不能在 Drop 里等 CQE——Drop 是同步的,阻塞在那里会把整个执行器卡死;你也不能只是「向内核提交一个 cancel」——IORING_OP_ASYNC_CANCEL 是尽力而为的,它可能返回「已经太晚了,操作正在执行中」。
这也是 Rust 早期几个 io_uring 封装被指出有 soundness 问题的根因:它们照抄了借用式接口。
3.4 三条出路,以及生态最终的选择
出路 A:所有权转移(ownership transfer)——把 buffer 的所有权交给运行时,操作完成后再还给你。这是 tokio-uring 和 monoio 的共同选择:
// tokio-uring 风格:buf 传进去(move),结果和 buf 一起还回来
// 类型别名习惯上叫 BufResult<T, B> = (io::Result<T>, B)
let buf = vec![0u8; 1024];
let (res, buf) = file.read_at(buf, 0).await;
let n = res?;
println!("{:?}", &buf[..n]);
// buf 回来了,可以继续复用
代价非常直白:
- API 传染。所有上层抽象都得跟着改签名,标准的
AsyncRead/AsyncWrite生态(hyper、tokio-util、几乎所有协议库)全都用不了。 - 不能用栈数组和临时切片,必须是有稳定地址、可移交所有权的容器(
Vec<u8>、BytesMut、自定义IoBuf)。 - 写起来更啰嗦,每次都要接回 buf。
而它的收益也很实在:取消变得安全了。因为 buffer 的所有权在运行时手里,future 被 drop 时运行时可以把这块 buffer 挂到一个「孤儿列表」(orphan list)里,向内核提交 cancel,然后等 CQE 真的回来之后再释放。用户代码不再持有这块内存,所以内核晚写也写不坏别人的数据。
这就是为什么 monoio 要定义自己的一套 trait,而且名字起得非常直白——AsyncReadRent / AsyncWriteRent,Rent 就是「租借」:
// monoio 的核心 trait(示意)
pub trait AsyncReadRent {
// 注意:不是 &mut [u8],而是把实现了 IoBufMut 的 buffer move 进来
fn read<T: IoBufMut>(&mut self, buf: T) -> impl Future<Output = BufResult<usize, T>>;
fn readv<T: IoVecBufMut>(&mut self, buf: T) -> impl Future<Output = BufResult<usize, T>>;
}
顺手说一句:这套写法在 Rust 1.75 稳定 AFIT(async fn in trait)/ RPITIT 之前,只能靠 Box<dyn Future> 或者 GAT 硬扛,性能和人机工程都很难看。完成式运行时的可用性,实际上是被 Rust 的类型系统进化解锁的——这也是为什么这类库在 2021 年很难用,到今天才勉强顺手。
出路 B:注册缓冲 / 提供缓冲(registered & provided buffers)——把 buffer 池提前 io_uring_register 给内核,内核收数据时自己从池里挑一块,CQE 里告诉你用了哪块。这条路更快(省掉每次的地址翻译和 pin 页),而且天然解决了所有权问题(buffer 一直属于池),代价是你得自己管一个池,还得处理池空的情况(内核会返回 -ENOBUFS)。
出路 C:泄漏(leak)——future 被 drop 时干脆把 buffer 泄漏掉,等 CQE 回来再回收。听着离谱,但在「取消极少发生」的场景下是可接受的工程折衷,很多早期实现走的是这条。
生态最终的分裂是:Tokio 主干选择不动(继续用 mio + epoll 的就绪式模型,保住整个生态的 AsyncRead 契约),把 io_uring 隔离在实验性的 tokio-uring 里;而愿意重建生态的(monoio、compio、glommio)选择了 A + B 组合。
这不是技术优劣问题,是生态兼容性和极限性能之间的取舍。
四、动手:手写一个 mini 完成式运行时
理解一个模型最快的方式是把它最小实现一遍。下面这个 uring-lite 大约 200 行,能跑起一个 echo server,包含:单线程执行器、io_uring driver、所有权转移式的 Op Future、取消时的孤儿处理。
依赖:
[dependencies]
io-uring = "0.6"
slab = "0.4"
libc = "0.2"
4.1 Driver:SQE 提交与 CQE 路由
核心难点是「CQE 回来了,我怎么知道该唤醒哪个 Future」。答案是 user_data——我们用一个 Slab 分配槽位,槽位索引就是 user_data。
use io_uring::{cqueue, squeue, IoUring};
use slab::Slab;
use std::cell::RefCell;
use std::io;
use std::rc::Rc;
use std::task::Waker;
/// 一个在途操作的生命周期状态
enum Lifecycle {
/// 已提交,还没人来等
Submitted,
/// 有 Future 在等,存着它的 Waker
Waiting(Waker),
/// 已完成,结果放在这里
Completed(i32),
/// Future 已经被 drop,但内核还没回复:孤儿态
/// Box<dyn Any> 用来把 buffer 的所有权吊在这里,防止提前释放
Ignored(Box<dyn std::any::Any>),
}
pub struct Driver {
ring: IoUring,
ops: Slab<Lifecycle>,
}
impl Driver {
pub fn new(entries: u32) -> io::Result<Self> {
Ok(Self {
ring: IoUring::new(entries)?,
ops: Slab::with_capacity(entries as usize),
})
}
/// 提交一个 SQE,返回槽位索引
///
/// # Safety
/// 调用者必须保证 sqe 中引用的内存在操作完成前一直有效
pub unsafe fn submit(&mut self, sqe: squeue::Entry) -> io::Result<usize> {
let index = self.ops.insert(Lifecycle::Submitted);
let sqe = sqe.user_data(index as u64);
// SQ 满了就先 flush 一次
if self.ring.submission().is_full() {
self.ring.submit()?;
self.ring.submission().sync();
}
self.ring
.submission()
.push(&sqe)
.map_err(|_| io::Error::new(io::ErrorKind::Other, "sq full after flush"))?;
Ok(index)
}
/// 提交并等待,然后把所有 CQE 分发出去
pub fn tick(&mut self, wait: bool) -> io::Result<()> {
if wait {
self.ring.submit_and_wait(1)?;
} else {
self.ring.submit()?;
}
let mut cq = self.ring.completion();
cq.sync();
for cqe in &mut cq {
self.dispatch(cqe);
}
Ok(())
}
fn dispatch(&mut self, cqe: cqueue::Entry) {
let index = cqe.user_data() as usize;
let Some(lifecycle) = self.ops.get_mut(index) else { return };
match std::mem::replace(lifecycle, Lifecycle::Completed(cqe.result())) {
Lifecycle::Submitted => { /* 结果已存,等 Future 来取 */ }
Lifecycle::Waiting(waker) => waker.wake(),
Lifecycle::Ignored(buf) => {
// 关键:孤儿态的 CQE 回来了,此刻才是真正能释放 buffer 的时机
drop(buf);
self.ops.remove(index);
}
Lifecycle::Completed(_) => unreachable!("duplicate CQE for one-shot op"),
}
}
}
Lifecycle::Ignored 这个状态就是第三节那个内存安全洞的补丁:Future 被 drop 时,buffer 不销毁,而是转移到 Driver 里等 CQE。
4.2 Op Future:所有权转移 + 安全取消
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
thread_local! {
static DRIVER: RefCell<Option<Rc<RefCell<Driver>>>> = RefCell::new(None);
}
fn with_driver<R>(f: impl FnOnce(&mut Driver) -> R) -> R {
DRIVER.with(|d| {
let d = d.borrow();
let d = d.as_ref().expect("no runtime running");
f(&mut d.borrow_mut())
})
}
/// data 是「操作私有数据」,通常就是被租走的 buffer
pub struct Op<T: 'static> {
index: usize,
data: Option<T>,
}
impl<T: 'static> Op<T> {
/// # Safety: sqe 里的指针必须来自 data 且在完成前有效
pub unsafe fn new(sqe: squeue::Entry, data: T) -> io::Result<Self> {
let index = with_driver(|d| d.submit(sqe))?;
Ok(Self { index, data: Some(data) })
}
}
impl<T: 'static> Future for Op<T> {
/// 结果 + 把 buffer 还回去
type Output = (io::Result<u32>, T);
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let index = self.index;
let ready = with_driver(|d| match d.ops.get_mut(index) {
Some(Lifecycle::Completed(res)) => Some(*res),
Some(slot) => {
// 存 Waker;注意要覆盖旧的(Future 可能被移动到别的 task)
*slot = Lifecycle::Waiting(cx.waker().clone());
None
}
None => unreachable!(),
});
match ready {
None => Poll::Pending,
Some(res) => {
with_driver(|d| { d.ops.remove(index); });
let data = self.data.take().expect("polled after completion");
let out = if res < 0 {
Err(io::Error::from_raw_os_error(-res))
} else {
Ok(res as u32)
};
Poll::Ready((out, data))
}
}
}
}
impl<T: 'static> Drop for Op<T> {
fn drop(&mut self) {
let Some(data) = self.data.take() else { return }; // 已正常完成
with_driver(|d| {
match d.ops.get_mut(self.index) {
// 内核已经回复了,直接清理
Some(Lifecycle::Completed(_)) => { d.ops.remove(self.index); }
Some(slot) => {
// 还在途:把 buffer 吊到 Driver 上,绝不在这里 drop
*slot = Lifecycle::Ignored(Box::new(data));
// 尽力提交一个 cancel(可能已经来不及,无所谓)
let cancel = io_uring::opcode::AsyncCancel::new(self.index as u64)
.build()
.user_data(u64::MAX); // 特殊 user_data:不关心结果
unsafe { let _ = d.ring.submission().push(&cancel); }
}
None => {}
}
});
}
}
这段 Drop 是整篇文章的技术核心。 它用「所有权吊挂 + 尽力取消」把完成式语义和 Rust 的同步取消模型缝合起来了。代价是:一次被取消的操作,它的 buffer 会晚一点释放(最坏情况是连接一直不来数据、直到 cancel 生效)。这就是安全的价钱。
4.3 执行器 + echo server
单线程执行器,最朴素的版本:
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::task::Wake;
struct Task {
future: RefCell<Pin<Box<dyn Future<Output = ()>>>>,
queue: Arc<Mutex<VecDeque<Rc<Task>>>>, // 演示用,生产上别这么写
}
pub struct Runtime {
driver: Rc<RefCell<Driver>>,
ready: Rc<RefCell<VecDeque<Rc<TaskCell>>>>,
}
impl Runtime {
pub fn block_on<F: Future<Output = ()> + 'static>(&self, fut: F) {
self.spawn(fut);
loop {
// 1. 跑干所有就绪任务
while let Some(task) = self.ready.borrow_mut().pop_front() {
task.poll_once();
}
// 2. 没活干了,去内核那儿等 CQE(这里才会真的睡)
let has_inflight = self.driver.borrow().ops.len() > 0;
if !has_inflight && self.ready.borrow().is_empty() {
break;
}
self.driver.borrow_mut().tick(true).expect("driver tick");
}
}
}
这个循环体现了完成式运行时的形状:执行器和 driver 是一体的,「等 I/O」就是「等 CQE」,没有 epoll 那层就绪通知的中间态。
现在写 echo server。注意 accept 用 multishot,一次提交、多次返回,这是 io_uring 最漂亮的特性之一(内核 5.19+):
async fn echo_server() -> io::Result<()> {
let listener = TcpListener::bind("0.0.0.0:9000")?;
// multishot accept:提交一次,每来一个连接就产生一个 CQE
// 省掉了「每接一个连接就重新提交一次 accept」的开销
let mut accepts = MultishotAccept::new(listener.as_raw_fd())?;
while let Some(fd) = accepts.next().await {
spawn(async move {
let mut buf = vec![0u8; 4096]; // 这块 buffer 会在 read/write 之间来回租借
loop {
let (res, b) = read(fd, buf).await;
buf = b; // 收回所有权
let n = match res { Ok(0) | Err(_) => break, Ok(n) => n as usize };
let (res, b) = write_all(fd, buf.slice(..n)).await;
buf = b.into_inner(); // 再收回
if res.is_err() { break; }
}
let _ = close(fd).await;
});
}
Ok(())
}
buf = b; 这两行就是所有权转移式 API 的日常体感:你在手动做编译器本来会替你做的事。写多了会习惯,但别指望能无痛迁移旧代码。
五、生态横评:四条路线,各有各的赌注
| 项目 | 模型 | I/O 后端 | Buffer 语义 | 生态兼容 | 定位 |
|---|---|---|---|---|---|
| tokio | work-stealing 多线程 | mio / epoll、kqueue、IOCP | 借用(&mut [u8]) | ★★★★★ 事实标准 | 通用生产首选 |
| tokio-uring | 单线程 per-runtime | io_uring | 所有权转移 | ★★ 需专用适配 | 实验性,未并入主干 |
| monoio | thread-per-core | io_uring + legacy(epoll/kqueue) | 所有权转移(Rent traits) | ★★★ 有自研生态 | 高性能网络中间件 |
| glommio | thread-per-core | io_uring(需 5.8+) | 所有权/内部池 | ★★ | 存储密集、维护活跃度波动 |
| compio | 完成式为一等公民 | io_uring + IOCP + kqueue | 所有权转移 | ★★ | 跨平台完成式统一抽象 |
5.1 monoio 与 thread-per-core 的真实代价
monoio 是字节跳动服务框架组开源的 thread-per-core 运行时。它的设计出发点很清楚:过去高性能网络中间件(Nginx、Envoy)都是 C/C++ 写的,Rust 想接这个位置,但 Tokio 的 work-stealing 调度器为了「任务能在线程间迁移」付出了代价——所有 task 必须 Send,跨线程队列要用原子操作,任务迁移会污染 CPU cache。
thread-per-core 的逻辑是:每个核一个独立运行时,task 永不跨线程。于是:
// monoio 里这样写是合法的——task 不需要 Send
#[monoio::main(driver = "uring", timer_enabled = true)]
async fn main() {
// Rc + RefCell 直接用,不需要 Arc<Mutex<...>>
let counter = Rc::new(RefCell::new(0usize));
let c = counter.clone();
monoio::spawn(async move {
*c.borrow_mut() += 1; // 零原子操作,零锁
});
}
收益:无锁、无原子、cache 亲和性好、尾延迟更稳定。
代价,一条条说清楚,这些才是决定你能不能上生产的东西:
!Send会传染。你的所有跨 await 状态都不能是Send,也就意味着任何依赖tokio::spawn语义的库都得改。- 负载不均衡没有兜底。work-stealing 的价值就是自动削峰。thread-per-core 下,一个核上来了个慢连接(比如某个客户端在做大文件上传),这个核上的其他连接只能跟着等,没有任何机制救它。
- 连接分发要自己解决。标准做法是每个线程各自
SO_REUSEPORTbind 同一端口,让内核按四元组哈希分发。但内核哈希不保证业务均衡——如果你的流量是长连接且连接数少(比如 8 个核 32 条连接),很容易出现两个核打满、六个核空转。 - 兼容层有明显损耗。monoio 提供了 poll-io 兼容模式来跑
AsyncRead/AsyncWrite生态(这样才能用 hyper 之类),但公开压测显示走兼容路径有明显性能损失——你会把 io_uring 省下来的那点开销,在兼容层里还回去。
第 4 点尤其要划重点,因为它构成了一个很常见的陷阱:你为了性能换到 monoio,又因为要用 hyper/tonic 而开了兼容层,最后性能不如原来的 Tokio。
5.2 那份「io_uring 没更快」的压测该怎么读
回到开头那份四家横评的数据(4 核测试机、k6 从三台 8 核机器打流、内核 5.15):
Tokio: rps 110883 / 80 conns / latency 568µs
tokio-uring / monoio / glommio: 无显著优势,glommio 最差
这份数据的正确读法不是「io_uring 是骗局」,而是:
- 测试场景是 ping-pong 小包,每个请求就一次 read + 一次 write,没有批量提交的空间。io_uring 最大的杀手锏——「攒一批请求一次系统调用」——在这里根本用不上。
- 打流端先打满了。三台 8 核机器、每台 30 连接就吃满 CPU,说明瓶颈可能不在被测服务。压测里最常见的错误就是瓶颈不在被测方。
- 内核 5.15。
DEFER_TASKRUN、buffer ring(IORING_REGISTER_PBUF_RING)、multishot recv、SEND_ZC这些真正拉开差距的特性,都在 5.19 / 6.0 之后。用 5.15 测 io_uring,等于用 2019 年的 io_uring 测 2026 年的结论。 - 默认配置。没有 SQPOLL、没有注册缓冲。回看第 2.2 节那张 NVMe 表:开 sq_poll 才从 129 万跳到 149 万。
所以结论应该改写成:在小包 ping-pong、默认配置、旧内核的条件下,io_uring 对 epoll 没有优势。 这个结论对绝大多数 Web 服务是成立的,也正是为什么我劝开头那位同学别换。
六、什么时候 io_uring 才真的赚:判据与调优实战
6.1 三个「真赚」判据
判据一:系统调用密度极高。 判断方法不是猜,是量:
# 看服务的系统调用分布和耗时占比
strace -c -f -p $(pidof my-server) -- sleep 10
# 更准的做法:看 CPU 时间里内核态的占比
pidstat -u -p $(pidof my-server) 1 10
# %system 显著高于 %usr(比如 2:1 甚至更高)→ 有搞头
如果 %system 只占 15%,你就算把系统调用开销降到零,天花板也只有 15%。这笔账要先算。
判据二:存储侧 direct I/O + 高队列深度。 这是 io_uring 优势最明确的战场,也是 PostgreSQL 18 把 io_method=io_uring 作为异步 I/O 后端之一的原因。要点是队列深度得上得去——深度 1 的时候 AIO 和 io_uring 是一样的(回看那张表:11.2 万 vs 11.2 万,一模一样)。
判据三:大量非 read/write 类系统调用。 这是最被忽视的一条。openat、statx、close、accept、splice、fsync 这些在 epoll 模型下只能同步调用(或者丢到线程池里假装异步——Tokio 的 spawn_blocking 就是这么干的)。io_uring 有 60 多种操作码,能把它们全部真正异步化。
一个具体例子:静态文件服务器,每个请求要 openat + statx + read + close。Tokio 下前后三个都得走 spawn_blocking,每次都有线程池调度和跨线程唤醒开销;io_uring 下这四步可以用 IOSQE_IO_LINK 串成一条链,一次提交,内核按序执行:
// 用 link 把 open -> read -> close 串成原子链条
// 前一个失败会自动取消后续(这是 IO_LINK 的语义)
let open = opcode::OpenAt::new(types::Fd(libc::AT_FDCWD), path.as_ptr())
.flags(libc::O_RDONLY)
.build()
.flags(squeue::Flags::IO_LINK) // ← 关键
.user_data(1);
// 用 fixed file slot,配合 register 省掉 fd 查找
let read = opcode::Read::new(types::Fixed(0), buf.as_mut_ptr(), buf.len() as _)
.build()
.flags(squeue::Flags::IO_LINK)
.user_data(2);
let close = opcode::Close::new(types::Fixed(0)).build().user_data(3);
unsafe {
ring.submission().push_multiple(&[open, read, close]).unwrap();
}
ring.submit_and_wait(3)?; // 一次系统调用,三个操作
从 4 次系统调用 + 2 次线程池往返,压到 1 次系统调用。 这才是 io_uring 该被用来干的事。
6.2 调优清单:把性能一个个开出来
(1)SQPOLL——最大单笔收益,也最贵
let mut ring = IoUring::builder()
.setup_sqpoll(2000) // 空闲 2000ms 后内核线程休眠
.setup_sqpoll_cpu(3) // 绑到 3 号核,别和业务线程抢
.build(256)?;
收益:稳态零系统调用(NVMe 场景实测 129 万 → 149 万 IOPS,延迟 109µs → 64µs)。
成本:一个内核线程持续烧一个核。四核容器上开 SQPOLL 等于送掉 25% 的算力,通常是负收益。这个特性只在核多、I/O 极密集时才划算,而且需要相应权限。
(2)DEFER_TASKRUN + COOP_TASKRUN——低成本高收益,优先开
let mut ring = IoUring::builder()
.setup_single_issuer() // 声明只有一个线程提交,内核可省掉同步开销
.setup_defer_taskrun() // task work 攒批处理,而不是每次中断都跑
.build(256)?;
这两个(内核 6.0 前后引入,SINGLE_ISSUER 也是同期)在 thread-per-core 架构下几乎是白送的性能:不占额外 CPU,减少无谓唤醒和 task work 抖动。如果你的内核够新,这是第一个该开的。 注意 SINGLE_ISSUER 有硬约束:必须始终由同一个线程提交,否则直接报错——所以它天生适合 thread-per-core,不适合 work-stealing。
(3)注册文件 + 注册缓冲——省掉每次的翻译成本
// 注册 fd 表,之后用 types::Fixed(idx) 代替 types::Fd(raw)
// 省掉内核每次的 fd -> struct file 查找与引用计数
ring.submitter().register_files(&fds)?;
// 注册缓冲,内核提前 pin 住这些页
// 省掉每次操作的页表遍历和 get_user_pages
let iovecs: Vec<libc::iovec> = bufs.iter_mut().map(|b| libc::iovec {
iov_base: b.as_mut_ptr() as *mut _,
iov_len: b.len(),
}).collect();
unsafe { ring.submitter().register_buffers(&iovecs)?; }
// 之后用 opcode::ReadFixed / WriteFixed
(4)Buffer Ring(PBUF_RING)——高并发读的正确姿势
传统做法:每个连接预分配一个读缓冲。10 万连接 × 4KB = 400MB 常驻内存,而其中绝大多数时刻是空闲的。
Buffer ring 的做法:共享一个缓冲池,内核收到数据时自己从池里挑一块,CQE 里的 flags 高位告诉你用了哪块。内存占用从 O(连接数) 降到 O(并发活跃数)。
配合 multishot recv(内核 6.0+):提交一次 recv,只要有数据就持续产生 CQE,连重新提交都省了。
传统: recv 提交 -> CQE -> 重新提交 recv -> CQE -> ... (每次都要 SQE)
multishot: recv 提交一次 -> CQE -> CQE -> CQE ... (CQE 带 IORING_CQE_F_MORE 标志)
要点:必须检查 IORING_CQE_F_MORE。这个标志没了说明 multishot 被内核终止了(常见原因是 buffer 池空了返回 -ENOBUFS),你必须重新提交,否则这条连接就永久静默了。这是 multishot 最容易踩的坑,没有之一。
(5)零拷贝发送(SEND_ZC,内核 6.0+)
大包发送时省掉一次内存拷贝。但它的完成语义是两个 CQE:第一个(带 F_MORE)告诉你「已提交」,第二个(F_NOTIF)告诉你「buffer 可以回收了」。
send_zc 提交
-> CQE #1: result = 发送字节数, flags 含 IORING_CQE_F_MORE
-> CQE #2: flags 含 IORING_CQE_F_NOTIF ← 只有到这里,buffer 才能复用!
在收到 NOTIF 之前复用 buffer,就是数据错乱。 而且小包用 SEND_ZC 是负优化——页 pin 的开销超过省下的拷贝。经验阈值大概在 4KB 以上才考虑,具体要压测。
(6)批量提交——最容易拿的分
// 反面:每个操作提交一次,白瞎了 io_uring
for op in ops { push(op); ring.submit()?; }
// 正面:攒一批再敲一次门
for op in ops { push(op); }
ring.submit()?; // 1 次系统调用搞定 N 个操作
这是唯一一个「不改内核、不占额外 CPU、纯改代码」就能拿到的大幅收益,也是 io_uring 相对 epoll 的架构性优势所在。如果你的运行时没有做提交批量化,那它基本没发挥 io_uring 的价值。
七、生产落地:15 条踩坑清单
- 先量后换。 用
pidstat看%system占比,低于 30% 就别指望 io_uring 能救你。瓶颈往往在业务逻辑、序列化、锁竞争、GC 式的内存分配,而不是 syscall。 - 内核版本是硬门槛。 5.1 只有基础功能;multishot accept 要 5.19;multishot recv /
SEND_ZC/DEFER_TASKRUN要 6.0 附近。在 5.10/5.15 上做 io_uring 选型评测,得到的结论没有参考价值。 - 确认运行环境没禁用 io_uring。 部分受限环境(沙箱运行时、加固过的容器平台、某些移动/桌面发行版)因为历史上的内核提权漏洞,把 io_uring 系统调用直接禁掉或降级了。上线前务必在目标环境跑一次探测,别在开发机上验证完就发版。降级路径要提前写好(monoio 的 legacy driver 就是干这个的)。
- 绝不在
Drop里直接释放在途 buffer。 必须转移所有权到运行时等 CQE。这是内存安全底线,不是优化建议。 AsyncCancel是尽力而为的。 它可能返回-EALREADY(正在执行)或-ENOENT(找不到)。你的状态机必须能处理「取消失败但操作照样完成」。user_data要防串号。 Slab 索引被复用时,如果一个迟到的 CQE 拿着旧索引回来,会唤醒错误的 Future。生产实现应该用(generation << 32) | index这类带版本号的编码。- multishot 必须检查
F_MORE。 标志消失就意味着 multishot 结束了,必须重新提交。漏掉这一步的表现是「跑一段时间后部分连接莫名不响应」,排查起来极其痛苦。 SEND_ZC必须等F_NOTIF才能复用 buffer。 第一个 CQE 只代表「已提交」,不代表「已发完」。- CQ 溢出会静默丢事件。 队列满时内核会在 ring 里累加
cq_overflow计数。必须监控这个值,它涨了说明你消费 CQE 的速度跟不上,可能已经丢事件了。默认建议 CQ 深度设成 SQ 的两倍。 - SQPOLL 在小核容器上是负优化。 它独占一个核。4 核容器上开它 = 送掉 25% 算力。开之前先算这笔账。
SINGLE_ISSUER是刚性约束。 声明了就必须始终由同一线程提交,跨线程提交直接报错。它和 work-stealing 调度天然互斥。- thread-per-core 要解决连接分发均衡。
SO_REUSEPORT靠内核四元组哈希,长连接少连接场景下极易倾斜。上线前用真实连接数模式压测各核负载,别只看总 QPS。 - 别在 thread-per-core 里做长 CPU 任务。 一个核被占住,这个核上所有连接一起等,没有 work-stealing 兜底。CPU 密集活儿要显式扔到独立线程池。
- 兼容层的性能损耗要实测。 为了用 hyper/tonic 而开 poll-io 兼容模式,可能把 io_uring 的收益全吃掉。「monoio + 兼容层」有可能慢于「纯 Tokio」,这个组合必须压测验证,不能想当然。
- 可观测性要跟上。 io_uring 的在途操作对传统工具是不可见的:
strace看不到(操作在共享内存里,不走系统调用)、lsof看不到 fixed file 的真实持有关系。必须自己埋点:在途操作数、各 opcode 延迟分布、CQ 溢出计数、-ENOBUFS次数。上线前先把这四个指标做出来,否则出问题你是瞎的。
八、总结:这场错配教给我们的事
回到最开始那个问题:io_uring 到底该不该用?
我的答案分三档,说得直白点:
该用(明确收益):
- 存储密集型:数据库、KV、日志系统、对象存储网关,direct I/O + 高队列深度。
- 系统调用密度极高:代理/网关中小包高频,且
%system占比超过 50%。 - 大量元数据操作:静态文件服务、文件同步,用 linked SQE 把 open/read/close 串起来。
- 内核 6.x + 核数充裕 + 团队有能力自建生态。
别用(大概率是负收益):
- 普通 Web/API 服务,瓶颈在业务逻辑和数据库往返。
- 小核容器(≤4 核),SQPOLL 开不起,收益被摊薄。
- 强依赖
AsyncRead/AsyncWrite生态,一换就要过兼容层。 - 内核 5.15 及更早。
观望: 等 compio 这类跨平台完成式抽象成熟,或者等 Rust 在语言层面把 async cancellation / async Drop 的坑填掉。
但比「用不用」更有价值的,是这场错配暴露出来的那个更普适的教训:
当底层模型发生范式转移时,上层抽象的接口契约会成为最大的迁移成本——而且这个成本是隐性的、语义级的,不是加个适配层就能糊过去的。
Rust 的 AsyncRead 用了「借用 buffer」这个签名,本质上是把「就绪式 I/O」这个假设焊死在了整个生态的类型签名里。这个假设在 epoll 时代天经地义,但当 io_uring 把「内核会主动写你的内存」变成现实,&mut [u8] 就从一个方便的设计变成了一个内存安全隐患。
而整个生态已经围绕它建起来了。所以你会看到:Tokio 宁可不上 io_uring,也要保住 AsyncRead 契约;monoio 宁可重建生态,也要拿到完成式的性能。两边都没错,他们只是在为不同的东西付账。
这件事也不只发生在 Rust。Go 的 netpoller 同样绑定在就绪式模型上,io_uring 在 Go 里更难落地(GMP 调度器 + GC 让 buffer 生命周期管理更复杂)。Node.js 在 libuv 里给文件操作接了 io_uring,也经历过反复调整。每一个成熟运行时都在为自己十年前的抽象选择还债。
所以下次你设计一个基础库的接口时,多问一句:我在这个签名里焊死了哪个关于底层的假设?如果那个假设明年变了,代价是谁付?
这个问题的答案,比任何一个性能数字都值钱。
参考方向(建议自行查阅一手资料):axboe/liburing 仓库与 man pages(最权威的 io_uring 语义来源)、tokio-rs/io-uring crate 文档、bytedance/monoio 设计文档、tokio-uring 的设计 RFC(关于 buffer 所有权的讨论值得逐条读)、以及 PostgreSQL 18 异步 I/O 相关的实现说明。
内核特性的可用版本请以你的目标发行版实际内核为准——发行版的 backport 情况差异很大,不要照抄本文的版本号,要在目标环境实测探测。