编程 io_uring 与 Rust 异步的世纪错配:为什么 Future 模型天生接不住完成式 I/O——从手写 mini 运行时到 monoio 生产落地的完全拆解

2026-08-13 04:17:43 +0800 CST views 29

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%」,而是三件更根本的事:

  1. 它统一了 Linux 的异步 I/O。Linux AIO 只支持 direct I/O 的存储文件,基本只有数据库在用;io_uring 同时覆盖存储文件、socket,还把 accept/openat/statx/splice 这类几十种系统调用都做成了异步操作码。
  2. 它是设计上真正的异步。Linux AIO 名义异步,实际在某些路径上照样阻塞(走 pagecache、文件系统元数据、submit 时排队),行为不可预测。
  3. 它把「一次 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 4K32~129 万/s109µs
io_uring randread 4K32~130 万/s106µs
io_uring + sq_poll32~149 万/s64µs
AIO randread 4K256~129 万/s881µs
io_uring + sq_poll256~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>;
}

三个关键事实:

  1. Future 是惰性的。不 poll 就什么都不发生。
  2. poll 返回 Pending 时,必须先把 Waker 存好,等条件满足再 wake() 让执行器重新 poll
  3. 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 的位置

灾难链条是这样的:

  1. read future 已经把 buf 的裸指针交给了内核,内核已经受理了这个读请求。
  2. 超时触发,read future 被 Drop。Rust 认为「取消完成了」,buf 的借用结束。
  3. 内核完全不知道你取消了。它还在等网卡来数据。
  4. 200ms 后数据到了,内核往那个裸指针指向的地址写了 500 字节。
  5. 那块内存现在是 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 生态(hypertokio-util、几乎所有协议库)全都用不了。
  • 不能用栈数组和临时切片,必须是有稳定地址、可移交所有权的容器(Vec<u8>BytesMut、自定义 IoBuf)。
  • 写起来更啰嗦,每次都要接回 buf。

而它的收益也很实在:取消变得安全了。因为 buffer 的所有权在运行时手里,future 被 drop 时运行时可以把这块 buffer 挂到一个「孤儿列表」(orphan list)里,向内核提交 cancel,然后等 CQE 真的回来之后再释放。用户代码不再持有这块内存,所以内核晚写也写不坏别人的数据。

这就是为什么 monoio 要定义自己的一套 trait,而且名字起得非常直白——AsyncReadRent / AsyncWriteRentRent 就是「租借」:

