Tokio 源码深度解析

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

作者 杨艺韬 · 10,025 字

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

本章要点

  • 一个进程可以起多个 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”这个词在 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::blockingredis::Client::blocking 都是这个模式。

第三个场景测试隔离。每个测试用例起自己的 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

#[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 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)channel(通过 Arc 跨 runtime 共享 channel endpoint)、(2)shared state + Notify(共享内存同步)、(3)oneshot + handle.spawn(把计算送到对面 runtime 跑)。每种路径有不同的语义和性能特征——本节详细讲。

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

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

mpsc channel 是跨 runtime 通信的首选——理由很简单:mpsc channel 本身不绑定任何 runtime。它的 send / recv 都是”纯数据结构操作”、不需要当前线程上有 runtime 上下文。这让你可以把 Sender 给 runtime A 的 task、Receiver 给 runtime B 的 task、两边自由 send/recv——天然跨 runtime。这是 Tokio sync 原语里一个极重要的性质——channel 是跨 runtime 安全的、其他同步原语要看具体情况。

其他 Tokio sync 原语的跨 runtime 安全性Notify / Semaphore / Barrier 都是 channel-like、跨 runtime 安全但 Mutex / RwLock 的 lock().await 会登记 Waker、Waker 绑定的 runtime 就是 lock await 时的 current runtime——如果你跨 runtime 用同一把锁、Waker 可能指向不同 runtime 的 task、带来意外行为。保险起见、锁放在单个 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、不依赖任何 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(请求-响应模式)

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"); });

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

18.4 block_on 的死亡陷阱

多 runtime 场景里最容易踩的坑是在一个 runtime 的 async task 里调用另一个 runtime 的 block_on——这会导致 worker 被永久阻塞、可能死锁、甚至整个系统卡死。本节把这个陷阱讲透——在 async task 里永远不要 block_on 另一个 runtime 的 Future、而要用 spawn + await 模式。这是多 runtime 代码里最难发现但最致命的一类 bug。

为什么这个陷阱如此隐蔽?因为本地测试不一定能触发。很多时候你只 spawn 几个 task、worker 没满、block_on 在哪都无所谓。但生产环境 worker 繁忙时、block_on 某个需要当前 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 操作
    });
    // ...
});

这段代码的问题

  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

源码证据

为什么 block_on 在 async fn 里是陷阱?去看 Tokio 源码就明白——Runtime::block_on 内部会 park 当前线程等结果、但如果当前线程是 worker 线程、park 它就意味着这个 worker 上所有其他 task 都停工了。更糟的是如果被等的 Future 需要调度到同一个 worker 才能完成——完全死锁。本节直接展示源码里对这个问题的注释和防御代码。

Tokio 的 block_on 实际上有 runtime-flavor 的保护——current_thread runtime 里 block_on 从 async fn 里调会直接 panic(Tokio 检测到你在 async 上下文里);multi_thread runtime 里 block_on 允许调用但有日志警告。但这些保护都不是百分百可靠——最可靠的做法还是 写代码时心里就知道不该 block_on——自觉比工具防护更重要。

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

// 需要这么绕:
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
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 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 runtimeCPU runtime设计意图
线程数少量 worker按 CPU 核数IO 保持低抖动,CPU 吃满核心
任务类型socket、DB、RPC、timer压缩、加密、序列化、推理前处理按资源瓶颈隔离
通信方式mpsc 发 job、oneshot 收结果mpsc 收 job、oneshot 回写单向数据流,避免共享状态
背压位置mpsc capacityjob queue 长度CPU 慢时自然减速 IO 入口

这种流水线式架构还有一个副产品好处——天然的背压机制。如果 CPU runtime 处理慢、mpsc 队列会满、IO runtime 的 send 会被挂起、上层自然降速。这种”架构自带限流”比手动在各个点加 rate limiter 优雅得多——好架构能省掉很多手动控制的代码

