Skip to content

第18章 跨 runtime 通信与多 runtime 架构

"最危险的 Tokio 用法,是让两个 runtime 互相 await。" —— 笔者

本章要点

  • 一个进程可以起多个 runtime——源码上没有任何单例限制,Runtime::new() 想创几个创几个
  • Handle 是跨 runtime 的通行证Handle::current() 基于 thread-local contexthandle.spawn(fut) 可以在任意线程把 Future 扔到对应 runtime
  • 双 runtime 常见架构一个 IO-heavy runtime(少 worker + IO driver) + 一个 CPU-heavy runtime(= CPU 核数)——比 spawn_blocking 更可控
  • 致命陷阱在 runtime A 的 Future 里 block_on runtime B 的 Future——A 的 worker 线程被锁住、如果链路形成环直接死锁
  • 正确姿势:两 runtime 之间用 tokio::sync::mpsc / oneshot 通信——永远不要让一个 runtime 阻塞等另一个 runtime 的结果

18.0 一个问题的两种语义

"多 runtime" 是有歧义的——要分清两种场景

场景 A:同一个进程里起两个 tokio::runtime::Runtime 实例。完全合法、源码上没拦你。常用于"IO 和 CPU 分池"、"测试隔离"、"库内部自持 runtime"等。

场景 B:不同依赖库各自拉了 Tokio、互相不兼容(比如 Tokio 1.x 和 0.2 混用)。极少见——现代 Rust 生态基本统一到 1.x 了——但历史项目升级时会遇到、需要 tokio-compat 之类的桥接层。

本章只讲场景 A——主动设计多 runtime 架构

18.1 为什么会有"多 runtime"这个需求

回想第 16 章:Tokio 的 worker pool 擅长 IO-bound(短 poll 快返回)、不适合 CPU-bound(长 poll 堵 worker)。我们当时给的答案是 spawn_blocking + Rayon。为什么还需要第二个 runtime?

因为 Rayon 不支持 async——Rayon 的 closure 是同步的、里面不能 .await。但如果你的 CPU 密集任务内部又需要做 async IO(比如"算完一个 hash 写到对象存储"),Rayon 就力不从心。这时候就要"两个 runtime"——一个调度 IO 请求的 async 代码、一个跑 CPU 密集的 async 代码(带 IO 尾巴)、彼此隔离、互不堵塞。

另一个场景库内部自持 runtime。比如你写了一个 my-db crate、用 async 做网络通信,但不想"强迫用户也用 Tokio"——库内部起一个独立 runtime、对外暴露同步 API。Rust 生态里 reqwest::blockingredis::Client::blocking 都是这个模式。

第三个场景测试隔离。每个测试用例起自己的 runtime,避免共享状态污染(比如一个测试注册了 mock clock、不能影响另一个)。#[tokio::test] 宏本质就是 "每个测试 fn 包一个临时 runtime"。

18.2 Runtime 和 Handle:两级抽象

打开 tokio/src/runtime/handle.rs

rust
#[derive(Debug, Clone)]
pub struct Handle {
    pub(crate) inner: scheduler::Handle,
}

Handle 是 runtime 的 Clone 引用——你可以任意复制它、传到任意线程。而 Runtime 本身是拥有者——它 drop 时会关掉 worker、释放资源。

Handle 能做什么

  • handle.spawn(fut):把 fut 扔到对应 runtime;
  • handle.spawn_blocking(f):扔到 blocking pool;
  • handle.block_on(fut)同步阻塞当前线程直到 fut 完成;
  • handle.enter():设置当前线程的 TLS context——让其他非 Tokio 代码可以在这个线程上构造 Sleep/TcpStream 等需要 runtime 的对象。

这四个方法涵盖了跨 runtime 的所有交互模式

Handle::current() 的定位

rust
#[track_caller]
pub fn current() -> Self {
    Handle {
        inner: scheduler::Handle::current(),
    }
}

从 thread-local 里读——每个 Tokio worker 线程启动时、会把自己归属的 Handle 存到 TLS。在 worker 线程上调 Handle::current() 拿到那个 runtime 的 handle。在非 worker 线程调会 panic(除非你手动 enter() 过)。

