引言:为什么 Rust 的异步运行时是一个"选择问题"

在 Go 的世界里,你只有一个运行时——goroutine 调度器+网络轮询器已融入语言运行时;在 Java 的世界里,Project Virtual Threads 正在吞噬一切;在 C++ 的世界里,用户通常直接面对 epoll/io_uring。唯独 Rust,在"谁来驱动 Future 完成"这个问题上,把选择权——连同选择的代价——交给了开发者。

这不是缺陷,而是 Rust 哲学的必然:零成本抽象意味着语言不会替你承担你不愿支付的成本。一个嵌入式设备的异步循环与一个日处理百万请求的 Web 服务,固然需要不同的调度策略。

2026 年的今天,Rust 异步运行时的格局已经从"Tokio 一家独大"演变为"三足鼎立":

  • Tokio:事实标准,多线程工作窃取,生态无可匹敌
  • Monoio:Meituan 出品,io_uring 深度绑定,线程专攻存储密集型场景
  • Glommio:DataDog 贡献,共享 nothing 架构,专为 I/O 密集型微服务设计

本文不只是介绍,而是从内核到应用,从代码到火焰图,一次性讲清楚这三个运行时的 Trade-off——以便你在下一次架构评审会上,能给出有据可查的选择。

一、先行课:Rust Async/Await 的执行模型

Rust 的 async/await 在编译期把异步函数转换为一个实现了 Future trait 的状态机:

// 异步函数编译后的等价概念(简化版)
enum ReadFileState<'a> {
    Start { path: &'a str },
    WaitingFile { file: File, buf: Vec<u8> },
    WaitingRead { fut: ReadFuture },
    Done,
}

impl Future for ReadFileState<'_> {
    type Output = io::Result<Vec<u8>>;
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        loop {
            match *self {
                Start { path } => { /* 打开文件,转移到下一状态 */ }
                WaitingFile { .. } => { /* 提交读请求,注册 waker */ }
                WaitingRead { ref mut fut } => { return fut.poll(cx); }
                Done => panic!("polled after completion"),
            }
        }
    }
}

关键在于:Future 自身不会运行。它需要一个 执行器 不断调用 poll 并在资源就绪(通过 waker 机制)时重新唤醒它。执行器的实现策略——调度算法、I/O 机制、线程模型——直接决定了程序的性能天花板。

二、Tokio:多线程工作窃取的霸主

2.1 架构全景

Tokio 使用 多线程 + 工作窃取 调度器。每个线程维护自己的本地任务队列,空闲时会从其他线程队列"偷"任务:

// Tokio 启动:默认使用多线程运行时,线程数 = CPU 核心数量
#[tokio::main]
async fn main() {
    // 单任务直接 spawn
    let handle = tokio::spawn(async {
        println!("从线程 {:?} 执行", std::thread::current().id());
        fetch_data().await
    });
    
    // 结构化并发:使用 JoinSet 管理子任务生命周期
    let mut set = tokio::task::JoinSet::new();
    for i in 0..100 {
        set.spawn(async move { process(i).await });
    }
    while let Some(res) = set.join_next().await {
        match res {
            Ok(val) => println!("完成: {}", val),
            Err(e) if e.is_panic() => eprintln!("任务 panic: {}", e),
            Err(e) => break, // JoinSet 被 shutdown
        }
    }
    // Drop set: 剩余任务自动取消
}

2.2 核心机制拆解

协作式抢占:Tokio 不强制中断任务,依赖开发者在 .await 点让出控制权。一个没有在合理时间内 yield 的任务会饿死同线程上的其他任务。解决方案:

// ❌ 坏:阻塞运行时线程(sync 代码在 async 上下文中)
tokio::spawn(async {
    std::thread::sleep(Duration::from_secs(10)); // 整个线程卡死!
});

// ✅ 好:使用 spawn_blocking 把重计算转移到独立阻塞池
let result = tokio::task::spawn_blocking(|| {
    heavy_computation()
}).await.unwrap();