典型请求的生命周期:

  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 存活期间、当前线程的 thread-local “当前 runtime”指针指向这个 runtime。这让你可以在一个非 async 函数里临时建立 Tokio 上下文——创建响应式类型、用 tokio::spawn 等。这是多 runtime 下做”上下文切换”的重要工具——但也要小心、guard drop 后上下文消失、如果你的代码依赖 runtime 会立刻 panic。

一个常用场景在同步初始化代码里需要构造 Tokio 对象(比如 TcpListener::bind)——这些构造函数需要”当前 runtime”才能工作、但你的初始化代码不在 async fn 里。解决方案:先 Runtime::new()、然后 let _guard = rt.enter()、然后在同步代码里做 Tokio 对象构造、最后 block_on main——enter guard 让你在任何位置都能临时”进入 runtime 上下文”。

有一个容易忽略的方法——handle.enter() 返回一个 EnterGuard、在 guard 存活期间当前线程的 TLS context = 那个 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 包装 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 能否共享 I/O driver 节省开销”。答案是不能——Tokio 的 I/O driver 和 runtime 是强绑定的(driver 里的 ScheduledIo 挂在 runtime 的 worker 上)、架构上不允许跨 runtime 共享。好消息是你也不需要共享——现代 Linux 的 epoll 在多线程共享时反而有锁竞争开销、每个 runtime 独立 driver 反而更快。

但这意味着 fd 不能跨 runtime 使用——你不能在 runtime A 里 bind 一个 TcpListener、然后把它交给 runtime B 处理 accept。必须在目标 runtime 的上下文里构造 fd。这个约束有时候让多 runtime 架构变得别扭——某些情况下你要”先在目标 runtime 里起 listener、再通过 channel 把消息送回去”。理解这个约束能避免很多奇怪的 “为什么我的 fd 不响应” 问题。

有人会想——“既然两个 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 顺序

多 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 时你希望:

  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”

tokio-uring 是 Tokio 生态里为利用 Linux io_uring 而生的独立 runtime。它和普通 Tokio runtime 不兼容——有自己的 task、自己的 I/O 操作语义(completion-based 而不是 readiness-based)。如果你的应用高度依赖 io_uring 的极致 IO 性能——可能需要让一部分代码跑在 tokio-uring runtime 里、另一部分跑在普通 Tokio——典型的多 runtime 场景。

但这个路径在 2025 年之前一直不是主流——因为 io_uring 的适用场景很窄(只有特定 IO 模式下有明显收益)、而且 tokio-uring 的生态不成熟(很多 Tokio 周边库不兼容)。如果你的应用不是百万 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 曾经试图做成 “Rust 的 Go”——对用户完全透明、不暴露 runtime 概念。这种简化让 async-std 有一段时间很受欢迎。但这种设计的代价是——用户失去控制——不能配置 worker 数、不能隔离不同负载类型、不能运行多个 runtime。最终 Tokio 的 “让用户看到 runtime、给用户配置控制”路线在生态里胜出——因为生产用户需要这些控制。async-std 的教训告诉我们:抽象得太完美反而限制用户——暴露适量复杂度让用户能做他们需要做的事。

这个故事背后有一个更深的工程教训基础设施层的 API 设计要留足”进阶选项——初学者用默认值、进阶用户能深挖配置。如果你的 API 只有默认、没有进阶出口、当用户撞到默认的局限时就只能换掉整个库。Tokio 的做法——默认值简单(new_multi_thread 一行起 runtime)+ 进阶可调(25+ builder 字段)——让它能从 hobby 项目到 Fortune 500 服务都能用。这种弹性是基础库的”长寿秘诀”。

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 和其他书的呼应

多 runtime 的概念在其他语言生态里也能找到类似——Java 的 ExecutorService 可以创建多个 Executor 实例做不同类型的工作、Python 的 asyncio 在 3.11 后也支持多 event loop、Node.js 通过 worker_threads 实现类似效果。每种语言的解法都有自己的特色、但**“隔离不同负载类型”的核心动机是共通的**。跨语言学习这个主题能让你在任何语言里都能设计好这种隔离架构。