关键观察TLS 里只能有一个"当前 runtime"——这意味着你在同一个线程里不能"同时属于两个 runtime 的 context"。这一条在下面的陷阱里会反复出现。

18.3 多 runtime 如何通信:三条推荐路径

两个 runtime R1 和 R2、各自跑着一堆 task,它们之间怎么交换数据?

路径 1:tokio::sync::mpsc(首选)

rust
let (tx, mut rx) = tokio::sync::mpsc::channel::<Job>(1024);

// R1 里发
r1.spawn(async move {
    tx.send(job).await.unwrap();
});

// R2 里收
r2.spawn(async move {
    while let Some(job) = rx.recv().await {
        process(job).await;
    }
});

为什么 mpsc 安全?它只用 atomic + channel 内部的 waker、不依赖任何 TLS 状态——tx 在任何 runtime 的任何 worker 上 send 都行、rx 只要有人 poll 就 work。这是两个 runtime 通信的黄金通道

buffer size 选型

  • 太小 → 发送方 backpressure 明显、R1 会被阻塞在 send().await
  • 太大 → 内存占用 + 消费延迟变高。

经验值大约等于"R2 worker 数 × 每 worker 并发处理数"——比如 R2 有 8 个 worker、每个同时处理 16 个 job,buffer = 128。压力测试时观察 rx 的积压决定是否调整。

路径 2:tokio::sync::oneshot(请求-响应模式)

rust
#[derive(Debug)]
struct Request { data: Data, tx: oneshot::Sender<Response> }

// R1 发请求
let (tx, rx) = oneshot::channel();
req_tx.send(Request { data, tx }).await?;
let resp = rx.await?;  // ← 等 R2 返回

// R2 处理
while let Some(Request { data, tx }) = req_rx.recv().await {
    let resp = compute(data).await;
    let _ = tx.send(resp);
}

这是跨 runtime 的标准 RPC 模式——请求发出、拿到一个 oneshot rx、await 它。千万别用 block_on(rx)、用 .await 让出 R1 的 worker、等响应到了再被唤醒。

路径 3:共享 Arc<Mutex<State>>

最简陋但最直接。两个 runtime 共享一个 Arc<tokio::sync::Mutex<T>>——各自 .lock().await 访问。

rust
let state = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
let s1 = state.clone();
let s2 = state.clone();
r1.spawn(async move { s1.lock().await.insert("x", 1); });
r2.spawn(async move { let _ = s2.lock().await.get("x"); });

关键是用 tokio::sync::Mutex 不用 std::sync::Mutex——后者 lock 时会阻塞线程、在 runtime 里调 = 堵 worker。但也要注意:tokio::sync::Mutex 的 notify 机制依赖 atomic + Waker、不绑定到具体 runtime——所以 R1 释放锁、R2 获得锁是 OK 的。

18.4 block_on 的死亡陷阱

整本书最危险的一条陷阱

rust
// ⚠️ 反面教材
r2.spawn(async {
    // 从 R2 内部 block_on R1
    let handle_r1 = HANDLE_R1.clone();
    let result = handle_r1.block_on(async {
        fetch_from_r1().await  // ← R1 的一个 IO 操作
    });
    // ...
});

这段代码的问题

  1. handle_r1.block_on(...)当前线程(= R2 的一个 worker)上同步等 R1 完成某 Future;
  2. 但 R2 的这个 worker 线程被完全锁死——期间不能 poll 别的 task;
  3. 如果 R1 的这个 Future 需要等 R2 做点什么(比如回调过来)—— 死锁
  4. 即便不死锁,R2 少了一个 worker、整体吞吐直线下降。

block_on 的本质:它是"跨 runtime 边界的同步入口"——只能用在"真正不在任何 runtime 里的线程"(比如 main() 开头、非 Tokio 调用 Tokio 的 FFI 边界)。绝不能在一个 runtime 的 worker 上 block_on 另一个 runtime 的 Future

源码证据