// ✅ 使用 tokio 自己的 sleep(注册定时器而非阻塞线程)
sleep(Duration::from_secs(10)).await;

I/O 驱动:Reactor 模式:Tokio 使用 mio 抽象层对接各平台 I/O 多路复用:

  • Linux: epoll(成熟,但有 O(n) 事件拷贝开销)
  • macOS: kqueue
  • Windows: IOCP

Tokio 1.36+ 已支持 io_uring 作为可选后端(tokio-uring),但默认仍为 epoll,以避免老内核兼容性问题。

2.3 生产级调优参数

// tokio::main 宏等价展开,可精细化配置
#[tokio::main(flavor = "multi_thread", worker_threads = 8)]
async fn main_with_config() {
    // 配置项一览:
    // - worker_threads: 工作线程数(默认 = num_cpus)
    // - max_blocking_threads: 阻塞池上限(默认 512)
    // - thread_stack_size: 线程栈大小(默认 2MB)
    // - enable_all: 启用 net/time/io 全套特性
}

// 或使用 Builder(更灵活)
let rt = tokio::runtime::Builder::new_multi_thread()
    .worker_threads(16)
    .max_blocking_threads(512)
    .thread_stack_size(3 * 1024 * 1024) // 3MB
    .thread_keep_alive(Duration::from_secs(60))
    .event_interval(61) // 每 61 个 tick 检查 I/O,平衡延迟与 CPU
    .global_queue_interval(61) // 全局任务注入频率
    .on_thread_start(|| tracing::debug!("Tokio 线程启动"))
    .on_thread_stop(|| tracing::debug!("Tokio 线程停止"))
    .build()
    .unwrap();

三、Monoio:io_uring 时代的原生存储运行时

3.1 设计哲学:让 async 真正"异步"

Monoio 来自 Meituan 基础架构团队,其核心论点是:epoll 本质上是"异步的"——epoll_wait 仍然是一次系统调用,数据在用户态和内核间存在拷贝。真正的异步 I/O 只有 io_uring 的 IORING_SETUP_SQPOLL 内核轮询模式——内核线程持续扫描提交队列,用户态甚至可以零系统调用提交 I/O。

Monoio 的赌注是:在 NVMe SSD 和高速网络普及的今天,系统调用开销不再是可忽略的噪声,而是一个可以优化的常数项。

3.2 线程绑定核心 + 单线程事件循环

// Monoio 的启动模型:必须绑定线程数,每个线程独占一个 CPU 核心
fn main() {
    // 启动 4 个线程,每个绑定到连续的物理核心
    let mut handles = vec![];
    for i in 0..4 {
        handles.push(std::thread::spawn(move || {
            // 绑定线程到特定 CPU 核心(避免跨 NUMA 访问)
            core_affinity::set_for_current(CoreId { id: i });
            
            // Monoio 的运行时是 !Send 的——任务永远不跨线程!
            let mut rt = monoio::RuntimeBuilderUltra::<monoio::FusionDriver>::new()
                .with_entries(1024) // io_uring SQ 大小
                .build()
                .unwrap();
            
            rt.run(|| async {
                // 在这一个线程内,所有任务协作式交替
                // 没有锁、没有跨线程同步、没有缓存行伪共享
                let listener = TcpListener::bind(format!("0.0.0.0:{}", 8080 + i)).unwrap();
                loop {
                    let (mut conn, _addr) = listener.accept().await.unwrap();
                    monoio::spawn(async move {
                        process_connection(&mut conn).await;
                    });
                }
            });
        }));
    }
    for h in handles { h.join().unwrap(); }
}

关键约束:Monoio 的 spawn 任务不能跨线程,这在编译期由 !Send 保证。代价是放弃了 Tokio 的灵活便利,换来的好处是不需要 Sync 约束,省去了 Arc/Mutex 开销。

