Appearance
第18章 跨 runtime 通信与多 runtime 架构
"最危险的 Tokio 用法,是让两个 runtime 互相 await。" —— 笔者
本章要点
- 一个进程可以起多个 runtime——源码上没有任何单例限制,
Runtime::new()想创几个创几个 - Handle 是跨 runtime 的通行证:
Handle::current()基于 thread-local context、handle.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::blocking、redis::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 操作
});
// ...
});这段代码的问题:
handle_r1.block_on(...)在当前线程(= R2 的一个 worker)上同步等 R1 完成某 Future;- 但 R2 的这个 worker 线程被完全锁死——期间不能 poll 别的 task;
- 如果 R1 的这个 Future 需要等 R2 做点什么(比如回调过来)—— 死锁;
- 即便不死锁,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 不行。
数据流向
典型请求的生命周期:
- 客户端请求打到 io-worker(一个 axum handler);
- io-worker 解析请求、决定需要一个 CPU-heavy 步骤(比如图像处理);
- io-worker 向 CPU runtime 发 job:通过 mpsc 发
Request { data, tx }; - io-worker
rx.await——当前 task 让出 io-worker、io-worker 去处理别的 IO; - cpu-worker 收到 Request、做图像处理(期间可能还有
.await的子 IO); - cpu-worker 把结果通过 oneshot
tx.send(resp)送回; - 原 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 包装 async:
reqwest::blocking::get内部就这样; - FFI 边界:C 代码回调进 Rust、需要在回调里创建 Sleep/TcpStream;
- 测试 setup:测试 fixture 需要构造一些 runtime 依赖的对象、但测试本身还没进入 async 代码。
不是"切换 runtime 身份"——只是"给 TLS 设一个默认值"、方便构造对象。真正跑 Future 还是得靠 handle.spawn 或 handle.block_on。
18.7 两个 runtime 共享 IO driver?不可能
有人会想——"既然两个 runtime 都要 IO driver、能不能共享一个 driver 省资源"?答案是不能。
源码上,IoDriver 是 Runtime::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 时你希望:
- 先关 IO runtime 的入口(不再接新连接);
- 让还在处理的请求跑完;
- 等 CPU runtime 里的 job 全部排空;
- 关 CPU runtime;
- 关 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 够用。真正需要分的信号:
- CPU 密集 + 内部 async IO——这是最有力的理由、Rayon 无能为力;
- 严格的延迟隔离(比如"IO 请求 p99 不能被 CPU 任务影响超过 5ms")——分池物理上隔离;
- 库内部自持 runtime——对外提供同步 API,不强迫调用方用 async;
- 混合 io_uring 和 epoll——主 runtime epoll、存储 runtime uring;
- 测试隔离——每个测试一个临时 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_timeonly)——做 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 本章小结
带走三件事:
- 一个进程多个 runtime 完全合法——用
Handle跨 runtime 通信、首选 mpsc/oneshot。绝不在一个 runtime 的 worker 里block_on另一个 runtime - 双 runtime 的经典架构是"IO runtime(少 worker) + CPU runtime(= 核数)"——解决"CPU 密集又带 async IO"这个 Rayon 搞不定的场景
- 多 runtime 是工具、不是时尚——单 runtime + spawn_blocking 已经解决 95% 场景。分 runtime 的成本在调试和认知上、收益必须明确才值
下一章进入性能调优与典型陷阱——把前 18 章讲过的知识串成一条"性能故障排查流程":从压测设计、火焰图解读、到 coop budget、lifo_slot、worker 亲和性——常见性能坑和对应的调优手段一网打尽。
延伸阅读
- Tokio 源码:
tokio/src/runtime/handle.rs - Tokio 源码:
tokio/src/runtime/runtime.rs - tokio-uring GitHub
- 《Vue 3 设计与实现》第 18 章:多 app instance 与 isolation
- 《Rust 编译器与运行时揭秘》第 13 章:rustc query system
- 《vLLM 源码剖析》multi-engine 架构