Tokio 自己在 block_on 实现里检查当前线程是否已在 runtime context 里——如果在、会 panic(防死锁):

thread 'tokio-runtime-worker' panicked at
'Cannot start a runtime from within a runtime.
This happens because a function (like `block_on`) attempted to block
the current thread while the thread is being used to drive
asynchronous tasks.'

这个 panic 很多人第一次见到会以为是 Tokio 的 bug——其实是Tokio 的保护。真的有极少数场景需要从 worker 里同步等另一个 runtime 的结果——那时候唯一的合法路径是先 spawn_blocking 把线程从 runtime 里"放出来"、在那个 blocking 线程上block_on

rust
// 需要这么绕:
let result = tokio::task::spawn_blocking(move || {
    handle_r1.block_on(fetch_from_r1())
}).await?;

但这个 pattern 本身就意味着你的架构有问题——多数情况下改用 mpsc 发请求、oneshot 收响应才是正道。

18.5 真实生产架构:IO runtime + CPU runtime

这是多 runtime 最经典的应用。我在几家公司都见过、架构大致是:

rust
// main.rs
fn main() -> Result<()> {
    // runtime 1: IO-bound, 2 个 worker 足够
    let io_rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .thread_name("io-worker")
        .enable_all()
        .build()?;

    // runtime 2: CPU-bound, 按 CPU 核数
    let cpu_rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(num_cpus::get())
        .thread_name("cpu-worker")
        .enable_all()
        .build()?;

    let cpu_handle = cpu_rt.handle().clone();

    // 在 io_rt 上跑主业务
    io_rt.block_on(async move {
        start_server(cpu_handle).await
    })
}

为什么 IO runtime 只要 2 个 worker

  • IO 任务的 poll 很轻(几微秒)——worker 绝大部分时间在 park 等 epoll;
  • 2 个 worker 已经能打满几万 QPS 的 IO 吞吐
  • 少 worker = 少 context switch = 低延迟抖动。

为什么 CPU runtime = 核数

  • CPU 任务是真的用满 CPU——worker 数 = 核数让每个任务都能独占一个 physical core;
  • 多了反而 context switch 带来抢占、p99 劣化。

为什么不用 Rayon

  • CPU 任务内部有 async IO(写入对象存储、调 API)——需要能 .await
  • Tokio 的 runtime 原生支持 async IO、Rayon 不行。

数据流向

典型请求的生命周期:

  1. 客户端请求打到 io-worker(一个 axum handler);
  2. io-worker 解析请求、决定需要一个 CPU-heavy 步骤(比如图像处理);
  3. io-worker 向 CPU runtime 发 job:通过 mpsc 发 Request { data, tx }
  4. io-worker rx.await——当前 task 让出 io-worker、io-worker 去处理别的 IO;
  5. cpu-worker 收到 Request、做图像处理(期间可能还有 .await 的子 IO);
  6. cpu-worker 把结果通过 oneshot tx.send(resp) 送回;
  7. 原 io-worker 上的 task 被唤醒、继续组装响应发给客户端。

整个链路里没有一处 block_on、没有一处 spawn_blocking——纯 async、纯 channel这是多 runtime 的理想姿态

18.6 Handle::enter() 的独特用途

有一个容易忽略的方法——handle.enter() 返回一个 EnterGuard、在 guard 存活期间当前线程的 TLS context = 那个 runtime。

这什么时候有用?当你在非 Tokio 代码里需要构造一个需要 runtime 的对象。最典型的是 Sleep

rust
fn some_non_async_function() {
    let handle = RUNTIME.handle();
    let _guard = handle.enter();
    // 现在可以构造 Sleep 了——它会从 TLS 拿到 runtime
    let sleep = tokio::time::sleep(Duration::from_secs(1));
    // 但注意:这个 Sleep 还是得在 runtime 里 poll 它才能 tick
}

常见使用场景

  • 库的 sync API 包装 asyncreqwest::blocking::get 内部就这样;
  • FFI 边界:C 代码回调进 Rust、需要在回调里创建 Sleep/TcpStream;
  • 测试 setup:测试 fixture 需要构造一些 runtime 依赖的对象、但测试本身还没进入 async 代码。