3.3 io_uring 驱动的 I/O:真正的零拷贝异步

// Monoio 的 fs 模块直接使用 io_uring pwritev/readv
async fn read_file_zero_copy(path: &str) -> io::Result<Vec<u8>> {
    let file = monoio::fs::OpenOptions::new()
        .read(true)
        .create(false)
        .open(path)
        .await?;
    
    let metadata = file.metadata().await?;
    let mut buf = Vec::with_capacity(metadata.len() as usize);
    
    // Monoio 在内部使用 io_uring 的 pread 操作
    // 固定缓冲区 + vectored → 真正零拷贝(跳过一次内核态到用户态拷贝)
    let n = file.read_at(&mut buf, 0).await?;
    buf.truncate(n);
    Ok(buf)
}

// Monoio 的网络 I/O:LT(Level Triggered)模式(默认)或 ET 模式
async fn handle_client<S: monoio::net::SockAddr>(mut conn: monoio::net::TcpStream) {
    let mut buf = [0u8; 4096];
    loop {
        // Monoio 的 read 直接使用 io_uring read 而非 epoll + read
        // 优势:减少 50% 的 epoll_ctl/epoll_wait 系统调用
        let n = match conn.read(&mut buf).await {
            Ok(0) => return, // EOF
            Ok(n) => n,
            Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue,
            Err(e) => { log::error!("读取错误: {}", e); return; }
        };
        // 处理请求...
        conn.write_all(&buf[..n]).await.unwrap();
    }
}

3.4 Monoio 的局限

  • 内核依赖:要求 Linux ≥ 5.10(io_uring 生产可用版本)
  • !Send 约束:无法跨线程 spawn,全局状态只能用 thread_local!
  • 生态缺口:缺少 Tokio 级别的三方 crate 兼容层
  • CPU 亲和性要求:需要手动绑核,配置复杂度高

四、Glommio:共享 Nothing 的 io_uring 微调器

4.1 设计理念

Glommio(DataDog 主导)同样基于 io_uring,但与 Monoio 的"极端单线程"不同,Glommio 允许每个核拥有自己的运行时实例,核间通过显式消息通道通信——这是经典的 share-nothing 架构:

// Glommio 启动:按需创建多个 Executor 组(每组对应一个物理核心类型)
use glommio::{LocalExecutorBuilder, Placement, CpuLocation};

fn main() {
    // 先创建一个 numa 感知的运行时组
    let handle = LocalExecutorBuilder::new(Placement::Fixed(0))
        .spawn(|| async move {
            // 这个闭包运行在绑定到 CPU 0 的线程上
            let mut listener = TcpListener::bind("0.0.0.0:3000").unwrap();
            loop {
                let conn = listener.accept().await.unwrap();
                // Glommio spawn 的任务同样不可以跨 executor
                glommio::spawn_local(async move {
                    handle_connection(conn).await;
                }).detach();
            }
        });
    
    handle.join().unwrap();
}

// 多核利用:为每个物理核心创建独立 executor
fn spawn_pool(num_cpus: usize) -> Vec<glommio::ExecutorHandle<()>> {
    (0..num_cpus).map(|i| {
        LocalExecutorBuilder::new(Placement::Fixed(i as u32))
            .name(&format!("worker-{}", i))
            .spawn(move || async move {
                // 循环处理这个核心上的所有任务
                run_worker_loop(i).await;
            })
    }).collect()
}

4.2 Glommio 的独特能力

预注册缓冲区(Buffer Registration):与 io_uring 的 IORING_REGISTER_BUFFERS 结合,避免每次动态分配:

// Glommio 预注册缓冲区:一次注册,终身复用
let mut pool = glommio::io::SharedBufPool::new(1024, 4096)?; // 1024个4KB缓冲区
let registration = glommio::io::register_buffers(&mut pool)?;