// 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 语义生态兼容定位
tokiowork-stealing 多线程mio / epoll、kqueue、IOCP借用(&mut [u8]★★★★★ 事实标准通用生产首选
tokio-uring单线程 per-runtimeio_uring所有权转移★★ 需专用适配实验性,未并入主干
monoiothread-per-coreio_uring + legacy(epoll/kqueue)所有权转移(Rent traits)★★★ 有自研生态高性能网络中间件
glommiothread-per-coreio_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 亲和性好、尾延迟更稳定。
代价,一条条说清楚,这些才是决定你能不能上生产的东西:

  1. !Send 会传染。你的所有跨 await 状态都不能是 Send,也就意味着任何依赖 tokio::spawn 语义的库都得改。
  2. 负载不均衡没有兜底。work-stealing 的价值就是自动削峰。thread-per-core 下,一个核上来了个慢连接(比如某个客户端在做大文件上传),这个核上的其他连接只能跟着等,没有任何机制救它
  3. 连接分发要自己解决。标准做法是每个线程各自 SO_REUSEPORT bind 同一端口,让内核按四元组哈希分发。但内核哈希不保证业务均衡——如果你的流量是长连接且连接数少(比如 8 个核 32 条连接),很容易出现两个核打满、六个核空转
  4. 兼容层有明显损耗。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 是骗局」,而是:

  1. 测试场景是 ping-pong 小包,每个请求就一次 read + 一次 write,没有批量提交的空间。io_uring 最大的杀手锏——「攒一批请求一次系统调用」——在这里根本用不上。
  2. 打流端先打满了。三台 8 核机器、每台 30 连接就吃满 CPU,说明瓶颈可能不在被测服务。压测里最常见的错误就是瓶颈不在被测方
  3. 内核 5.15DEFER_TASKRUN、buffer ring(IORING_REGISTER_PBUF_RING)、multishot recv、SEND_ZC 这些真正拉开差距的特性,都在 5.19 / 6.0 之后。用 5.15 测 io_uring,等于用 2019 年的 io_uring 测 2026 年的结论。
  4. 默认配置。没有 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 类系统调用。 这是最被忽视的一条。openatstatxcloseacceptsplicefsync 这些在 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 条踩坑清单

  1. 先量后换。pidstat%system 占比,低于 30% 就别指望 io_uring 能救你。瓶颈往往在业务逻辑、序列化、锁竞争、GC 式的内存分配,而不是 syscall。
  2. 内核版本是硬门槛。 5.1 只有基础功能;multishot accept 要 5.19;multishot recv / SEND_ZC / DEFER_TASKRUN 要 6.0 附近。在 5.10/5.15 上做 io_uring 选型评测,得到的结论没有参考价值。
  3. 确认运行环境没禁用 io_uring。 部分受限环境(沙箱运行时、加固过的容器平台、某些移动/桌面发行版)因为历史上的内核提权漏洞,把 io_uring 系统调用直接禁掉或降级了。上线前务必在目标环境跑一次探测,别在开发机上验证完就发版。降级路径要提前写好(monoio 的 legacy driver 就是干这个的)。
  4. 绝不在 Drop 里直接释放在途 buffer。 必须转移所有权到运行时等 CQE。这是内存安全底线,不是优化建议。
  5. AsyncCancel 是尽力而为的。 它可能返回 -EALREADY(正在执行)或 -ENOENT(找不到)。你的状态机必须能处理「取消失败但操作照样完成」。
  6. user_data 要防串号。 Slab 索引被复用时,如果一个迟到的 CQE 拿着旧索引回来,会唤醒错误的 Future。生产实现应该用 (generation << 32) | index 这类带版本号的编码。
  7. multishot 必须检查 F_MORE 标志消失就意味着 multishot 结束了,必须重新提交。漏掉这一步的表现是「跑一段时间后部分连接莫名不响应」,排查起来极其痛苦。
  8. SEND_ZC 必须等 F_NOTIF 才能复用 buffer。 第一个 CQE 只代表「已提交」,不代表「已发完」。
  9. CQ 溢出会静默丢事件。 队列满时内核会在 ring 里累加 cq_overflow 计数。必须监控这个值,它涨了说明你消费 CQE 的速度跟不上,可能已经丢事件了。默认建议 CQ 深度设成 SQ 的两倍。
  10. SQPOLL 在小核容器上是负优化。 它独占一个核。4 核容器上开它 = 送掉 25% 算力。开之前先算这笔账。
  11. SINGLE_ISSUER 是刚性约束。 声明了就必须始终由同一线程提交,跨线程提交直接报错。它和 work-stealing 调度天然互斥。
  12. thread-per-core 要解决连接分发均衡。 SO_REUSEPORT 靠内核四元组哈希,长连接少连接场景下极易倾斜。上线前用真实连接数模式压测各核负载,别只看总 QPS。
  13. 别在 thread-per-core 里做长 CPU 任务。 一个核被占住,这个核上所有连接一起等,没有 work-stealing 兜底。CPU 密集活儿要显式扔到独立线程池。
  14. 兼容层的性能损耗要实测。 为了用 hyper/tonic 而开 poll-io 兼容模式,可能把 io_uring 的收益全吃掉。「monoio + 兼容层」有可能慢于「纯 Tokio」,这个组合必须压测验证,不能想当然。
  15. 可观测性要跟上。 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 情况差异很大,不要照抄本文的版本号,要在目标环境实测探测

推荐文章

MCP 测试文章 18058
2026-08-13 06:22:06 +0800 CST
15 个 JavaScript 性能优化技巧
2024-11-19 07:52:10 +0800 CST
JavaScript 实现访问本地文件夹
2024-11-18 23:12:47 +0800 CST
Requests库详细介绍
2024-11-18 05:53:37 +0800 CST
使用 sync.Pool 优化 Go 程序性能
2024-11-19 05:56:51 +0800 CST
程序员茄子在线接单