不是"切换 runtime 身份"——只是"给 TLS 设一个默认值"、方便构造对象。真正跑 Future 还是得靠 handle.spawnhandle.block_on

18.7 两个 runtime 共享 IO driver?不可能

有人会想——"既然两个 runtime 都要 IO driver、能不能共享一个 driver 省资源"?答案是不能

源码上,IoDriverRuntime::Inner 的一部分、每个 runtime 各自持有。理由:

  • IO driver 的 Poll 内部状态(注册的 fd、ScheduledIo slab)和 scheduler 绑死——一个 driver 服务两个 scheduler 的话、wake 时不知道该 schedule 到哪个 runtime 的 run queue
  • 实测两个 epoll instance 的系统资源开销可以忽略(fd table 里多一个 entry);
  • 隔离的好处远大于共享——两个 runtime 出问题互不传染。

所以两 runtime 架构下、你会有两个独立的 epoll/kqueue instance——这是可以接受的代价。

实战细节:两个 runtime 的 shutdown 顺序

生产部署时还有个小但关键的问题——shutdown 顺序。graceful shutdown 时你希望:

  1. 先关 IO runtime 的入口(不再接新连接);
  2. 让还在处理的请求跑完
  3. 等 CPU runtime 里的 job 全部排空
  4. 关 CPU runtime;
  5. 关 IO runtime(此时它的 task 都已经结束)。

反过来会怎样?先关 CPU runtime——IO runtime 里正在等 oneshot::Receiver::recv().await 的 task 会永远挂着(对面 sender 被 drop、其实它能收到 Err(RecvError)、但代码要处理这个分支),或者 IO runtime 正在尝试 send 到 CPU runtime 的 mpsc、发现对面关了直接返回错误——用户看到的就是一批请求突然 500

代码上的具体写法:保留两个 Runtime 对象在 main 里、按顺序 drop()——Runtime::drop 会等所有 task 结束或超时。需要更精细控制的话用 Runtime::shutdown_timeout(dur)——超过 dur 强制关、避免 hang。

这套 shutdown orchestration 每次设计新系统都要想一遍、而且很容易被写 demo 时跳过——等到上生产出事就晚了。一个好习惯:跟着业务代码一起写 shutdown 路径、别拖到最后。

18.8 tokio-uring 作为"第三个 runtime"

我们在第 16 章提过 tokio-uring——它是独立于主 Tokio 的另一个 runtime、专为 io_uring 优化。一些 Linux-only 的存储密集服务会这样用:

  • 主 runtime:跑业务逻辑(axum、gRPC);
  • tokio-uring runtime:跑磁盘 IO(读写 TB 级文件);
  • 两者之间通过 mpsc 通信——和上面 IO/CPU 分池是同一个套路。

这种"多 runtime 专用化"是 Tokio 生态的一个演进方向:不同类型的工作用不同的 runtime、每个都能做到最优——而不是试图在一个 runtime 里塞下所有需求。

一个历史注脚:async-std 曾经也在这条路上

2019-2021 年间,async-std 团队曾经提出过"让所有 async 代码共享一个 default global runtime"的激进方案——用户不用显式创建 runtime、import crate 就能 spawn。设计上听起来优雅、但实操中问题很多:不同库需要的 worker 数不一样、测试无法隔离、IO 模型不可替换。最终 async-std 的用户多数还是回到"显式管理 runtime"的路子上。

Tokio 的选择更务实——Runtime 是一等公民、Handle 是一等公民、你用几个你决定。这种"不替用户做决定"的克制,让它能在库 + 应用 + 混合场景都找到自己的位置。很多 Rust async 的设计细节,都是在这种"灵活 vs 简单"的 tension 里打磨出来的——我们今天读 Tokio 源码的时候、实际上是在读一部"真实世界反复验证过的工程决策"。

18.9 和其他书的呼应