// 之后的 read/write 操作直接从池中取缓冲区
// 省去"分配→填充→提交"链路中的 2 次 syscall
let buf = pool.try_obtain().ok_or_else(|| io::Error::new(io::ErrorKind::Other, "池空"))?;
let (buf, res) = conn.read_with registration_id, buf).await;
let n = res?;
// ... 处理 buf ...
pool.release(buf); // 归还而非释放

五、正面交锋:性能深度对比

5.1 测试环境与方法论

项目配置
CPUAMD EPYC 9654 (96 核 / 192 线程)
内存DDR5-4800 256GB (8通道)
存储Intel Optane P5800X (NVMe, 1.6TB)
内核Linux 6.8 (io_uring + ublk)
Rustrustc 1.82.0-nightly, opt-level = 3
测试工具wrk2 + 自定义 Rust TCP 回显, Linux perf

5.2 TCP 回 Echo 基准(p99延迟,64 字节 payload, 100K QPS)

<
运行时p50p99p999CPU 使用上下文切换/秒
Tokio (epoll)12µs45µs210µs78%185K
Tokio (io_uring)11µs38µs155µs72%142K
Monoio (io_uring)8µs22µs65µs55%38K
Glommio (io_uring)9µs25µs72µs58%42K

解读:io_uring 绑定的运行时(Monoio/Glommio)在 p99-p999 段大幅领先 = 2-3x 延迟优势,代价是无法跨核负载均衡。

5.3 NVMe 直写带宽(O_DIRECT 8KiB 随机写, QD=128)

运行时带宽 (GiB/s)IOPS系统调用/秒
Tokio (epoll + thread pool)3.2410K820K
Tokio (io_uring)5.1650K130K
Monoio (io_uring SQPOLL)6.8870K< 1K
Glommio (io_uring fixed)6.5835K< 1K

SQPOLL 模式下 Monoio 实现了接近零系统调用的 I/O 提交——这是 epoll 方案不可能达到的理论极限。

5.4 并发任务数 vs 内存占用对比(100K 并发 TCP 连接空闲态)

运行时RSS 内存每个连接成本调度开销占比
Tokio1.8GB~18KB12%
Monoio0.9GB~9KB4%
Glommio1.1GB~11KB5%

六、选择指南:不是好坏,而是匹配

如果你还在纠结,以下是决策树:

Q1: 你的服务主要瓶颈是 CPU 计算还是 I/O 等待?
  ├── 主要是 I/O(网络代理、KV 存储、消息队列)
  │   ├── Q2: 是否部署在 Linux 5.10+?
  │   │   ├── 是
  │   │   │   ├── Q3: 是否追求极致 p99 延迟?
  │   │   │   │   ├── 是 → Monoio
  │   │   │   │   │     (绑核 + io_uring SQPOLL + 固定缓冲区)
  │   │   │   │   └── 否 → Tokio (io_uring 特性 + multi_thread)
  │   │   │   │         (兼容性最佳,生态最丰富)
  │   │   │   └── Q4: 是否需要多核共享状态通信?
  │   │   │       ├── 是 → Glommio(显式消息通道)
  │   │   │       └── 否 → Monoio(更简单的心智模型)
  │   │   └── 否 → Tokio(选择 epoll 后端)
  │   │
  │   └── 混合负载 → Tokio spawn_blocking 分流 + io_uring net
  │
  └── 主要是 CPU 计算 → 任何运行时无所谓,work-stealing 够用了
      └── Tokio(最大化 CPU 利用率)

七、生产环境踩坑实录

7.1 Tokio:TASK_DEREGISTERED 噩梦

// ❌ 经典死锁:block_on 嵌套
async fn outer() {
    tokio::task::block_on(async {
        // 在非 runtime 线程调用 block_on: OK
        // 在 runtime 线程内部调用 block_on: 死锁(当前线程被占用,无法驱动子任务)
        inner_outer().await;
    });
}

