Tokio 源码深度解析
第16章 spawn_blocking 与 block_in_place:把阻塞优雅地塞进 async 世界
本章要点
- Tokio 有两个线程池:worker pool(默认 = CPU 核数,跑 Future)+ blocking pool(默认上限 512 线程,跑阻塞代码)——职责分离
spawn_blocking:把闭包扔到 blocking pool、返回一个JoinHandle——本质是Spawner::spawn_task往Mutex<Shared>里的VecDeque<Task>push_back,有 idle 线程就用 Condvar 唤醒一个、没有就起新线程block_in_place:当前 worker 声明"我要阻塞了"、把 core 交给另一个线程继续跑其他 Task——自己在原地阻塞、完成后收回 core- 致命误区:spawn_blocking 不适合长期 CPU 密集(会挤爆 blocking pool)——正确选择是 Rayon 或单独的 worker runtime
- blocking pool 不做工作窃取:它就是一个 Mutex + VecDeque + Condvar 的经典生产消费者——因为 blocking task 本来就是粗粒度、窃取没意义
16.0 最短的问题引入
async Rust 的第一戒律是"不要阻塞 worker 线程"——但现实中"阻塞操作"无处不在:文件 IO 在很多系统上底层就是阻塞的、数据库驱动可能是阻塞的、老 C 库几乎都是阻塞的、CPU 密集计算(加密、压缩、图像处理)会占满 worker 几百毫秒。规避阻塞是异步编程初学者最常忽略的一点、也是线上事故最常见的来源之一。本章讲的两个工具——spawn_blocking 和 block_in_place——就是专门解决这个问题的。
理解阻塞危害的形象比喻:想象一个 4 worker 的 Tokio runtime 同时处理 1000 个连接——正常情况下每个 worker 在 250 个连接间快速切换、每个连接都在几百微秒内被处理。如果其中一个连接里调了一个阻塞 500ms 的函数——那个 worker 就卡住 500ms——它本地队列里的 task 只能等别的 worker 空下来偷走,LIFO slot 里那个连偷都偷不走、只能干等 500ms,整个 runtime 的处理能力少了 1/4。一个阻塞点就能拖垮整个 runtime——这就是 "第一戒律" 的严肃性。本章讲的工具就是让你能合法地调用阻塞函数、同时不让它拖垮 runtime。
async fn handler() -> Vec<u8> {
std::fs::read("/etc/passwd").unwrap() // ⚠️ 同步 IO
}
你把这个 handler 挂在 axum 上、一压测就会发现吞吐远低于用 tokio::fs::read 的版本、而且多核根本跑不满。为什么?
因为 std::fs::read 是同步系统调用——它会让当前线程陷入内核态等盘。Tokio 的 worker 线程(默认 = CPU 核数)是共享的——这个 handler 把其中一个 worker 线程完全堵住、期间这个 worker 本地队列里的其他 Task 只能等别的 worker 来偷(LIFO slot 里那个偷不走、只能干等)。8 核 CPU、8 个 worker,阻塞一个就少 1/8 的吞吐——连着阻塞 8 个,整个 runtime 直接停摆。
这就是 async 编程的第一条铁律:async fn 里严禁出现任何阻塞调用。包括——
- 同步文件 IO(
std::fs::*) - 同步网络 IO(
std::net::*) - CPU 密集计算(sha、压缩、正则、JSON 序列化超大对象)
- 锁的阻塞获取(
std::sync::Mutex::lock——临界区一旦不是"几条指令"级别就危险) - 任何 CLI / subprocess 阻塞等待(
Command::output)
问题是——不可能完全避免。有些场景你必须调阻塞 API(比如老旧 C 库)、必须做 CPU 密集(图像处理、AES 加密)。Tokio 给了两条路:spawn_blocking 和 block_in_place。它们都能把阻塞代码从 worker 线程上剥离,但实现机制和适用场景天差地别。本章带你看清。
16.1 spawn_blocking:扔到专属线程池
spawn_blocking 是 Tokio 对"必须阻塞的操作"的标准答案——把这些操作搬到专属的 blocking 线程池、不污染 async worker。这个分离让 async worker 永远保持非阻塞、吞吐量稳定;阻塞操作在专属池里自由等待、不会拖累其他 task。这是 Tokio 对"纯异步世界里怎么容纳同步世界"这个问题的工程答案——极其实用、极其重要。
这种"隔离池"设计在其他 runtime 里也能看到同源思想:Node.js 的 libuv 有独立的 thread pool 处理 fs 和 DNS;Java 的 CompletableFuture 可以指定 Executor(包括专用的 ForkJoinPool);Python asyncio 的 run_in_executor 也是类似机制。不同 runtime 各自的实现细节有差异、但"阻塞操作必须隔离在专属池"的核心原则是一致的——这是所有 async runtime 的共识。Tokio 只是把这个共识做到了 API 层面的极简。
最常用的路径。源码在 tokio/src/runtime/blocking/pool.rs:
pub(crate) struct BlockingPool {
spawner: Spawner,
shutdown_rx: shutdown::Receiver,
}
#[derive(Clone)]
pub(crate) struct Spawner {
inner: Arc<Inner>,
}
struct Inner {
shared: Mutex<Shared>,
condvar: Condvar,
thread_name: ThreadNameFn,
stack_size: Option<usize>,
after_start: Option<Callback>,
before_stop: Option<Callback>,
thread_cap: usize, // ← max_blocking_threads(默认 512);multi_thread 下再加 worker 数
keep_alive: Duration, // ← 默认 10 秒
metrics: SpawnerMetrics,
}
struct Shared {
queue: VecDeque<Task>,
num_notify: u32,
shutdown: bool,
// ...
}
认出什么了吗?这就是教科书里的"生产者-消费者"——Mutex<VecDeque> + Condvar。和 Java 的 ThreadPoolExecutor、Python 的 concurrent.futures.ThreadPoolExecutor 是一个思路——几十年前就证明过可行的经典设计。
提交路径
spawn_blocking 从调用到任务真正开始跑要经过几层"提交"——创建 BlockingTask → 加入 BlockingPool 的全局 queue → 唤醒一个闲置 blocking worker → worker 从 queue 取 task → 开始跑。这条链路比 spawn 到 async worker 长一截——多了一次拿 Mutex,再加一次 Condvar 唤醒或一次 OS 线程创建。
注意 "唤醒闲置 worker" 这一步的 trickiness——如果此时没有闲置 worker(全在跑别的)、pool 会 spawn 一个新 OS 线程(直到 max_blocking_threads 上限 512);超过上限时新 task 会排队等。这个 "按需扩张" 让 blocking pool 能自适应负载——平时轻量、突发场景下能瞬间扩容到几百线程。
用户调 tokio::task::spawn_blocking(|| ...),背后走到 spawn_blocking_inner → spawn_task:
// tokio/src/runtime/blocking/pool.rs:408
shared.queue.push_back(task);
self.inner.metrics.inc_queue_depth();
if self.inner.metrics.num_idle_threads() == 0 {
// 没有空闲线程能接这个任务
if self.inner.metrics.num_threads() == self.inner.thread_cap {
// 已经到上限,什么也不做——任务留在 queue 里等
} else {
// 起一个新的 blocking 线程
match self.spawn_thread(shutdown_tx, rt, id) { /* ... */ }
}
} else {
// 有空闲线程,唤醒一个(num_notify 用来抵消 condvar 的 spurious wakeup)
self.inner.metrics.dec_num_idle_threads();
shared.num_notify += 1;
self.inner.condvar.notify_one();
}
注意这是个二选一:有空闲线程才 notify_one;没有空闲线程就去起新线程(起不动就让任务在 queue 里等着)——而不是"起完线程再顺手唤醒一下"。另外 num_notify 这个计数器是必需的:Condvar 允许 spurious wakeup,worker 醒来后要靠它判断"这次是不是真的有人叫我"。
工作线程循环
blocking worker 的主循环比 async worker 简单得多——没有 work-stealing、没有 LIFO slot、没有 Driver poll——只有一个循环:从 queue 取 task → 跑它 → 返回结果 → 继续取。这种简化反映了 blocking worker 和 async worker 的本质不同:async worker 跑大量短小的异步 task、需要复杂的调度优化;blocking worker 跑少量长时间阻塞的 task、一个 worker 基本就专属一个 task。
这种"简化带来的清晰性"让 blocking pool 的实现在 Tokio 源码里只有短短几百行——对比 async worker 的几千行。越简单的代码越可靠、越容易维护——Tokio 作者对 blocking pool 的"尽量简单"设计选择体现了好工程师的品味。
每个 blocking worker 大致跑这个:
// 示意:省略了 num_notify 核对、spurious wakeup 处理和 shutdown 时的排空逻辑
// (完整实现见 tokio/src/runtime/blocking/pool.rs:505 的 Inner::run)
loop {
let task = {
let mut shared = spawner.inner.shared.lock();
loop {
if let Some(t) = shared.queue.pop_front() {
break t;
}
if shared.shutdown { return; }
// 等 keep_alive 超时——超时就退出
let (s, timeout) = spawner.inner
.condvar.wait_timeout(shared, keep_alive).unwrap();
shared = s;
if timeout.timed_out() { return; }
}
};
task.run(); // ← 真正执行用户闭包
}
keep_alive = 10 秒(tokio/src/runtime/blocking/pool.rs:171 那行 const KEEP_ALIVE: Duration = Duration::from_secs(10);)——空闲超过 10 秒自动退出、线程数缩回去。thread_cap 默认 512(tokio/src/runtime/builder.rs:289 的 max_blocking_threads: 512)——注意它限的是线程数、不是排队任务数:queue 是无上限的 VecDeque,第 513 个并发阻塞任务不会被拒绝、只会在队列里等。
还有个容易被漏掉的细节:multi_thread runtime 实际建池时用的上限是 max_blocking_threads + core_threads(tokio/src/runtime/builder.rs:1356:create_blocking_pool(self, self.max_blocking_threads + core_threads))——多出来的这些名额是给 worker 线程自己的:multi_thread 的每个 worker 线程本身就是用 spawn_blocking 在 blocking pool 里起的(tokio/src/runtime/scheduler/multi_thread/worker.rs:466 的 Launch::launch),加上这 core_threads 个名额,max_blocking_threads 才能像文档说的那样"不把 async worker 线程算在内"(tokio/src/runtime/builder.rs:438-439)。block_in_place 起的替补 worker 则和普通 blocking 任务一样占用这个总名额。current_thread runtime 则老老实实就是 max_blocking_threads(tokio/src/runtime/builder.rs:1200)。
这两个参数都可以在 Builder 里调:
tokio::runtime::Builder::new_multi_thread()
.max_blocking_threads(1024)
.thread_keep_alive(Duration::from_secs(60))
.build()?;
返回值:还是 JoinHandle
spawn_blocking 返回的是 JoinHandle<T>——和 tokio::spawn 的返回类型完全一样。这种API 对称性极为重要——它让用户可以用完全相同的模式管理 blocking task 和 async task:同样用 .await 等结果、同样用 abort() 取消、同样放 JoinSet 集合管理。这种对称让 Tokio 学习曲线平缓——一旦你掌握了 spawn 的使用模式、spawn_blocking 几乎不需要额外学习成本。
值得注意的是 abort 行为的微妙差异:async task 的 abort 是"协作式"的——下次 poll 时 task 观察到 Cancelled 状态、优雅退出;但 blocking task 的 abort 干脆就是无效的。AbortHandle 的文档(tokio/src/runtime/task/abort.rs:14)写得很直白:"tasks spawned using spawn_blocking cannot be aborted because they are not async … this will not have any effect, and the task will continue running normally. The exception is if the task has not started running yet."——唯一能被 abort 拦住的,是还没被任何 blocking 线程取走的那一刻。这个限制你要记住——它是"同步代码嵌入异步世界"的根本代价之一。
但 abort 对 blocking task 的语义弱到接近于零:Rust 没有线程中断机制(不像 Java 的 Thread.interrupt),而 BlockingTask::poll 是"一次 poll 里把整个闭包跑完、直接 Poll::Ready(func())"(tokio/src/runtime/blocking/task.rs:27)——根本没有第二次 poll 的机会去观察 cancelled 位。所以你 abort 一个已经开跑的 blocking closure,闭包照跑不误、JoinHandle 照样拿到正常返回值;只有在它还躺在 queue 里没被取走时,abort 才可能阻止它启动。
16.2 block_in_place:偷走自己的 core
block_in_place 是 Tokio 里最容易被误用的 API——它的工作方式完全违反直觉。它不是把当前 task 搬到别处、而是把当前 worker 线程从 Scheduler 里"偷走"给当前 task 独享、同时 spawn 一个新 worker 接管 Scheduler 的工作。听起来就很危险——事实上它也确实危险、有一堆 corner case。本节讲它的正确使用和陷阱。
为什么 Tokio 要提供一个这么危险的 API?——因为有一类场景 spawn_blocking 真的接不住:你必须在当前调用栈上同步拿到结果、又不能把闭包 move 走(Drop 实现里、不能改签名的老接口里)。除此之外的绝大多数场景都该用 spawn_blocking。官方文档(tokio/src/task/blocking.rs,block_in_place 的 doc comment)自己就写了一条劝退理由:block_in_place 只保证其他独立 spawn 的 task 不被饿死,"any other code running concurrently in the same task will be suspended"——比如同一个 join! 里的另一条分支会被一起挂住。
第二条路完全不同。看源码(下面这段按调用顺序做了删减并加了编号注释,示意,非逐字;完整实现见 tokio/src/runtime/scheduler/multi_thread/worker.rs:326):
pub fn block_in_place<F, R>(f: F) -> R
where F: FnOnce() -> R,
{
// 1. 检测是不是在 worker 线程上
match (
crate::runtime::context::current_enter_context(),
maybe_cx.is_some(),
) {
(context::EnterRuntime::Entered { .. }, true) => {
had_entered = true;
}
// ...
}
// 2. 把 LIFO slot 里的 Task 推回 run_queue(否则会被卡死)
if let Some(task) = core.lifo_slot.take() {
core.run_queue.push_back_or_overflow(
task, &*cx.worker.handle, &mut core.stats
);
}
// 3. 把 core 放回 worker 的 atomic cell、让另一个线程能 take
cx.worker.core.set(core);
let worker = cx.worker.clone();
runtime::spawn_blocking(move || run(worker));
// 4. 当前线程直接执行闭包——此刻"它不再是 worker"
f()
}
魔法就在第 3 步——runtime::spawn_blocking(move || run(worker))——它让 blocking pool 里的一个线程(有空闲的就复用,没有才新起)接手跑 worker 循环。当前线程从此和 worker 脱钩、自由地 block。闭包返回后、一个 Drop guard 尝试把 core 夺回来(同文件 :336,同样是删减后的示意)——如果替补线程已经把 core 取走,就夺不回来,当前线程会在这个 task 本次 poll 结束后放弃 worker 身份(同文件 :604-612):
impl Drop for Reset {
fn drop(&mut self) {
if self.take_core {
let core = cx.worker.core.take();
*cx_core = core;
}
coop::set(self.budget);
}
}
为什么要"搬走 LIFO slot"
block_in_place 的实现里有一个非常技术性的细节——被 block 的 worker 在交出 core 前、要先把 LIFO slot 里的任务倒进本地 run_queue。原因是 blocking pool 繁忙时替补线程可能迟迟没法接手这个 core:这段时间里 run_queue 里的任务还能被其他 worker 偷走,LIFO slot 里的却偷不走——不倒出来,它就只能干等到有线程接手 core 为止(tokio/src/runtime/scheduler/multi_thread/worker.rs:414-416 的注释)。这个细节体现了 block_in_place 有多危险——实现者需要极其小心地处理 worker 交接过程中的所有状态、一个小 bug 就能让某个 task 神秘卡住。
这就是为什么我们反复劝你不要用 block_in_place——它的正确实现依赖于 Tokio 内部一系列微妙的状态交接协议、一个小疏忽就可能把 bug 引入用户代码之外的地方。spawn_blocking 把这些复杂性完全隐藏、你只需要"扔进去 + 等结果"——接口和保证都简单得多。
第 5 章讲过 LIFO slot——每个 worker 有一个"最后 spawn 的 Task 优先跑"的单槽。它不会被其他 worker steal——目的是优化"父子 Task 紧接着执行"的局部性。
但 block_in_place 场景下它变成了负担:我把 core 交出去、接手的线程却可能迟迟不来,LIFO slot 里的 Task 又没人能 steal,只能一直卡着。所以代码第 2 步把它推回正常 run_queue——恢复可 steal 性。
这是一个精心设计的细节——如果忘了这一步,block_in_place 在有 LIFO task 的 worker上可能让那个 task 一直干等、没人能偷走它——这一步正是 1.38.0 才补上的(PR #6596,CHANGELOG 记为"move task out of the lifo_slot in block_in_place",第 5 章讲过)。
block_in_place 的铁律:只能用在 multi_thread runtime
在 current_thread runtime 上调用 block_in_place 会直接 panic——因为没有其他 worker 可以替代它工作、"偷走当前 worker"会让整个 runtime 停摆。这个铁律在文档里明确写着、但很多人写代码时没注意、然后在单元测试里(#[tokio::test] 默认用 current_thread)直接 panic。任何时候写 block_in_place 都要检查你的 runtime 配置——这个"小盒子警告"值得贴在编辑器边上。
current_thread runtime 只有一个 worker——把它的 core 给谁?没人!所以 block_in_place 在 current_thread 下会 panic:
thread 'main' (…) panicked at src/main.rs:…:
can call blocking only when running on the multi-threaded runtime
这句 panic 文本就写在 tokio/src/runtime/scheduler/multi_thread/worker.rs:390。不过"不是 multi_thread 就 panic"还要再精确一点:真正的判据是"当前线程是不是正处在一个不允许 block_in_place 的 runtime 上下文里"——current_thread runtime 和 LocalSet(tokio/src/runtime/scheduler/multi_thread/worker.rs:387-388 的注释点了这两种情况;实测 multi_thread runtime 上的 LocalSet::block_on 里调它同样 panic)会 panic,而完全在任何 runtime 之外调它是允许的,此时它只是原样跑一遍闭包(tokio/src/task/blocking.rs:21:"calling the function outside a runtime is allowed. In this case, block_in_place just calls the provided closure normally")。
这是 runtime 选择又一次影响你能写什么代码的例子——第 7 章(current_thread)讲过类似话题。
16.3 两者的取舍矩阵
spawn_blocking 和 block_in_place 这两个 API 看起来相似、实际应用场景完全不重叠。本节用一张清晰对比表帮你秒速决策:I/O 阻塞用 spawn_blocking、CPU 密集看情况、极特殊才用 block_in_place。实操上 block_in_place 绝大多数场景都不该用——用错了会让 Tokio 表现出各种诡异行为。
一个常被问的问题:"spawn_blocking 和 std::thread::spawn 有啥区别?"——答案很微妙:功能上它们都能"把代码扔到单独线程跑"、但 spawn_blocking 返回的是 Tokio JoinHandle(能 await、能放 JoinSet;abort 只对还没开跑的有效)、而 std::thread::spawn 返回 std JoinHandle(只能 join 同步等)。如果你的代码是纯 blocking、但你要在 async 上下文里管理它、永远用 spawn_blocking——它给你完整的 Tokio 集成。
| 维度 | spawn_blocking |
block_in_place |
|---|---|---|
| 提交形式 | spawn_blocking(|| ...) 返回 JoinHandle |
block_in_place(|| ...) 同步返回值 |
| 执行线程 | blocking pool | 当前线程原地执行 |
| 对 worker 的影响 | 无(不占 worker) | worker 的 core 交给 blocking pool 里的另一个线程 |
| 提交开销 | 一把全局 Mutex + Condvar 唤醒(没有空闲线程时改为起新 OS 线程) | 把 core 让出 + 到 blocking pool 起一个替补 worker |
| 并行度 | 可并行 N 个(N ≤ thread_cap) | 只能串行一个(单 worker 视角) |
| 适合场景 | 异步上下文里偶发阻塞 | 必须在当前 Task 栈上拿到结果(不能 await) |
| runtime 约束 | 任何 runtime | 只能 multi_thread |
最核心的区别:spawn_blocking 是"把活派出去"——非阻塞返回 Handle、你 .await 它;block_in_place 是"自己干但放走 worker"——同步返回结果、期间 worker core 跑别的。
什么时候非用 block_in_place 不可?
几乎没有场景——这是这一节的核心结论。你可能觉得 "CPU 密集 + 需要访问当前 task 本地状态" 的场景适合 block_in_place——但仔细想会发现这些场景绝大多数都可以用 spawn_blocking + 传递 state 替代。block_in_place 只在极罕见场景下有优势(上文那类必须在当前调用栈上同步拿结果的场合)。
经验之谈:真正非 block_in_place 不可的场合极其罕见——每次想用它之前,先花两分钟论证一遍"为什么 spawn_blocking + .await 不行",绝大多数时候论证会失败。默认不用、需要时再证明为什么非用不可——这是对 block_in_place 的正确态度。
当你在一个 Future 内部、不方便 await时。比如你在实现一个 Drop:
impl Drop for MyResource {
fn drop(&mut self) {
// Drop 里不能 .await
tokio::task::block_in_place(|| self.sync_cleanup());
}
}
或者你在一个 async fn 里想同步拿到 CPU 密集任务的结果、不想改代码结构:
async fn handler(data: Vec<u8>) -> Response {
// 不想把下一行改成 spawn_blocking + .await
let hash = tokio::task::block_in_place(|| heavy_hash(&data));
Response::new(hash)
}
实务建议:优先用 spawn_blocking——它对 runtime 的侵扰最小、更容易推理。block_in_place 只在迁移老代码 / Drop / 封库时用。
16.4 一个典型反面教材:spawn_blocking 炸 blocking pool
本节讲一类很容易踩中的反面用法:把一个看起来无害的 spawn_blocking 放到 hot path 上,随着流量上来 blocking pool 被占满,新任务开始在队列里堆积、延迟失控。根本原因是一个简单的排队论关系(Little's law):同时占用的 blocking 线程数 ≈ 请求速率 × 平均阻塞时长——这个乘积一旦超过 thread_cap,池就满了。这个关系很多人第一次遇到都意识不到、上线后才发现。本节讲怎么预防、监控、补救。
这个案例背后有一个广义教训:Tokio 的 spawn_blocking 语义让"阻塞是局部的"成立——但"局部"的能力是有上限的。如果你把阻塞操作当成"免费"(反正不污染 async worker)、在无限扩张的路径上使用——最终就会打爆 blocking pool 这个有限资源。任何"看起来免费"的系统资源都不是真免费——只是代价以另一种方式出现。在 spawn_blocking 这里、代价是 blocking pool 的线程数上限。把这个教训内化、你就不会在其他"看起来免费"的技术决策上重蹈覆辙。
设想一个服务把 CPU 密集计算(每个请求几十毫秒的 sha256)放在 spawn_blocking 里。随着并发上去,你会依次看到三个阶段:并发低时一切正常;并发升高后 blocking pool 的线程数一路涨到 thread_cap;并发继续升高,延迟开始失控——请求全堆在 blocking pool 的 VecDeque 里排队。
问题根源:thread_cap = 512 的含义是"最多 512 个线程",不是"最多 512 个排队的任务"。Shared::queue 是个没有容量上限的 VecDeque,spawn_task 永远 push_back 成功。超过 512 个同时在跑的 blocking task,第 513 个开始排队、排队延迟随队列长度线性增长,而没有任何东西会告诉你"满了"。这个"数学关系"很简单:并发阻塞任务数 ≈ 请求速率 × 平均阻塞时长,它一旦超过 thread_cap,延迟就开始积压。
正确选择:CPU 密集任务应该用 Rayon(rayon::spawn 或 rayon::join)——它的线程数 = CPU 核数、队列是 work-stealing 的、永远不会"线程数超过核数"挤爆 scheduler。把 Rayon 和 Tokio 配合用:
async fn handler(data: Vec<u8>) -> Hash {
let (tx, rx) = tokio::sync::oneshot::channel();
rayon::spawn(move || {
let hash = heavy_hash(&data);
let _ = tx.send(hash);
});
rx.await.unwrap()
}
这个 pattern 在高并发 + CPU 密集场景的可控性远胜 spawn_blocking。Tokio 的 crate 级文档(tokio/src/lib.rs:213)原话是:"If your code is CPU-bound and you wish to limit the number of threads used to run it, you should use a separate thread pool dedicated to CPU bound tasks. For example, you could consider using the rayon library",后面还专门提示了"用 oneshot channel 把 rayon 的结果送回 Tokio"——上面这段代码就是它的直译。
blocking pool 的真正归属:阻塞 I/O
很多人以为 blocking pool 主要是跑 CPU 密集——错。实际上 blocking pool 主要是给阻塞 I/O(文件、数据库驱动、外部命令)用。CPU 密集任务更适合用 rayon(专门的 CPU 并行库)——rayon 的线程数默认等于 CPU 核心数、不会像 blocking pool 那样开几百线程。把 rayon 和 Tokio blocking pool 分清楚是正确配置服务的第一步。
一个常见的错误组合:用 spawn_blocking 跑 CPU 密集任务——这会"正确工作"但性能不如用 rayon。原因:blocking pool 最多开 512 线程、但 CPU 核心可能只有 8-16 个——开 512 线程做 CPU 密集是浪费(一个核心只能跑一个线程)。rayon 专门为 CPU 密集优化、线程数恰好等于核心数、work-stealing、任务粒度细分——同样 workload 能快得多。
那 spawn_blocking 的"正统"使用场景是什么?阻塞 I/O:
- 第三方 C 库提供的同步 API(比如老式 DB driver)
- 本地文件 IO(虽然
tokio::fs存在、但它底层就是asyncify→spawn_blocking的封装——没有跨平台通用的异步文件 IO、只能用线程池模拟) - DNS 查询(
getaddrinfo是阻塞的——tokio/src/net/addr.rs:182里ToSocketAddrs的实现就是spawn_blocking(move || std::net::ToSocketAddrs::to_socket_addrs(&s)))
这些场景的共同特征:时间花在"等系统调用"而不是"占 CPU"。线程绝大多数时间在 syscall 里 sleeping、不占 CPU 核——512 个线程都 sleep 并不比 8 个更费 CPU。这就是为什么 thread_cap 可以开到 512:blocking pool 本质上是个 IO 线程池、不是 CPU 线程池。
换个角度看:既然线程大多在 sleep,为什么还要设上限
Tokio 默认最多 512 blocking 线程——但你不用担心开了 512 线程系统会崩。大多数时候 blocking pool 里只有个位数线程活跃——因为空闲 worker 10 秒后会自动 exit(thread_keep_alive=10s)。这个自动收缩机制让 blocking pool "按需扩张"——平时几乎不占资源、突发阻塞任务多时能临时开几百线程应对。这种"自适应弹性"是 Tokio blocking pool 设计里最优雅的部分之一。
读到这里常有人问:"既然 blocking 线程大部分时间 sleep、那多开一点有什么坏处、为什么还要限 512?"答案在线程自己的成本:
- 栈内存:Tokio 默认不设置
stack_size(Builder里thread_stack_size: None),于是走 Rust std 的默认线程栈——在 Linux 上是 2 MiB(常量DEFAULT_MIN_STACK_SIZE在library/std/src/sys/thread/unix.rs:26;真正取值的是library/std/src/thread/lifecycle.rs:37-39,它先读RUST_MIN_STACK环境变量、没设才回退到这个常量——所以这一项其实是可以在部署侧压下去的);512 个线程就是 1 GB 虚存(Linux 是 lazy allocation、只有真正 touch 的 page 才占物理内存、但虚存 mapping 还是要花); - 内核调度开销:睡着的线程不在调度器的运行队列里、不增加调度决策成本;但一旦大量线程同时醒来变成可运行,调度器(CFS/EEVDF 的运行队列是红黑树,O(log N))的决策与跨核负载均衡开销就会随之上升;
- 上下文切换:每次 syscall 返回都可能 reschedule、大量线程竞争同一核心时 L1/L2 cache 的命中率崩盘;
- thread local storage:Rust 的
thread_local!变量每个线程都要独立的一份、数量过多时累加内存可观。
512 只是一个默认值——源码里就是 Builder::default() 的一行 max_blocking_threads: 512(同上,tokio/src/runtime/builder.rs),官方文档(tokio/src/runtime/builder.rs 里 max_blocking_threads 的 doc comment)对它的全部说明只有两句:"The default value is 512",以及"It's recommended to not set this limit too low in order to avoid hanging on operations requiring spawn_blocking"。也就是说 Tokio 只给出了"别设太小"的方向性建议,并没有给出"512 从哪来"的推导。真正该记住的是:当你需要把它调到远超 512 才够用时,多半意味着更深层的架构问题,而不是"让 Tokio 多开几个线程"能解决的。
细节:thread_keep_alive 为什么是 10 秒
10 秒是一个经验常数——不是太短(避免在流量间隙不断 spawn/destroy 线程、浪费 OS 调用)、也不是太长(避免长时间闲置的线程占着不用)。这个"既不激进也不保守"的设定覆盖了绝大多数真实服务场景。如果你的负载特征特别(比如有规律的大流量峰谷)、可以调这个参数——但绝大多数服务不需要动它。
这个取值背后的权衡是清楚的:太短(比如 1 秒)会让低峰期线程频繁销毁、高峰期又要现起,而起线程要走一次 OS 的线程创建;太长(比如 5 分钟)则让 RSS 长期占着不释放。10 秒大致覆盖典型"低谷 → 回升"的时间尺度——大多数业务的流量波动周期是分钟级而非秒级。需要说明的是,Tokio 源码和文档都没有解释 10 秒是怎么定出来的(tokio/src/runtime/blocking/pool.rs:171 就是一行 const KEEP_ALIVE: Duration = Duration::from_secs(10);),上面是对这个取值的合理化解读,不是官方依据。
Tokio 这种"默认值照顾大多数场景"的哲学,让大多数用户开箱就有合理表现、而不需要去啃一堆配置文档。这是基础设施库成熟的标志之一——相反,不成熟的库会把决策全推给调用方、美其名曰"灵活"。
16.5 tokio::fs 的全貌:一个 spawn_blocking 的大玩具
tokio::fs 模块里的所有异步文件 API(read / write / metadata / rename ...)底下都是 spawn_blocking 的简单包装——这可能违反你的直觉。你可能期待 Tokio 的文件操作用 io_uring 或 AIO 做真正的 OS 级异步——但 Tokio 选择了 spawn_blocking 路线。原因有二:(1)io_uring / AIO 跨平台支持差;(2)很多文件系统(NFS / FUSE)对 AIO 支持也不完全。用 blocking 线程池做文件 IO 是最保守也最兼容的方案。
这个设计有一个实战影响:如果你的服务同时做大量文件 IO 和大量 spawn_blocking 计算、它们会抢同一个 blocking pool——可能互相饿死。解决方案是用 tokio-uring(如果你只跑 Linux)或者手动给 spawn_blocking 加限流 Semaphore。这种"多用户抢同一资源"的问题在 blocking pool 上会反复出现、需要你主动设计隔离机制。
看一下 tokio::fs::read 的实现(简化版):
// tokio/src/fs/read.rs:46
pub async fn read(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
let path = path.as_ref().to_owned();
asyncify(move || std::fs::read(path)).await
}
// tokio/src/fs/mod.rs
pub(crate) async fn asyncify<F, T>(f: F) -> io::Result<T>
where F: FnOnce() -> io::Result<T> + Send + 'static, T: Send + 'static,
{
match spawn_blocking(f).await {
Ok(res) => res,
Err(_) => Err(io::Error::new(io::ErrorKind::Other, "background task failed")),
}
}
本质就是"spawn_blocking + await"——没有任何操作系统层面的"异步文件 IO"。那为什么 Tokio 还提供 tokio::fs?纯粹是让调用者代码长得像 async——少一个手动 spawn_blocking 的 boilerplate。
Linux 的 io_uring 其实能提供真正的异步文件 IO、但 Tokio 到 1.40 还没有原生支持——需要 tokio-uring crate。这是一个长期 open 的演进方向。
为什么 Tokio 还没把 io_uring 合进主线?两个原因:跨平台成本(macOS 的 kqueue 是 readiness 模型、没有对等的"submit-completion"机制;Windows 的 IOCP 虽然也是完成模型、但 API 与 io_uring 差异很大——都要做一层抽象)、API 语义差异(io_uring 的 completion 是"内核写到完成队列"、epoll 的 readiness 是"文件描述符可读/可写"——两者的 Future 状态机写法不一样)。tokio-uring 选择做一个独立的 runtime,和主 Tokio 共存但不是替代——这种"不强求统一接口"的 pragmatism 比"为了一致性牺牲 Linux 性能"明智得多。这是基础设施演进的另一个经验教训:有时候并存比统一更健康。
16.6 实用心法与跨专栏呼应
关于 blocking 我想再补几段实战心得——这些观察来自真实生产经验、不容易从文档里读到、但对写出健壮 Tokio 代码非常重要。
心法 1:怀疑所有看起来"同步"的 API——Rust 生态里大量 crate 没有 async 版本(crossbeam、serde、image、regex)。调它们本身不等于阻塞——但如果它们内部会等 IO 或 跑几百毫秒 CPU 密集、那就是 hidden blocking。不确定时的安全做法是用 spawn_blocking 包一层——哪怕多一次调度开销、也比偶发卡住 worker 强得多。
心法 2:blocking task 也算 task——它们的 panic 一样会被 JoinHandle 报告,也能放进 JoinSet、拿到 AbortHandle。这让 spawn_blocking 在 API 表面上和 spawn 几乎完全对称——你可以用同样的模式管理它们。唯一要记住的例外是取消:abort 只能拦住还没开跑的 blocking task(§16.1)。这种对称性是 Tokio API 设计的一大优点。
心法 3:blocking pool 默认 512 不是神奇数字——它就是 Builder::default() 里的一个默认值,官方只说了"别设太小"。真实服务里如果你用了大量 spawn_blocking(比如每请求一个阻塞 IO)、要按 "请求速率 × 平均阻塞时长" 自己算一遍、必要时用 max_blocking_threads 调整——不要假设 512 一定够。
心法 4:spawn_blocking 比 tokio::task::spawn 贵得多——前者要拿一把全局 Mutex、做一次 Condvar 唤醒(或者创建 OS 线程),至少跨一次内核调度;后者只是往 worker 的本地队列里塞一个指针。所以对极短的操作用 spawn_blocking 是得不偿失的,而对真正会阻塞几十上百毫秒的操作、这点开销可以忽略。具体的分界点请在自己的机器上 bench。
《Vue 3 设计与实现》第 10 章讲过Vue 的异步组件 + Suspense——遇到 await 时挂起整个组件子树、显示 fallback UI。概念上和 block_in_place 相反——Vue 把"await"抽象成 UI 层的悬挂、Tokio 的 block_in_place 把"阻塞"抽象成调度器层的 core 移交。两个方向都是"让异步和同步在同一个代码里共存"。
《Rust 编译器与运行时揭秘》第 11 章讲过 std::thread::spawn 对闭包的 Send + 'static 约束是怎么来的——blocking pool 的每个线程最终都是 thread::Builder::new().spawn(...) 起来的,spawn_blocking 那个 F: FnOnce() -> R + Send + 'static 的签名就是这条约束的直接后果。但 Tokio 不是裸调 thread::spawn、而是自己维护 SpawnerMetrics 里的 num_threads / num_idle_threads 等计数,这样才能做 max_blocking_threads 限流。这是"运行时库在 std 之上自建调度层"的典型模式——和那个专栏里讲的"std 只给你原语、真正的调度靠上层库"完全对应。
《vLLM 推理内核深度解析》第 2 章、第 17 章里的分层拆分——API server 进程负责 HTTP、chat template 渲染和 tokenize/detokenize,EngineCore 是独立进程,Worker 又是另一批进程。这和 Tokio 的"worker pool + blocking pool"分离是同构的思想:不同负载特征的任务用不同的执行单元、互不干扰。
16.7 一套决策流程:如何在五秒内判断该用哪个
判断"这里该用 spawn_blocking / block_in_place / 还是不用"只需要三个问题:(1)是 CPU 密集还是 I/O 阻塞?(2)预期阻塞多久?(3)是偶发还是高频?。这三个问题串起来就是一个完整决策树——本节把这棵决策树画出来、让你下次遇到类似场景时 5 秒出结果。
决策树简化版:CPU 密集 + 需并行,或会持续吃满一个核 → rayon;偶发、较短的 CPU 片段 → spawn_blocking;I/O 阻塞 + 偶发 → spawn_blocking;I/O 阻塞 + 高频 → 用 async 版本的 I/O 库(如果有的话)或者上限流 + spawn_blocking;初始化阶段的一次性阻塞 → 直接阻塞或 block_on(runtime 还没启动);其他几乎所有场景 → 不用任何 blocking API——那些场景原本就应该写成纯 async。
生产 code review 里我常用这套决策流程、分享给你:
- 这段代码会阻塞吗?(sleep / syscall / heavy compute / lock 等)——不会 → 直接 async、不用任何特殊工具;
- 会,那它 CPU-bound 还是 IO-bound?CPU-bound 且会持续吃满一个核 → Rayon(或一个专门跑 CPU 任务的独立 runtime);
- IO-bound(时间花在等 syscall 上)或很短的 CPU 片段 → spawn_blocking;
- 必须在当前栈上同步拿到结果(Drop、不想改签名的老接口)→ block_in_place(且必须 multi_thread runtime);
- 并发阻塞任务数逼近
max_blocking_threads→ 重新审视架构——这是"你该换设计"的信号、不是"Tokio 该加配置"的时候。
这五条能覆盖绝大多数生产决策。剩下的是和 C FFI / 老式库的适配细节——那些要 case by case 看。
16.8 观测 blocking pool 的实战技巧
真实生产里、你怎么知道 blocking pool 健康还是不健康?稳定 API 里没有直接"查"blocking pool 状态的接口——但有几个间接手段:tokio-console 能看到各个 blocking task 的状态、定期 metrics 暴露 active blocking threads 数量、日志记录每个 spawn_blocking 调用的耗时。这一节把三种方法合起来讲、让你在生产环境能对 blocking pool 有可观测性。
这些观测手段的组合使用就是"运行时健康看板"——在服务监控仪表盘上加几张关键图:blocking pool 当前 active 线程数(应该远低于 512、平时个位数、突发几十)、blocking queue 排队深度(应该几乎总是 0,持续不为 0 且还在涨就要警惕)、spawn_blocking 调用的 P99 耗时(判断是否有长期阻塞逐渐积累)。这三张图盯住、blocking pool 的健康状态就一目了然——比任何事后根因分析都更及时。
很多性能问题只有在看到 blocking pool 的实时指标后才能定位。Tokio 提供了两条观测路径:
路径一:Metrics(unstable、需要 tokio_unstable cfg):
let handle = tokio::runtime::Handle::current();
let metrics = handle.metrics();
println!("blocking threads: {}", metrics.num_blocking_threads());
println!("idle blocking: {}", metrics.num_idle_blocking_threads());
println!("queue depth: {}", metrics.blocking_queue_depth());
路径二:tokio-console —— 图形化界面、实时刷新。下一章专门讲。
黄金规则:生产环境一定要把这三个数暴露到 Prometheus——num_blocking_threads 长期在 500+ 上下游走 = blocking pool 快满了;blocking_queue_depth > 0 且持续增加 = 任务提交速率 > 处理速率、排队积压。这两个告警通常能在真正出事之前就把你叫醒。
反面教训很常见:把 CPU 密集任务扔进 spawn_blocking、又从不看 metrics——num_blocking_threads 顶在上限、blocking_queue_depth 一路走高,这些信号其实一直在,只是没人订阅。看得见才能调得动——下一章就讲这个主题。
16.9 从 scheduler 视角看"一次 spawn_blocking 调用的完整旅程"
跟一次 spawn_blocking 调用从开始到结束的完整过程——你会看到它如何跨越 async worker 和 blocking worker 两个世界。这条完整路径涉及几个关键步骤:(1)async task 调用 spawn_blocking 创建 BlockingTask、(2)通过 Spawner 送到 blocking pool 的全局队列、(3)某个 blocking worker 线程取出 Task 开始执行、(4)Task 完成、结果写进 Task 自己的 output slot、async 侧的 JoinHandle 被 wake 后读走。每一步都有自己的微妙细节。
这条旅程揭示了 Tokio 的一个深层架构设计:async 世界和 blocking 世界之间的桥就是 Task 本身——blocking 闭包和普通 async task 共用同一套 Header + Core + Trailer 布局,闭包的返回值写进 Core 里那个 Stage 枚举的 Finished 变体(tokio/src/runtime/task/core.rs:202),async 侧的 JoinHandle 被 wake 之后走 try_read_output 把它取走(tokio/src/runtime/task/harness.rs:280)。这条路上并没有 tokio::sync::oneshot 参与——blocking pool 里唯一的那个 oneshot 是用来发 shutdown 信号的(tokio/src/runtime/blocking/shutdown.rs:22)。理解这种"一套 Task 结构同时表示两个世界的工作"的复用,你就理解了 Tokio 整体的"用少数几个基础原语解决多种场景"的设计哲学。
为了让你对两个线程池的协作有立体感觉,跟着一次 spawn_blocking 走完整条路径:
时刻 T0(worker 线程 W0 上的某个 Task 执行到 spawn_blocking(f)):
- 代码拿到当前 runtime 的
Handle; - 把
f包成BlockingTask::new(func),再走task::unowned(fut, BlockingSchedule::new(rt), id)(tokio/src/runtime/blocking/pool.rs:389)——它和 async Task 共享同一套 Header + Vtable,只是 schedule 换成了BlockingSchedule; - 调
Spawner::spawn_task——进入 blocking pool 的世界。
紧接着(进入 spawner):
4. 获取 Inner::shared 的 Mutex 锁——临界区很短、无竞争时基本就是一次原子 CAS;
5. shared.queue.push_back(task)——VecDeque 的 push_back 均摊 O(1);
6. 检查 num_idle_threads()——有空闲线程就走第 7 步;没有就看 num_threads() 是否已到 thread_cap,没到就当场 spawn_thread 起新线程,到了就什么都不做、任务留在 queue 里;
7. condvar.notify_one()——唤醒一个正在 wait_timeout 的 blocking worker;
8. 释放锁——走"唤醒空闲线程"这条常见路径时持锁时间很短;但要起新线程时,创建 OS 线程的系统调用是在持锁期间完成的。
随后(blocking worker B7 被唤醒):
9. B7 从 condvar.wait_timeout 返回——这一跳要经过内核的线程唤醒与重新调度,是整条链路里最贵的一步;
10. wait_timeout 返回时已经重新持锁——核对 num_notify 确认不是 spurious wakeup,再从 queue 里 pop_front() 出刚才那个 task;
11. 释放锁、调 task.run()——开始执行用户闭包。
时刻 T0 + [闭包耗时](闭包执行中): 12. 闭包是同步代码、直接跑在 B7 上、不和 runtime 任何部分交互; 13. 同时 W0 上的原 Task 继续跑(毕竟 spawn_blocking 本身立即返回 JoinHandle、不阻塞)、worker pool 完全不知道 B7 在忙啥。
时刻 T1(闭包返回值 r 产生):
14. 闭包结果写入 Task 的 output slot;
15. Task 的 complete() 把 state 的 COMPLETE bit 置位;
16. 通知等待方——如果 JoinHandle 已经被 await、它内部存的 waker 被调用;
17. B7 回到 condvar 等下一个任务(或 10 秒超时退出)。
时刻 T1 + waker 开销(W0 被唤醒):
18. 原来 await JoinHandle 的那个 async Task 被 wake、重新进入 scheduler 的 run queue;
19. 下一次 poll 时、JoinHandle::poll 通过 vtable 把 output 读回来——第 15 章讲的那套 try_read_output。
关键观察:整条路径没有任何一次 worker 线程的 syscall 阻塞——所有"等"都发生在 blocking pool 内部(condvar)。worker 的吞吐不受任何阻塞任务影响——这就是为什么"两池分离"能带来稳定性。
这套流程的微妙之处在于每一跳单独看都不贵,但跳的次数不少(W0 → B7 → W0),而且其中两跳要穿过内核调度器。如果你的闭包本身跑得比这条链路还快(比如一个极短的 hash),那 overhead 就和工作本身一个量级、相当于白费力气——这种场景别 spawn_blocking,直接在 async 里跑。spawn_blocking 真正划算的区间是"闭包耗时远大于一次跨线程移交"——具体阈值随机器而变,务必自己 bench,别抄别人的数字。
这也是第 19 章(性能调优)会反复出现的主题:异步编程的成本从来不是零、只有在"工作量 >> 调度 overhead"时才划算。学会在脑子里数清一次操作跨了几次线程、几次内核态、是从"会写 async"到"会用 async"的跨越。
16.10 一个对比:同样的计算,三条路径的差别
把同一个 CPU 密集计算(比如给一大块数据算 SHA-256)用三条路径跑:直接在 async fn 里跑(错误示范)、spawn_blocking 包装、rayon 并行。直接在 async 里跑 CPU 密集任务会让当前 worker 停工、它队列里的其他 task 都跑不了——这正是错误示范的危害。而 spawn_blocking 和 rayon 都能把计算挪走、但瓶颈落点完全不同。
先说结论:直接调用——单次计算本身反而最快(没有任何调度开销),代价是同 worker 上的其他 task 全被推迟;spawn_blocking——单次多一次跨线程移交,但其他 async task 完全不受影响;rayon——同样多一次移交,但如果是"对一批数据并行算",它能把计算摊到所有核上,同时 async 也不受影响。三条路径各有场景——选对需要理解各自的 trade-off。
下面这张表只给定性结论——具体差多少强烈依赖机器、数据大小和并发模型,请在自己的环境上 bench:
| 实现方式 | 对其他 async task 的影响 | 单次计算本身 | 高并发下的瓶颈 |
|---|---|---|---|
直接在 async 里 hash(&data) |
最差:占住 worker 直到算完,同 worker 队列里没被偷走的 task 全被推迟 | 最快(零调度开销) | worker 被 CPU 占满,尾延迟随并发迅速恶化 |
spawn_blocking(|| hash(&data)) |
无(worker 不受影响) | 多一次跨线程移交 | blocking pool 的 thread_cap,以及"线程数远超核数"带来的上下文切换 |
rayon::spawn + oneshot |
无 | 多一次跨线程移交 | CPU 核数本身——这才是 CPU-bound 任务应该撞上的天花板 |
为什么第一行的尾延迟最容易炸?因为哈希本身耗时不长,但它把 worker 线程整段堵住,导致那个 worker 队列里没被别的 worker 偷走的 Task 全部顺延。在高并发下这种顺延会连锁叠加——某些 Task 经历几十次"被别人的计算推迟",p99 就上去了。
第二行用 spawn_blocking 解决了 worker 阻塞——但代价是把并发压力转嫁给 blocking pool:并发阻塞任务数一旦逼近 thread_cap,排队延迟就会出现;而且几百个线程抢十几个核,本身就在做无用的上下文切换。
第三行 rayon 才是 CPU-bound 的正解——它默认只开"核数"个线程、work-stealing 调度、不存在"线程数远大于核数"的 context switch 开销。
这组对比是"选对工具"的教科书例证——不是 Tokio 不好、而是 Tokio 的 blocking pool 是为 IO-bound 设计的、CPU-bound 交给 Rayon 才是正道。
16.11 本章小结
"如何在异步世界里容纳同步代码"是所有异步框架的共同挑战——Node.js 用 libuv 的 thread pool、Python asyncio 用 run_in_executor、Java 的 Project Loom 用 virtual thread。Tokio 的答案——spawn_blocking + 专属 blocking 线程池——是这类方案里最务实的一种。本章讲完、你对"async / sync 边界"这个工程现实有了实战级应对能力。
带走四件事:
- Tokio 的两个线程池职责分离——worker pool 跑 Future(= CPU 核数、work-stealing)、blocking pool 跑阻塞闭包(默认 512 上限、Condvar + VecDeque)——不要混淆
- spawn_blocking 是默认选择,适合偶发阻塞 I/O,不适合持续 CPU 密集——后者用 Rayon 或独立 worker runtime。blocking pool 的 512 上限一旦被 CPU 任务占满、延迟雪崩
- 阻塞时长 × 请求速率决定 blocking pool 是否会爆——设计前先做这个计算;
tokio::fs也是 blocking pool 的大用户,文件 IO 密集的服务要把它算进去 - block_in_place 是"把 worker core 偷给别人、自己阻塞"的骚操作——只在 multi_thread runtime 可用、只在迁移老代码 / Drop / 封库时用。能不用就不用
下一章进入 Runtime 可观测性——tokio::runtime::RuntimeMetrics、tokio-console、tracing 如何给 runtime 装上"X 光片"。你会看到为什么"看得见才能调得动"——所有性能调优的前提都是先有数据。
延伸阅读
- Tokio 源码:
tokio/src/runtime/blocking/pool.rs - Tokio 源码:
tokio/src/runtime/scheduler/multi_thread/worker.rs(block_in_place 实现) - Rayon 官方文档:CPU 密集场景的首选
- 《Vue 3 设计与实现》第 10 章:组件系统与异步组件
- 《vLLM 推理内核深度解析》第 17 章:API 服务器与生产部署