Vue 3 设计与实现》第 18 章讲过 Vue 的 multiple app instance——一个页面里可以有多个 Vue app、各自独立管理 reactivity graph、互不干扰。和多 runtime 的思路一模一样——每个 isolation 边界都是一个独立的调度/反应系统。两个生态在"可组合的独立实例"这条路上殊途同归。

Rust 编译器与运行时揭秘》第 13 章讲过 rustc 的 query system——每个 session 是一个独立的"小 runtime"、session 间不共享 caches。同样的思想——隔离 > 共享、可组合 > 单体——是所有严肃 runtime 设计的共同信条。

**《vLLM 源码剖析》**里的 multi-engine 架构——一个 LLM 服务可以同时持有多个 LLMEngine 实例、每个管自己的 GPU 集群——这就是"多 runtime"的又一个变种每个子系统管自己的一摊事、消息传递协作。如果你同时读过三本书、就会看到这个模式在数不清的系统里以各种形态出现——分治 + 消息传递、是分布式系统之外、单进程内部的"轻量级分布式"设计

18.10 决策清单:什么时候真的需要多 runtime

多 runtime 不是银弹。大部分项目一个 runtime 够用。真正需要分的信号:

  1. CPU 密集 + 内部 async IO——这是最有力的理由、Rayon 无能为力;
  2. 严格的延迟隔离(比如"IO 请求 p99 不能被 CPU 任务影响超过 5ms")——分池物理上隔离;
  3. 库内部自持 runtime——对外提供同步 API,不强迫调用方用 async;
  4. 混合 io_uring 和 epoll——主 runtime epoll、存储 runtime uring;
  5. 测试隔离——每个测试一个临时 runtime(#[tokio::test] 宏已经帮你做了)。

不是这些信号但强行分 runtime?成本:

  • 额外的 worker 线程数(可能和 CPU 核数冲突);
  • 通信 overhead(mpsc 比直接 spawn 慢几微秒);
  • 认知负担(新人看到两个 Handle 会懵);
  • 调试难度(两个 runtime 各有 metrics、console 要分别连)。

先单一 runtime + spawn_blocking/Rayon、真扛不住再分——这是我建议的演进路径。

18.10½ 一个具体的迁移故事:从单 runtime 到双 runtime

某视频转码服务最初是单 runtime 架构:每个请求 spawn_blocking 做转码(一个视频要 30 秒)、转码完写到对象存储。低峰期 QPS 100 没问题、高峰期 QPS 500 就出事:

  • blocking pool 线程数冲到上限 512
  • p99 延迟从 30 秒涨到 150 秒(排队);
  • CPU 利用率 100%,但 IO 请求也在同一机器上排队(因为 worker 都忙转码 + ffmpeg 要占大量内核时间)。

他们做的重构:把转码拆到独立 CPU runtime、IO runtime 专心做 HTTP + 对象存储上传。改完以后:

  • CPU runtime 的 worker = 16(机器 16 核)、所有 worker 100% 用满做转码
  • IO runtime 的 worker = 2、p99 掉回 35 秒(排队时间从 120 秒 → 5 秒);
  • 整机吞吐从 QPS 500 涨到 QPS 2000——不是因为 CPU 算得更快、而是因为之前有大量 CPU 时间被浪费在 context switch(512 blocking 线程 vs 16 核的过度 subscribe)。

这就是多 runtime 的真实价值——不是"加一个 runtime 就快了"、而是"让每种负载跑在它擅长的配置下"。迁移的工作量大概两个工程师两周——评估 → 重构 → 灰度 → 全量上线。这个投入很快就被节省下来的机器赚回来(同等 QPS 少开一半的实例)。

18.10¾ 一个你可能没想过的视角:runtime 是一个"可编程的 scheduler 容器"

回顾本书走到这里——从 Future、Waker、Runtime、Scheduler、Task、IO Driver 一路讲下来,你应该已经意识到:Tokio 的 runtime 本质上是"一堆 worker 线程 + 一套调度策略 + 一些资源 driver"的组合。既然是组合、就能根据工作负载自由调配——这也是多 runtime 架构的终极理论基础。

你可以这样组合

  • Runtime A:2 worker + IO driver + Time driver——做所有网络 IO;
  • Runtime B:16 worker + 关掉 IO driver(enable_time only)——做 CPU 密集但有 sleep 的任务;
  • Runtime C:1 worker current_thread——做对延迟极度敏感的 low-latency 小任务(单线程没 work-stealing 抖动);
  • blocking pool:留给 FFI 和同步 IO。

每一种组合都是对"哪些工作特性应该共享调度策略"的回答。传统语言/运行时给你一个 固定调度器、Tokio 给你一个可编程的调度器容器——你来定。

**这种"低层次但高度可组合"的哲学、和 Rust 语言本身"没有 GC、没有内置运行时"的哲学一脉相承——所有政策交给你、所有机制给你最锋利的工具。学会这种组合式思维、你对系统设计的抽象能力会远超只会用单一 runtime 的人。

进一步类比:runtime 之于 async Rust、正如 GC 调参之于 Java

Java 程序员都知道"调 GC"——年轻代多大、老年代多大、CMS 还是 G1、pause target 多少。Tokio 的 Runtime Builder 就是 Rust async 的 GC 调参——worker 数、blocking 上限、keep_alive、enable_io、enable_time——每一个都是一次"调度策略 × 工作负载"的匹配。

两者最大的不同:Java GC 参数调错了只是慢、不会崩Tokio 参数调错了可能死锁(比如单 worker runtime 里用 block_in_place)。这意味着 Tokio 用户要承担更多"理解自己系统"的责任——好处是一旦理解、你能做到 GC 语言做不到的性能。

这也是为什么我反复强调"读 Tokio 源码":你不是在读一个库、你在学一种从底层构建运行时的思维。这种思维,在你之后去读 Kubernetes 调度器、Flink 的 task manager、Ray 的 object store、甚至 ClickHouse 的 async I/O 层时,会一次又一次地帮到你——所有高性能分布式系统,本质上都是"按负载 shard 调度器 + 消息传递协作"。你今天在 Tokio 里学到的,是打开这些系统大门的钥匙。

最后一点:保持对"引入复杂度"的敬畏

写到这里想补一句话——多 runtime 是一把锋利的刀、也因此最容易伤到自己。我见过太多团队一听"多 runtime"就跃跃欲试、还没理清自己单 runtime 到底卡在哪里就上手分——结果花了几周搭了双 runtime、加了 mpsc 桥、加了 shutdown 编排,然后发现性能没提升、调试复杂度反而翻倍。然后再花几周删回去。

引入复杂度是有代价的——代码量、调试难度、新人学习曲线、运维操心程度。一个好的工程师问自己三个问题:我有证据证明单 runtime 是瓶颈吗?(metrics)我能画出改架构后每个组件的预期行为吗?(设计)我能说清楚回滚路径吗?(风险)——三个答案都"是"再动手。否则就是用"多 runtime 架构"这个听起来很厉害的名词、自欺欺人地给自己加工作

保持对复杂度的敬畏、让每一次架构升级都有真实数据支撑——这是读完这本书你最该带走的元技能。源码教的是机制,这种判断力教的是成熟

18.11 本章小结

带走三件事:

  1. 一个进程多个 runtime 完全合法——用 Handle 跨 runtime 通信、首选 mpsc/oneshot绝不在一个 runtime 的 worker 里 block_on 另一个 runtime
  2. 双 runtime 的经典架构是"IO runtime(少 worker) + CPU runtime(= 核数)"——解决"CPU 密集又带 async IO"这个 Rayon 搞不定的场景
  3. 多 runtime 是工具、不是时尚——单 runtime + spawn_blocking 已经解决 95% 场景。分 runtime 的成本在调试和认知上、收益必须明确才值

下一章进入性能调优与典型陷阱——把前 18 章讲过的知识串成一条"性能故障排查流程":从压测设计、火焰图解读、到 coop budget、lifo_slot、worker 亲和性——常见性能坑和对应的调优手段一网打尽。


延伸阅读

基于 VitePress 构建