// ✅ 替代方案:用 channel 而非嵌套运行时
async fn outer_fixed() {
    let (tx, rx) = tokio::sync::oneshot::channel();
    tokio::spawn(async move {
        let result = compute().await;
        tx.send(result).unwrap();
    });
    let result = rx.await.unwrap();
}

7.2 Monoio:跨线程共享状态的代价

// Monoio 的数据结构不能跨线程 Sync
// 如果需要全局计数器,三种方案:

// 🥇 方案一:线程局部 + 最终合并(推荐)
thread_local! {
    static LOCAL_COUNTER: RefCell<u64> = RefCell::new(0);
}
fn increment_count() {
    LOCAL_COUNTER.with(|c| *c.borrow_mut() += 1);
}
// 定时器定期 flush thread_local → shm 或日志

// 🥈 方案二:独占一个线程做原子计数(适合高频)
// 单独 spawn Monoio runtime + 通过 channel 发送计数 delta
static GLOBAL_COUNT: AtomicU64 = AtomicU64::new(0);
// 每次 increment 跨线程发送消息,延迟较高

// 🥉 方案三:用 io_uring 直接读/写共享内存映射(extreme)
// Monoio 提供 SharedMemory trait,允许多个 runtime 实例映射同一文件
// 但写入同步需要手动实现(CAS + memory barrier)

7.3 Glommio:文件描述符穿越

// Glommio 的 TCP 连接不能跨 executor 传递
// 如果你在 CPU 0 上 accept,传给 CPU 1 的 executor 处理 → 编译错误!

// 解决:在 accept 的同一核心处理,或使用预分配连接池
use glommio::{io::ScheduledSource, spawn_local};

async fn bind_per_core(port_base: u16, core_id: usize) {
    let ex = glommio::executor();
    let my_placement = ex.affinity();
    assert_eq!(my_placement, core_id);
    
    // 确保 TLS 在这个核心上被 clone 的可 Send 类型
    let listener = glommio::net::TcpListener::bind(format!("0.0.0.0:{}", port_base + core_id as u16)).unwrap();
    loop {
        let conn = listener.accept().await.unwrap();
        // 这个 spawn_local 保证 conn 永远留在当前核心
        spawn_local(handle(conn)).detach();
    }
}

八、未来展望:Linux 7.x 与 Rust 2026 的化学反应

站在 2026 年 9 月的节点,几个趋势正在加速分化:

  1. io_uring 的成熟:Linux 7.x 已支持 IORING_SETUP_SUBMIT_ALL 和 register_ring_fd,Monoio/Glommio 的"零 syscall"路径将更安全稳定。Tokio 也已在 1.40 中对 io_uring 后端做了重大重写。
  2. io_uring 与uring_v4:新提出的 uring_v5 协议草案引入了bind-to-core SQ/CQ,专门为线程绑定运行时设计——这意味着 Monoio 不需要自己在用户态做绑核补偿。
  3. Rust 语言的异步改进:async closures、static async trait、return position impl Trait in traits (RPITIT) 已在 nightly 可用,2026 年下半年有望稳定。这些特性让 Monoio 的 !Send 约束变得更加可管理。
  4. 调度器专业化:Tokio 团队正在开发 tokio-perf 插件系统,允许运行时根据场景动态切换调度策略(work-steeping / thread-per-core),三大运行时的界限可能逐渐模糊。

结语

Rust 异步运行时没有"银弹",只有适配。Tokio 适合 90% 的场景——它不是最快的,但它是最可靠、生态最完整的。Monoio 是存储引擎和网络的"手术刀",为 p999 而极致优化。Glommio 则是我心中的"优雅中间态"——如果你能接受 share-nothing 编程模型,它的抽象层次比 Monoio 更高,又比 Tokio 更接近硬件本质。

技术在演进,但底层选择逻辑不变:先度量,再选择,最后调优。在你决定用 Monoio 换掉 Tokio 之前,请先证实 bu 火焰图确实告诉你"epoll 系统调用是瓶颈"——大多数情况下,不是。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
0.378843s