Tokio 源码深度解析
第18章 跨 runtime 通信与多 runtime 架构
本章要点
- 一个进程可以起多个 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——直接调用会被 Tokio 当场 panic;绕过检测的同步等待则会锁住 A 的 worker 线程、如果链路形成环直接死锁
- 正确姿势:两 runtime 之间用
tokio::sync::mpsc/oneshot通信——永远不要让一个 runtime 阻塞等另一个 runtime 的结果
18.0 一个问题的两种语义
"多 runtime"这个词在 Tokio 语境下有两种不同含义:(1)一个进程里运行多个 Tokio Runtime 实例(本章主题)、(2)你的业务和另一个异步运行时(比如 async-std / smol)共存(第二类问题、更复杂、本章不详讲)。弄清你面对的是哪种"多 runtime"、本章的内容才对应正确。
第二类问题(跨 runtime 家族共存)几乎没有好答案——不同 runtime 家族的 Future 底层假设完全不同(比如 smol 用简单 wake queue、tokio 用复杂 task state)、互相跨调用会导致各种微妙的 bug。最佳建议是不要这么做——一个项目选一个 runtime 家族、从头到尾用它。如果你有二选一的纠结、几乎永远选 Tokio——它的生态最大、社区支持最好、生产案例最多。
"多 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 就是这个模式——它内部起一个 current_thread runtime,对外只暴露同步 API。
第三个场景:测试隔离。每个测试用例起自己的 runtime,避免共享状态污染(比如一个测试注册了 mock clock、不能影响另一个)。#[tokio::test] 宏本质就是 "每个测试 fn 包一个临时 runtime"。
18.2 Runtime 和 Handle:两级抽象
在多 runtime 场景里、Runtime 和 Handle 的区别变得尤其重要。Runtime 是物理实例、Handle 是引用:Runtime 只能被独占持有(drop 时整个 runtime 停止);Handle 可以随意 clone 并传递。这让"某个 task 里想给另一个 runtime 发活儿"变得简单——传 Handle 进去、让 task 通过 Handle.spawn() 把活儿送过去。这种"Handle 作为跨 runtime 桥梁"的模式是多 runtime 架构的基础。
打开 tokio/src/runtime/handle.rs:11:
#[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() 的定位
Handle::current() 返回 当前 async task 所在 runtime 的 Handle——这是一个 thread-local 查询。在 async task 里调用它总能拿到正确的 Handle;但在非 async 函数里调用可能 panic(没有 current runtime)。这个 API 的存在让 "在 async 代码里访问当前 runtime" 变得极简——你不用显式传 Handle 到每个函数、直接 Handle::current() 即可。但它也是多 runtime 场景里的一个陷阱来源——你以为 current 是 runtime A、实际可能是 B。
规避这个陷阱的最佳实践是"显式传 Handle"——你的代码永远持有期望 runtime 的 Handle 引用、不依赖 thread-local。这样即使 task 在多 runtime 间流转、你要用哪个 runtime 是明确的。这种"显式 > 隐式"的原则在多 runtime 场景下尤其重要——任何"隐式取当前"都是潜在 bug 源。
#[track_caller]
pub fn current() -> Self {
Handle {
inner: scheduler::Handle::current(),
}
}
从 thread-local 里读(tokio/src/runtime/handle.rs:137,落到 tokio/src/runtime/scheduler/mod.rs:105 的 context::with_current,取不到就 panic!)——每个 Tokio worker 线程启动时、会把自己归属的 Handle 存到 TLS。在 worker 线程上调 Handle::current() 拿到那个 runtime 的 handle。在非 worker 线程调会 panic(除非你手动 enter() 过)。
关键观察:TLS 里只能有一个"当前 runtime"——这意味着你在同一个线程里不能"同时属于两个 runtime 的 context"。这一条在下面的陷阱里会反复出现。
18.3 多 runtime 如何通信:三条推荐路径
两个 runtime 里的 task 不能直接通信——每个 runtime 独立调度、独立 I/O driver、独立 timer wheel。要让 task A(runtime 1)给 task B(runtime 2)送数据、必须通过三条推荐路径之一:(1)mpsc channel(把 Sender / Receiver 分别交给两个 runtime 的 task)、(2)mpsc 送请求 + oneshot 回响应(跨 runtime 的 request-response)、(3)共享 Arc<Mutex<State>>(共享内存同步)。每种路径有不同的语义和性能特征——本节详细讲。
两个 runtime R1 和 R2、各自跑着一堆 task,它们之间怎么交换数据?
路径 1:tokio::sync::mpsc(首选)
mpsc channel 是跨 runtime 通信的首选——理由很简单:mpsc channel 本身不绑定任何 runtime。它的 send / recv 都是"纯数据结构操作"、不需要当前线程上有 runtime 上下文(recv 唯一碰到的 thread-local 是协作调度的 budget,没有 runtime 时 coop::poll_proceed 直接按不限额处理,tokio/src/runtime/coop.rs:176-196)。这让你可以把 Sender 给 runtime A 的 task、Receiver 给 runtime B 的 task、两边自由 send/recv——天然跨 runtime。这是 Tokio sync 原语里一个极重要的性质——channel 是跨 runtime 安全的、其他同步原语要看具体情况。
其他 Tokio sync 原语的跨 runtime 安全性:Notify / Semaphore / Barrier / Mutex / RwLock 在这一点上是同一类东西——tokio::sync::Mutex 和 RwLock 底下都是 batch_semaphore,而 Semaphore 的全部状态就是 waiters: Mutex<Waitlist> 加一个 permits: AtomicUsize(tokio/src/sync/batch_semaphore.rs:35),等待者是一条侵入式链表、每个节点存一个 Waker,除了协作调度的 budget 之外不读 runtime 的 thread-local、也不记录"当前 runtime"。所以 R1 加锁、R2 解锁并唤醒 R1 的 waiter,机制上是成立的。真正劝你别这么干的理由是可推理性:一把锁被两个 runtime 争用时,争用热点和死锁都更难排查——所以仍然建议锁放在单个 runtime 使用、跨 runtime 通信用 channel。
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、不依赖任何 runtime 上下文——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(请求-响应模式)
oneshot channel 和 mpsc 类似、也是跨 runtime 安全的。典型用法:runtime A 把"请求"通过 mpsc 发给 runtime B、请求里带一个 oneshot Sender;runtime B 处理完通过 oneshot 把响应送回给 A。这种"mpsc 送请求 + oneshot 回响应"的模式就是跨 runtime 版的 request-response、极其常用。
这个模式在 actor-style 架构里也常见——每个 actor 持有一个 mpsc Receiver 监听 message、每个 message 里带一个 oneshot Sender 用于回应。这种结构让 async actor 之间能有 RPC-like 的通信模式——Tokio 的 channel 机制让 async actor 模型几乎是免费的。
#[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>>
通过 shared state 通信是最灵活但也最危险的路径——两个 runtime 共享一个 Arc<Mutex<State>>、各自读写这个 State。灵活在于可以表达任意读写模式;危险在于 Mutex 在两个 runtime 的 task 里都可能被争用、容易死锁、性能瓶颈也难诊断。只有当 channel 模式明显不合适时才用 shared state——大部分场景 channel 是更好的选择。
什么情况 channel 明显不合适?——比如你有一个读远多于写的数据结构(缓存、配置)、用 channel 做每次访问都要 send+recv 开销大、用 Arc<RwLock<State>> 更高效。或者你有一个需要原子多字段操作的状态(比如给用户扣款要同时改 balance 和 history)——channel 做这种原子操作麻烦、shared Mutex 反而直观。关键是知道什么场景对应什么工具——Tokio 没有"万能 channel"、不同场景需要不同工具。
最简陋但最直接。两个 runtime 共享一个 Arc<tokio::sync::Mutex<T>>——各自 .lock().await 访问。
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"); });
需要跨 .await 持锁时用 tokio::sync::Mutex——std::sync::Mutex 争用时会阻塞线程、在 runtime 里 = 堵 worker;不过临界区很短、不跨 .await 时,Tokio 文档反而说用标准库 Mutex 没问题、而且往往更好(tokio/src/sync/mutex.rs:26)。还要注意:tokio::sync::Mutex 的 notify 机制依赖 atomic + Waker、不绑定到具体 runtime——所以 R1 释放锁、R2 获得锁是 OK 的。
18.4 block_on 的死亡陷阱
多 runtime 场景里最容易踩的坑是在一个 runtime 的 async task 里同步等另一个 runtime 的结果——直接调 block_on 会被 Tokio 当场 panic;换成 Tokio 检测不到的同步等待,则会导致 worker 被长时间阻塞、可能死锁、甚至整个系统卡死。本节把这个陷阱讲透——在 async task 里永远不要 block_on 另一个 runtime 的 Future、而要用 spawn + await 模式。这是多 runtime 代码里最难发现但最致命的一类 bug。
为什么这个陷阱如此隐蔽?直接 block_on 的写法一跑就 panic、反倒好发现;难的是 Tokio 检测不到的变种(在 worker 上用 std 的 mpsc::Receiver::recv、Mutex::lock 之类同步等另一个 runtime),它们本地测试不一定能触发。很多时候你只 spawn 几个 task、worker 没满、同步等一下也无所谓。但生产环境 worker 繁忙时、同步等某个需要当前 worker 才能完成的 Future——死锁。你的测试环境几乎不可能复现这种"繁忙时的刚好时序"——但生产必然遇到。这种"测试通过、生产炸"的隐蔽性是多 runtime 代码最大的风险来源。
整个专栏最危险的一条陷阱。
// ⚠️ 反面教材
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 操作
});
// ...
});
这段代码在 Tokio 里会直接 panic(下文「源码证据」),panic 被这个 task 的 JoinHandle 收走(拿到的 JoinError 上 is_panic() 为 true)。假如没有这道检查,它的问题是:
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。
源码证据
为什么 block_on 在 async fn 里是陷阱?去看 Tokio 源码就明白——Runtime::block_on 内部会 park 当前线程等结果、但如果当前线程是 worker 线程、park 它就意味着这个 worker 上所有其他 task 都停工了。更糟的是如果被等的 Future 需要调度到同一个 worker 才能完成——完全死锁。本节直接展示源码里对这个问题的注释和防御代码。
Tokio 对此有一道防线,而且不分 runtime flavor:Handle::block_on / Runtime::block_on 内部都走 context::enter_runtime(...)(tokio/src/runtime/handle.rs:323、tokio/src/runtime/scheduler/multi_thread/mod.rs:86、tokio/src/runtime/scheduler/current_thread/mod.rs:179——注意第二个参数 allow_block_in_place 前两处传 true、current_thread 那处传的是 false,这也正是 current_thread 上 block_in_place 会 panic 的来源),而 enter_runtime 一旦发现当前线程已经处在某个 runtime 的上下文里,就直接 panic!——multi_thread 和 current_thread 一视同仁。但这道防线只挡得住"直接调用",挡不住"你先 spawn_blocking 跳出上下文、再在里面搭一条环形依赖"。所以最可靠的做法还是"写代码时心里就知道不该 block_on"。
Tokio 在 tokio/src/runtime/context/runtime.rs:35 的 enter_runtime 末尾(:68)就是这句 panic(防死锁):
thread 'tokio-runtime-worker' (<线程 ID>) panicked at src/main.rs:<行>:<列>:
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:
// 需要这么绕:
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 架构模式——一个 runtime 专门跑 IO(网络、数据库、RPC)、另一个 runtime 专门跑 CPU 密集(序列化、压缩、加密、计算)。两个 runtime 独立 worker 池、通过 channel 或 handle 交换数据。这种分离的好处是CPU 密集任务不会拖慢 IO 响应——IO worker 永远可用、响应稳定;CPU worker 可以跑满不影响对外服务能力。本节讲这个架构的完整搭建方式。
这个模式在高性能服务器领域早已成熟——Nginx 的 master + worker、Envoy 的 main + worker + admin threads、ScyllaDB 的 shard-per-core——都是"隔离不同职责、用专属线程池处理"的变体。多 runtime 把这种成熟架构模式带到 Rust async 世界——让你用 async 编程模型也能实现类似 Nginx 级别的架构清晰度。
这是多 runtime 最经典的应用。我在几家公司都见过、架构大致是:
// main.rs(示意:Result 是 anyhow::Result 之类的别名,num_cpus 是第三方 crate,start_server 是你自己的服务入口)
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;
- 单个 worker 一轮就能处理掉一大批 readiness 事件,worker 数不需要跟着 QPS 走;
- 少 worker = 少 context switch = 低延迟抖动。
为什么 CPU runtime = 核数:
- CPU 任务是真的用满 CPU——worker 数 = 核数让每个 worker 都能占满一个核(
num_cpus::get()返回的是逻辑核数); - 多了反而 context switch 带来抢占、p99 劣化。
为什么不用 Rayon:
- CPU 任务内部有 async IO(写入对象存储、调 API)——需要能
.await; - Tokio 的 runtime 原生支持 async IO、Rayon 不行。
数据流向
IO runtime 接收请求 → 通过 mpsc 把"需要 CPU 处理的任务"送给 CPU runtime → CPU runtime 处理完通过 oneshot 回传结果 → IO runtime 把结果发回给客户端。整个数据流是单向循环——IO → CPU → IO → 客户端。这种"流水线式数据流"比"共享状态多方写"容易推理得多、也容易优化得多。
flowchart LR
Client[Client] --> IO[IO Runtime<br/>network / database / RPC]
IO -->|mpsc job| CPU[CPU Runtime<br/>serialization / compression / compute]
CPU -->|oneshot result| IO
IO --> Client
IO -. backpressure .-> Queue[mpsc capacity]
Queue -. full .-> IO
| 边界 | IO runtime | CPU runtime | 设计意图 |
|---|---|---|---|
| 线程数 | 少量 worker | 按 CPU 核数 | IO 保持低抖动,CPU 吃满核心 |
| 任务类型 | socket、DB、RPC、timer | 压缩、加密、序列化、推理前处理 | 按资源瓶颈隔离 |
| 通信方式 | mpsc 发 job、oneshot 收结果 | mpsc 收 job、oneshot 回写 | 单向数据流,避免共享状态 |
| 背压位置 | mpsc capacity | job queue 长度 | CPU 慢时自然减速 IO 入口 |
这种流水线式架构还有一个副产品好处——天然的背压机制。如果 CPU runtime 处理慢、mpsc 队列会满、IO runtime 的 send 会被挂起、上层自然降速。这种"架构自带限流"比手动在各个点加 rate limiter 优雅得多——好架构能省掉很多手动控制的代码。
典型请求的生命周期:
- 客户端请求打到 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 存活期间、当前线程的 thread-local "当前 runtime"指针指向这个 runtime。这让你可以在一个非 async 函数里临时建立 Tokio 上下文——创建响应式类型、用 tokio::spawn 等。这是多 runtime 下做"上下文切换"的重要工具——但也要小心、guard drop 后上下文消失、如果你的代码依赖 runtime 会立刻 panic。
一个常用场景:在同步初始化代码里需要构造 Tokio 对象(比如 TcpListener::from_std;TcpListener::bind 是 async fn,不在此列)——这些构造函数需要"当前 runtime"才能工作、但你的初始化代码不在 async fn 里。解决方案:先 Runtime::new()、然后 let _guard = rt.enter()、然后在同步代码里做 Tokio 对象构造、最后 block_on main——enter guard 让你在任何位置都能临时"进入 runtime 上下文"。
这什么时候有用?当你在非 Tokio 代码里需要构造一个需要 runtime 的对象。最典型的是 Sleep:
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:同步 API 需要在调用方线程上构造 Tokio 对象时(
reqwest::blocking走的是另一条路:自起一个跑 current_thread runtime 的线程、用 channel 收发请求,并不在调用方线程上enter()); - FFI 边界:C 代码回调进 Rust、需要在回调里创建 Sleep/TcpStream;
- 测试 setup:测试 fixture 需要构造一些 runtime 依赖的对象、但测试本身还没进入 async 代码。
不是"切换 runtime 身份"——只是"给 TLS 设一个默认值"、方便构造对象。真正跑 Future 还是得靠 handle.spawn 或 handle.block_on。
18.7 两个 runtime 共享 IO driver?不可能
这是一个常见但错误的设想——"两个 runtime 能否共享 I/O driver 节省开销"。答案是不能——Tokio 的 I/O driver 和 runtime 是强绑定的(driver 放在所属 runtime 的 Parker 里、由它自己的 worker 在 park 时轮流驱动,tokio/src/runtime/scheduler/multi_thread/park.rs:45-46)、架构上不允许跨 runtime 共享。好消息是你也不需要共享——每个 runtime 多带一份 driver 的开销很小(见下文)。
但这意味着 fd 永远绑在"创建它的那个 runtime"上——Registration 里就存着一个 handle: scheduler::Handle(tokio/src/runtime/io/registration.rs:49),注册关系从建立起一直到 Registration 被 drop 都不变。你把 runtime A 里 bind 出来的 TcpListener 交给 runtime B 去 accept,唤醒它的仍然是 A 的 driver;A 一旦被 drop,这个 fd 就彻底失效——Runtime 的文档写得很直白:runtime 被 drop 之后,绑在它上面的 I/O 资源不再工作,对它们调任何方法都只会拿到错误(tokio/src/runtime/runtime.rs:52)。所以实践上仍然是"在哪个 runtime 里用,就在哪个 runtime 的上下文里构造"最省事——理解这层绑定关系能避免很多奇怪的 "为什么我的 fd 不响应" 问题。
源码上,driver handle 挂在每个 scheduler 自己的 Handle 里(tokio/src/runtime/scheduler/multi_thread/handle.rs:25 和 tokio/src/runtime/scheduler/current_thread/mod.rs:41 里都有一行 pub(crate) driver: driver::Handle),Runtime 结构体本身只有 scheduler / handle / blocking_pool 三个字段(tokio/src/runtime/runtime.rs:97)——driver 是跟着 runtime 走的,没有共享的口子。理由:
- IO driver 没有自己的线程,靠所属 scheduler 的 worker 在 park 时去 poll 它——一个 driver 服务两个 scheduler 的话、就得决定由哪个 runtime 的 worker 来驱动它,其中一个 runtime 关闭时 driver 的去留也说不清;
- 多一个 epoll/kqueue instance 的系统资源开销很小(进程 fd table 里多一个 entry);
- 隔离的好处远大于共享——两个 runtime 出问题互不传染。
所以两 runtime 架构下、你会有两个独立的 epoll/kqueue instance——这是可以接受的代价。
实战细节:两个 runtime 的 shutdown 顺序
多 runtime 场景最被低估的细节之一是shutdown 顺序。两个 runtime 互相通信时、shutdown 顺序错了会导致:runtime A 关闭后、runtime B 还在往它的 channel 发数据——收到 Err、但 B 的 task 还在继续跑、浪费资源。正确的 shutdown 模式:先 A 停止接收新请求、等 A 的 pending 请求处理完、再 A 关闭 → 然后 B 关闭。这种有序 shutdown 对生产服务的优雅下线至关重要。
一个实用的 shutdown 模式是用 tokio::signal::ctrl_c + CancellationToken——用一个顶层 token 作为 "关闭信号"、两个 runtime 的顶层 task 都监听它。token 被触发时:IO runtime 先进入 "不再接新请求" 状态、处理完 pending → 发一个 sentinel 到 CPU channel → CPU runtime 看到 sentinel 后处理完剩余任务 → 两边都 shutdown。这种"二阶段 shutdown"保证了数据完整性、适合关键生产服务。
生产部署时还有个小但关键的问题——shutdown 顺序。graceful shutdown 时你希望:
- 先关 IO runtime 的入口(不再接新连接);
- 让还在处理的请求跑完;
- 等 CPU runtime 里的 job 全部排空;
- 关 CPU runtime;
- 关 IO runtime(此时它的 task 都已经结束)。
反过来会怎样?先关 CPU runtime——IO runtime 里正在 .await 一个 oneshot::Receiver 的 task 会收到 Err(RecvError)(CPU runtime 被 drop 时、上面的 task 连同它持有的 oneshot Sender 一起被 drop)、代码必须处理这个分支,或者 IO runtime 正在尝试 send 到 CPU runtime 的 mpsc、发现对面关了直接返回错误——用户看到的就是一批请求突然 500。
代码上的具体写法:保留两个 Runtime 对象在 main 里、按顺序 drop()。注意 Runtime::drop 的语义要分清(官方说明在 tokio/src/runtime/runtime.rs:35):异步 task 只跑到下一个让出点、然后就被 drop 掉,文档明说不保证跑到完成;真正会"等"的是 blocking pool 里已经开跑的 spawn_blocking 闭包——文档的说法是它们会一直跑到返回为止,Drop 实现就一直等着。所以如果你有长时间的 blocking 任务,drop 可能会挂很久,这时用 Runtime::shutdown_timeout(dur)——超时就不再等、避免 hang(但闭包本身仍会继续跑完)。
这套 shutdown orchestration 每次设计新系统都要想一遍、而且很容易被写 demo 时跳过——等到上生产出事就晚了。一个好习惯:跟着业务代码一起写 shutdown 路径、别拖到最后。
18.8 tokio-uring 作为"第三个 runtime"
tokio-uring 是 Tokio 生态里为利用 Linux io_uring 而生的独立 runtime。它和普通 Tokio runtime 不兼容——有自己的 task、自己的 I/O 操作语义(completion-based 而不是 readiness-based)。如果你的应用高度依赖 io_uring 的极致 IO 性能——可能需要让一部分代码跑在 tokio-uring runtime 里、另一部分跑在普通 Tokio——典型的多 runtime 场景。
但这个路径一直不是主流——因为 io_uring 的适用场景很窄(只有特定 IO 模式下有明显收益)、而且 tokio-uring 的生态不成熟(很多 Tokio 周边库不兼容)。需要补一句版本背景:本专栏基准的 1.40.0 里 tokio 主仓库还没有任何 io_uring 代码,到 1.51.1 时 tokio/src/runtime/io/driver/uring.rs 才作为实验性后端进入主仓库(见 §8.20)——但那也不改变"tokio-uring 是独立 runtime"这个事实。如果你的应用不是百万 QPS 级别的 IO 密集、用普通 Tokio 就够了——不要为了 io_uring 引入 tokio-uring 的整套复杂度。
我们在第 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 曾经也在这条路上
async-std 走的是"尽量不让用户看见 runtime"的路线——默认全局 runtime、spawn 拿来就用。这种简化确实降低了入门门槛,但代价是用户失去控制:worker 数只能用环境变量 ASYNC_STD_THREAD_COUNT 给全局 runtime 设一个总数、不能隔离不同负载类型、也没法在一个进程里跑多套调度策略。Tokio 选了相反的路线——runtime 是显式的一等公民。从今天的生态格局看,生产用户确实需要这些控制。这里的教训是:抽象得太干净反而限制用户——暴露适量复杂度,让用户能做他们需要做的事。
这个故事背后有一个更深的工程教训:基础设施层的 API 设计要留足"进阶选项"——初学者用默认值、进阶用户能深挖配置。如果你的 API 只有默认、没有进阶出口、当用户撞到默认的局限时就只能换掉整个库。Tokio 的做法——默认值简单(new_multi_thread() 一行起 runtime)+ 进阶可调(1.40.0 的 Builder 结构体一共 24 个字段(tokio/src/runtime/builder.rs:45),其中只有 unhandled_panic 在 tokio_unstable 之下,稳定构建里是 23 个)——让它从小工具到大型服务都能用。这种弹性是基础库的"长寿秘诀"。
Tokio 的选择更务实——Runtime 是一等公民、Handle 是一等公民、你用几个你决定。这种"不替用户做决定"的克制,让它能在库 + 应用 + 混合场景都找到自己的位置。很多 Rust async 的设计细节,都是在这种"灵活 vs 简单"的 tension 里打磨出来的——我们今天读 Tokio 源码的时候、实际上是在读一部"真实世界反复验证过的工程决策"。
18.9 和其他专栏的呼应
多 runtime 的概念在其他语言生态里也能找到类似——Java 的 ExecutorService 可以创建多个 Executor 实例做不同类型的工作、Python 的 asyncio 一直允许每个线程跑各自的 event loop、Node.js 通过 worker_threads 实现类似效果。每种语言的解法都有自己的特色、但"隔离不同负载类型"的核心动机是共通的。跨语言学习这个主题能让你在任何语言里都能设计好这种隔离架构。
Rust 相对其他语言的独特优势在类型系统——Handle: Send + Sync + Clone、JoinHandle: Send、mpsc::Sender: Send + Sync——这些约束让编译器能在编译期帮你挡住很多"把不该跨线程的东西跨了线程"的 bug。其他语言里类似架构很容易因为"忘记共享可变状态要加锁"等错误导致运行时 bug。Rust 多 runtime 架构编译通过就排除了数据竞争——但死锁、跨 runtime 的同步等待这类问题编译器管不了(18.4 就是例子)。
《Vue 3 设计与实现》第 14 章讲过 Vue 的 appContext——app.provide() 注册的依赖挂在 instance.appContext.provides 上,每个 createApp 出来的 app 实例有自己的一份注入上下文,一个页面里的多个 app 互不干扰。和多 runtime 的思路一模一样——每个 isolation 边界都是一个独立的上下文根。两个生态在"可组合的独立实例"这条路上殊途同归。
《Rust 编译器与运行时揭秘》第 17 章讲过 rustc 的 query system——每个 session 是一个独立的"小 runtime"、内存里的查询缓存各归各的,跨会话复用只走磁盘缓存这一条显式通道。同样的思想——隔离 > 共享、可组合 > 单体——是所有严肃 runtime 设计的共同信条。
《vLLM 推理内核深度解析》第 14 章讲的 数据并行(DP)——多个完全独立的引擎副本、副本之间零通信、靠上层路由分发请求——这就是"多 runtime"的又一个变种:每个子系统管自己的一摊事、消息传递协作。如果你同时读过三个专栏、就会看到这个模式在数不清的系统里以各种形态出现——分治 + 消息传递、是分布式系统之外、单进程内部的"轻量级分布式"设计。
18.10 决策清单:什么时候真的需要多 runtime
多 runtime 是强大但危险的工具——大多数应用不需要。判断是否需要的决策清单:(1)你的应用真的有"CPU 拖累 IO"问题吗?先用 spawn_blocking / rayon 试试;(2)你的不同任务类型真的需要物理隔离吗?priority + Semaphore 往往就够用;(3)你的库确实需要对外同步 API 吗?如果用户都是 Tokio 用户就不用包 runtime。这三个问题大多数场景能答 "不需要"——那就别上多 runtime。
真实触发多 runtime 的通常是"你已经被单 runtime 打败"的时刻——你试过所有更轻的方案(spawn_blocking 调大、Semaphore 限流、优先级调度)、profile 里看到依然是"某类 task 拖慢另一类"——这时候再上多 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 send/recv 加一次 oneshot 往返,还多一次跨线程唤醒);
- 认知负担(新人看到两个 Handle 会懵);
- 调试难度(两个 runtime 各有 metrics、console 要分别连)。
先单一 runtime + spawn_blocking/Rayon、真扛不住再分——这是我建议的演进路径。
18.11 一个典型的迁移路径:从单 runtime 到双 runtime
把前面几节的原则串成一条完整的演进路径,比孤立地记结论更有用。下面这条路径不带具体数字(那些强依赖机器和负载,抄别人的没有意义),只讲每一步该看什么、改什么。
起点:单 runtime 架构,每个请求 spawn_blocking 做一段重活(转码、加解密、大对象序列化),做完再写回下游。低峰期毫无问题。
症状(这一步靠第 17 章的 metrics 发现,不靠猜):
num_blocking_threads顶到max_blocking_threads;blocking_queue_depth持续大于 0 且不回落——排队时间开始主导端到端延迟;- 机器 CPU 打满,但有效工作占比不高——几百个 blocking 线程抢十几个核,大量时间花在上下文切换上;
- 与此同时,纯 IO 的请求也在变慢,因为它们和重活挤在同一台机器、同一个 blocking pool 上。
为什么 spawn_blocking 在这里不够用:blocking pool 是按 IO-bound 设计的(线程大多在 syscall 里睡着),它的上限是"线程数"而不是"核数"。拿它跑 CPU-bound,等于主动制造一个远超核数的可运行线程集合。
改法:把重活拆到一个独立的 CPU runtime——worker 数设成机器核数,IO runtime 只留少量 worker 专心做 HTTP 和下游调用,两者之间用"mpsc 送 job + oneshot 回结果"连起来(就是 18.5 那张图)。
改完之后该看到什么:
- CPU runtime 的 worker 数 = 核数,可运行线程数不再远超核数,上下文切换的浪费消失;
- IO runtime 的 worker 几乎总是可用,IO 路径的尾延迟和重活的负载解耦;
- 队列压力从"看不见的 blocking pool 内部"移到了"你自己定义的 mpsc capacity"上——变成了一个可观测、可调、可告警的量。
这就是多 runtime 的真实价值——不是"加一个 runtime 就快了"、而是"让每种负载跑在它擅长的配置下,并且让排队发生在你看得见的地方"。至于收益到底有多大,必须在你自己的机器和负载上量——本节刻意不给数字,就是因为任何抄来的数字都会误导决策。
18.12 一个你可能没想过的视角:runtime 是一个"可编程的 scheduler 容器"
换一个角度看——Runtime 其实就是"可编程调度策略"的容器。每个 runtime 可以有自己的 worker 数、自己的 Blocking pool size、自己的 global_queue_interval、自己的 event_interval——这些参数共同决定了这个 runtime 的调度特性。多 runtime 的本质是"在一个进程里运行多套不同调度特性"——这种能力在其他语言里很难做到(Go 只有一个全局 goroutine scheduler、Java CompletableFuture 需要手动 Executor 才能类似)。
回顾本专栏走到这里——从 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
做类比有助于理解——Tokio runtime 的配置空间相当于 JVM 的 GC 调参。都是"大多数应用默认值够用、但有高级用户需要深度调优"的能力。JVM 的 GC 有 G1 / ZGC / Shenandoah 等选项、每个有几十个参数;Tokio 的 runtime 有 worker_threads / max_blocking_threads / global_queue_interval / event_interval 等二十来个可调项。两者都给"高阶玩家"留足空间、也都让初学者能用默认值跑得很好。这种"简单易用 + 深度可调"的双轨设计是成熟基础设施的标志。
Java 程序员都知道"调 GC"——年轻代多大、老年代多大、CMS 还是 G1、pause target 多少。Tokio 的 Runtime Builder 就是 Rust async 的 GC 调参——worker 数、blocking 上限、keep_alive、enable_io、enable_time——每一个都是一次"调度策略 × 工作负载"的匹配。
两者最大的不同:Java GC 参数调错了通常只是慢;Tokio 参数调错了可能死锁(比如 worker_threads(1) 的 runtime 里、一个 task 用同步方式阻塞等另一个 task 的结果)。这意味着 Tokio 用户要承担更多"理解自己系统"的责任——好处是一旦理解、你能做到 GC 语言做不到的性能。
这也是为什么我反复强调"读 Tokio 源码":你不是在读一个库、你在学一种从底层构建运行时的思维。这种思维,在你之后去读 Kubernetes 调度器、Flink 的 task manager、Ray 的 object store、甚至 ClickHouse 的 async I/O 层时,会一次又一次地帮到你——很多高性能分布式系统,骨架都是"按负载 shard 调度器 + 消息传递协作"。你今天在 Tokio 里学到的,是打开这些系统大门的钥匙。
最后一点:保持对"引入复杂度"的敬畏
多 runtime 不是免费的——它引入的复杂度很真实:部署配置变复杂、监控要按 runtime 维度细分、bug 排查多一层变量、onboarding 新人要额外解释。这些隐性成本在项目初期看不见、但在团队规模扩大后会显著。所以我劝你把「上多 runtime」当成一个小心谨慎的决定——有明确证据说明单 runtime 不够时才上、上了就接受随之而来的全部复杂度。这种"对工程复杂度的敬畏"是成熟工程师的标志——不是害怕复杂度、是清醒地知道它的代价。
写到这里想补一句话——多 runtime 是一把锋利的刀、也因此最容易伤到自己。我见过太多团队一听"多 runtime"就跃跃欲试、还没理清自己单 runtime 到底卡在哪里就上手分——结果花了几周搭了双 runtime、加了 mpsc 桥、加了 shutdown 编排,然后发现性能没提升、调试复杂度反而翻倍。然后再花几周删回去。
引入复杂度是有代价的——代码量、调试难度、新人学习曲线、运维操心程度。一个好的工程师问自己三个问题:我有证据证明单 runtime 是瓶颈吗?(metrics)我能画出改架构后每个组件的预期行为吗?(设计)我能说清楚回滚路径吗?(风险)——三个答案都"是"再动手。否则就是用"多 runtime 架构"这个听起来很厉害的名词、自欺欺人地给自己加工作。
保持对复杂度的敬畏、让每一次架构升级都有真实数据支撑——这是读完这个专栏你最该带走的元技能。源码教的是机制,这种判断力教的是成熟。
18.13 本章小结
多 runtime 是 Tokio 提供的高阶能力——功能强大但使用门槛高。4 个 take-home:
1. 默认单 runtime——绝大多数应用用一个 runtime 就够了。只有明确发现"某类任务拖累另一类"时才考虑拆分。
2. 跨 runtime 通信首选 channel——共享可变状态(Mutex 之类的)只留给 §18.3 路径 3 那类读多写少、需要原子多字段操作的场景,争用和死锁都更难排查。
3. block_on 别在 async fn 里用——这是最容易踩的坑:直接调会当场 panic,绕过检测的同步等待则会把 worker 堵住。
4. 多 runtime 配观测 essential——每个 runtime 起有意义的名字、独立暴露 metrics——生产调试时救命。
一个回望:读完本章你对 Tokio 从单 runtime 单一视角扩展到多 runtime 架构视角。这种视角升级让你能设计更复杂、更严肃的生产系统——把"把所有东西塞一个 runtime 里"这种初学者模式升级为"按负载特征拆 runtime"这种架构师模式。这种升级不是炫技、是应对真实生产负载的必然。
带走三件事:
- 一个进程多个 runtime 完全合法——用
Handle跨 runtime 通信、首选 mpsc/oneshot。绝不在一个 runtime 的 worker 里block_on另一个 runtime - 双 runtime 的经典架构是"IO runtime(少 worker) + CPU runtime(= 核数)"——解决"CPU 密集又带 async IO"这个 Rayon 搞不定的场景
- 多 runtime 是工具、不是时尚——单 runtime + spawn_blocking 已经能解决绝大多数场景。分 runtime 的成本在调试和认知上、收益必须明确才值
下一章进入性能调优与典型陷阱——把前 18 章讲过的知识串成一条"性能故障排查流程":从压测设计、火焰图解读、到 coop budget、lifo_slot、worker 亲和性——常见性能坑和对应的调优手段一网打尽。
延伸阅读
- Tokio 源码:
tokio/src/runtime/handle.rs - Tokio 源码:
tokio/src/runtime/runtime.rs - tokio-uring GitHub
- 《Rust 编译器与运行时揭秘》第 17 章:增量编译中的 query system
- 《vLLM 推理内核深度解析》第 14 章:数据并行与多副本