Rust 相对其他语言的独特优势在类型系统——Handle: Send + Sync + CloneJoinHandle: Sendmpsc::Sender: Send + Sync——这些约束让编译器能在编译期帮你挡住很多”把不该跨线程的东西跨了线程”的 bug。其他语言里类似架构很容易因为”忘记共享可变状态要加锁”等错误导致运行时 bug。Rust 多 runtime 架构编译通过基本就不会有并发安全问题——这个保证在生产级服务里价值巨大。

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 是强大但危险的工具——大多数应用不需要。判断是否需要的决策清单:(1)你的应用真的有”CPU 拖累 IO”问题吗?先用 spawn_blocking / rayon 试试;(2)你的不同任务类型真的需要物理隔离吗?priority + Semaphore 往往就够用;(3)你的库确实需要对外同步 API 吗?如果用户都是 Tokio 用户就不用包 runtime。这三个问题大多数场景能答 “不需要”——那就别上多 runtime。

真实触发多 runtime 的通常是”你已经被单 runtime 打败”的时刻——你试过所有更轻的方案(spawn_blocking 调大、Semaphore 限流、优先级调度)、profile 里看到依然是”某类 task 拖慢另一类”——这时候再上多 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

一个真实项目的演进历程:某 API 服务最初是单 Tokio runtime 跑所有事——网络请求、数据库查询、JWT 验证、JSON 序列化都在同一个 worker 池。随着流量上涨、发现 JWT 验证(CPU 密集)偶尔占满一个 worker 几毫秒、导致同一 worker 上的 IO task 尾延迟飙升。团队做了改造:拆成两个 runtime——IO runtime(4 worker)处理网络和数据库、CPU runtime(8 worker)专门跑 JWT / 加解密 / 序列化。改造后 IO 的 P99 从 120ms 降到 15ms。这个故事说明多 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 容器”

换一个角度看——Runtime 其实就是”可编程调度策略”的容器。每个 runtime 可以有自己的 worker 数、自己的 Blocking pool size、自己的 tick 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_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

做类比有助于理解——Tokio runtime 的配置空间相当于 JVM 的 GC 调参。都是”大多数应用默认值够用、但有高级用户需要深度调优”的能力。JVM 的 GC 有 G1 / ZGC / Shenandoah 等选项、每个有几十个参数;Tokio 的 runtime 有 worker_threads / max_blocking_threads / tick / 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 runtime 里用 block_in_place)。这意味着 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.11 本章小结

多 runtime 是 Tokio 提供的高阶能力——功能强大但使用门槛高。4 个 take-home:

1. 默认单 runtime——90% 的应用用一个 runtime 就够了。只有明确发现”某类任务拖累另一类”时才考虑拆分。

2. 跨 runtime 通信用 channel——不要用共享可变状态(Mutex 之类的)、容易死锁。

3. block_on 别在 async fn 里用——这是最容易踩的坑、会导致 worker 永久阻塞。

4. 多 runtime 配观测 essential——每个 runtime 起有意义的名字、独立暴露 metrics——生产调试时救命。

下一章(第 19 章)讲性能调优——Tokio 在真实生产上的各种性能陷阱和调优方法。有了前 18 章的机制理解作基础、第 19 章的”怎么让 Tokio 跑得更快”实战会很自然。

一个回望:读完本章你对 Tokio 从单 runtime 单一视角扩展到多 runtime 架构视角。这种视角升级让你能设计更复杂、更严肃的生产系统——把”把所有东西塞一个 runtime 里”这种初学者模式升级为”按负载特征拆 runtime”这种架构师模式。这种升级不是炫技、是应对真实生产负载的必然。

带走三件事:

  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 亲和性——常见性能坑和对应的调优手段一网打尽。


延伸阅读