Tokio 源码深度解析
第16章 spawn_blocking 与 block_in_place:把阻塞优雅地塞进 async 世界
第16章 spawn_blocking 与 block_in_place:把阻塞优雅地塞进 async 世界
本章要点
- Tokio 有两个线程池:worker pool(默认 = CPU 核数,跑 Future)+ blocking pool(默认上限 512 线程,跑阻塞代码)——完全分离
spawn_blocking:把闭包扔到 blocking pool、返回一个JoinHandle——本质是BlockingPool::spawn_task往Mutex<VecDeque<Task>>push_back、Condvar 唤醒一个 idle 线程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、它负责的 250 个连接全部停工 500ms、吞吐量立刻下降 25%。一个阻塞点就能拖垮整个 runtime——这就是 “第一戒律” 的严肃性。本章讲的工具就是让你能合法地调用阻塞函数、同时不让它拖垮 runtime。
async fn handler() -> Vec<u8> {
std::fs::read("/etc/passwd").unwrap() // ⚠️ 同步 IO
}
你把这个 handler 挂在 axum 上、压测发现 QPS 比裸 tokio::fs::read 低 20 倍、而且CPU 核利用率只有 1 核。为什么?
因为 std::fs::read 是同步系统调用——它会让当前线程陷入内核态等盘。Tokio 的 worker 线程(默认 = CPU 核数)是共享的——这个 handler 把其中一个 worker 线程完全堵住、期间这个 worker 调度队列里的所有其他 Task 都饿死。8 核 CPU、8 个 worker,阻塞一个就少 1/8 的吞吐——连着阻塞 8 个,整个 runtime 直接停摆。
这就是 async 编程的第一条铁律:async fn 里严禁出现任何阻塞调用。包括——
- 同步文件 IO(
std::fs::*) - 同步网络 IO(
std::net::*) - CPU 密集计算(sha、压缩、正则、JSON 序列化超大对象)
- 锁的阻塞获取(
std::sync::Mutex::lock——如果临界区 >1 微秒就危险) - 任何 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, // ← 默认 512
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 稍长一点——但大部分步骤都是 fast path 下的简单原子操作、总开销在微秒级。
注意 “唤醒闲置 worker” 这一步的 trickiness——如果此时没有闲置 worker(全在跑别的)、pool 会 spawn 一个新 OS 线程(直到 max_blocking_threads 上限 512);超过上限时新 task 会排队等。这个 “按需扩张” 让 blocking pool 能自适应负载——平时轻量、突发场景下能瞬间扩容到几百线程。
用户调 tokio::task::spawn_blocking(|| ...),背后走到 spawn_blocking_inner → spawn_task:
shared.queue.push_back(task);
self.inner.metrics.inc_queue_depth();
// 如果没有 idle 线程、且未达 thread_cap、就启一个新线程:
if self.inner.threads_idle.get() == 0
&& shared.num_th < self.inner.thread_cap
{
self.inner.spawn_thread(shutdown_tx, rt, id);
shared.num_th += 1;
}
// 否则就只唤醒已有线程:
self.inner.condvar.notify_one();
三步:push 队列、如果需要起新线程就起、Condvar 唤醒一个 consumer。
工作线程循环
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 大致跑这个:
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 秒——空闲超过 10 秒自动退出、线程数缩回去。thread_cap = 512——超过 512 个任务同时挂着会阻塞新提交(push_back 在锁内、队列无上限但线程数有上限)。
这两个参数都可以在 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 就没那么优雅——它已经在执行一段同步、不可中断的代码、abort 能做的只是”标记它为 cancelled、等它跑完再 drop”——真实世界里blocking 操作在执行中不可强制中断。这个限制你要记住——它是”同步代码嵌入异步世界”的根本代价之一。
spawn_blocking 返回 JoinHandle<T>——和 tokio::spawn 一样的类型。你可以 await 它、abort 它、和 select! 组合。这是 Tokio 设计上的一致性巧思——blocking task 在外部接口上和 async task 完全同质、调用者不需要区分。
但 abort 对 blocking task 的语义比较弱:Rust 没有线程中断机制(不像 Java 的 Thread.interrupt),你 abort 一个已经 start 跑的 blocking closure、Tokio 只能等它自己结束——abort 的作用是:不再把 output 交给你、JoinHandle 收到 JoinError::cancelled。闭包里的代码照跑不误。
16.2 block_in_place:偷走自己的 core
block_in_place 是 Tokio 里最容易被误用的 API——它的工作方式完全违反直觉。它不是把当前 task 搬到别处、而是把当前 worker 线程从 Scheduler 里”偷走”给当前 task 独享、同时 spawn 一个新 worker 接管 Scheduler 的工作。听起来就很危险——事实上它也确实危险、有一堆 corner case。本节讲它的正确使用和陷阱。
为什么 Tokio 要提供一个这么危险的 API?——历史原因 + 极罕见的合法用途。block_in_place 在早期 Tokio 就存在(比 spawn_blocking 更早)、当时是解决阻塞问题的主要方式。spawn_blocking 出现后、大部分场景都转向 spawn_blocking。但有极少数 “必须就地阻塞 + 不能让其他 async task 卡住”的场景(比如某些 middleware 的初始化代码)还需要 block_in_place。现代 Tokio 代码里看到 block_in_place 通常要么是老代码、要么是非常特殊的边界场景。
第二条路完全不同。看源码:
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 夺回来:
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 在让出 Scheduler 前、必须把自己 LIFO slot 里的任务转交给新起的替代 worker。如果忘做这件事、那个 task 在 block 期间就会永远卡住(没人 poll 它)。这个细节体现了 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 饿死。类似的细节在整个 scheduler 源码里有几十处、每一处都是”真实 production bug 交学费换来的”。
block_in_place 的铁律:只能用在 multi_thread runtime
在 current_thread runtime 上调用 block_in_place 会直接 panic——因为没有其他 worker 可以替代它工作、“偷走当前 worker”会让整个 runtime 停摆。这个铁律在文档里明确写着、但很多人写代码时没注意、然后在生产里单元测试(用的是 current_thread)直接 panic。任何时候写 block_in_place 都要检查你的 runtime 配置——这个”小盒子警告”值得贴在编辑器边上。
current_thread runtime 只有一个 worker——把它的 core 给谁?没人!所以 block_in_place 在 current_thread 下会 panic:
thread panicked at 'can call blocking only when running on the multi-threaded runtime'
这是 runtime 选择又一次影响你能写什么代码的例子——第 7 章(current_thread)讲过类似话题。
16.3 两者的取舍矩阵
spawn_blocking 和 block_in_place 这两个 API 看起来相似、实际应用场景完全不重叠。本节用一张清晰对比表帮你秒速决策:I/O 阻塞用 spawn_blocking、CPU 密集看情况、极特殊才用 block_in_place。实操上 block_in_place 99% 的场景都不该用——用错了会让 Tokio 表现出各种诡异行为。
一个常被问的问题:“spawn_blocking 和 std::thread::spawn 有啥区别?“——答案很微妙:功能上它们都能”把代码扔到单独线程跑”、但 spawn_blocking 返回的是 Tokio JoinHandle(能 await、能 abort、能放 JoinSet)、而 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 thread |
| 延迟 | ~几微秒(Condvar 唤醒 + 可能起新线程) | ~几微秒(spawn 新 thread 接管) |
| 并行度 | 可并行 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——但仔细想会发现这些场景 99% 都可以用 spawn_blocking + 传递 state 替代。block_in_place 留在 Tokio API 里更多是历史原因(早期 Tokio 版本缺 spawn_blocking)、现在只在极罕见场景下有优势。
我个人经验:在超过十年的 Rust 异步实战里、我用过 block_in_place 不超过三次——而且每次用完都后悔、觉得当时应该再想想能不能用 spawn_blocking。默认不用、需要时再证明为什么非用不可——这是对 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() -> 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 满员、新请求全部被阻塞、服务性能崩溃。根本原因是:blocking pool 默认上限 512 线程、每个阻塞 task 占一个线程——如果 request rate × 平均阻塞时长 > 512、池就满。这个数学关系很多人第一次遇到都意识不到、上线后才发现。本节讲怎么预防、监控、补救。
这个案例背后有一个广义教训:Tokio 的 spawn_blocking 语义让”阻塞是局部的”成立——但”局部”的能力是有上限的。如果你把阻塞操作当成”免费”(反正不污染 async worker)、在无限扩张的路径上使用——最终就会打爆 blocking pool 这个有限资源。任何”看起来免费”的系统资源都不是真免费——只是代价以另一种方式出现。在 spawn_blocking 这里、代价是 blocking pool 的线程数上限。把这个教训内化、你就不会在其他”看起来免费”的技术决策上重蹈覆辙。
某生产服务把 CPU 密集计算(每个请求做 50ms sha256)放在 spawn_blocking 里。压测发现:
- 低并发(QPS < 500):一切正常;
- 中并发(QPS ~1000):blocking pool 线程数飙到 512;
- 高并发(QPS > 1500):p99 延迟暴涨到 10 秒以上——请求全在 blocking pool 的 VecDeque 里排队。
问题根源:blocking pool 的 thread_cap = 512——意思是”最多 512 个线程”,不是”最多 512 个排队的任务”。排队任务上限是 VecDeque 无限(物理内存决定)。超过 512 个同时在跑的 blocking task,第 513 个开始排队、延迟随排队长度线性增长。
正确选择: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 官方文档也明确建议:“If you have long-running CPU-bound tasks, use a dedicated thread pool like Rayon”。
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存在、但它底层就是 spawn_blocking 封装——操作系统没有通用的异步文件 IO、只能用 worker 线程池模拟) - DNS 查询(
getaddrinfo是阻塞的——tokio::net::lookup_host内部也是 spawn_blocking)
这些场景的共同特征:时间花在”等系统调用”而不是”占 CPU”。线程绝大多数时间在 syscall 里 sleeping、不占 CPU 核——512 个线程都 sleep 并不比 8 个更费 CPU。这就是为什么 thread_cap 可以开到 512:blocking pool 本质上是个 IO 线程池、不是 CPU 线程池。
换个角度看:为什么不是 512 个线程都在 sleep 的理由
Tokio 默认最多 512 blocking 线程——但你不用担心开了 512 线程系统会崩。大多数时候 blocking pool 里只有个位数线程活跃——因为空闲 worker 10 秒后会自动 exit(thread_keep_alive=10s)。这个自动收缩机制让 blocking pool “按需扩张”——平时几乎不占资源、突发阻塞任务多时能临时开几百线程应对。这种”自适应弹性”是 Tokio blocking pool 设计里最优雅的部分之一。
读到这里常有人问:“既然 blocking 线程大部分时间 sleep、那多开一点有什么坏处、为什么还要限 512?“答案在线程自己的成本:
- 栈内存:Tokio 默认给 blocking 线程分配 2 MB 栈——512 个线程就是 1 GB 虚存(Linux 用了 lazy allocation、只有真正 touch 的 page 才占物理内存、但虚存 mapping 还是要花);
- 内核调度开销:Linux 的 CFS 调度器对几千个线程还算稳、但对几万线程会出现抖动(调度决策的 O(log N) 变得不可忽视);
- 上下文切换:每次 syscall 返回都可能 reschedule、大量线程竞争同一核心时 L1/L2 cache 的命中率崩盘;
- thread local storage:Rust 的
thread_local!变量每个线程都要独立的一份、数量过多时累加内存可观。
512 是一个经验数字——在 32 核的典型服务器上、它大约对应”每核 16 个并发阻塞调用”。这和 Linux 内核调度器对线程数 : CPU 数 = 8~32 : 1 的舒适区高度吻合。再高意味着你其实有更深层的架构问题,而不是”让 Tokio 多开几个线程”能解决的。
细节:thread_keep_alive 为什么是 10 秒
10 秒是一个经验常数——不是太短(避免在流量间隙不断 spawn/destroy 线程、浪费 OS 调用)、也不是太长(避免长时间闲置的线程占着不用)。这个”既不激进也不保守”的设定覆盖了绝大多数真实服务场景。如果你的负载特征特别(比如有规律的大流量峰谷)、可以调这个参数——但绝大多数服务不需要动它。
10 秒这个数字也不是拍脑袋。太短(比如 1 秒):低峰期线程频繁销毁、高峰期又要现起——线程创建 = 一次 pthread_create ≈ 50 微秒、高频场景积累起来可观;太长(比如 5 分钟):RSS 长期占着不释放、idle 资源浪费。10 秒刚好覆盖典型”低谷 → 回升”的时间尺度——大多数业务的流量波动周期是分钟级而非秒级、10 秒之内还有活来就别销毁、10 秒还没有就大概率一阵没事了。
Tokio 这种”默认值踩在 80 分位”的哲学,让 90% 的用户开箱就有合理表现、而不需要去啃一堆配置文档。这是基础设施库成熟的标志之一——相反,不成熟的库会把决策全推给调用方、美其名曰”灵活”。
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 的实现(简化版):
pub async fn read(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
let path = path.as_ref().to_owned();
tokio::task::spawn_blocking(move || std::fs::read(path))
.await
.map_err(|_| io::Error::new(io::ErrorKind::Other, "join error"))?
}
本质就是”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 和 Windows 的 IOCP 都没有直接对等的”submit-completion”模型、要做一层抽象)、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——它们和普通 task 一样会被 abort、会被 cancel、它们的 panic 一样会被 JoinHandle 报告。这让 spawn_blocking 在 API 表面上和 spawn 几乎完全对称——你可以用同样的模式管理它们(JoinSet、AbortHandle、cancellation 等)。这种对称性是 Tokio API 设计的一大优点。
心法 3:blocking pool 默认 512 不是神奇数字——它是 Tokio 作者在”绝大多数服务够用”和”内存消耗可控”之间权衡的结果。真实服务里如果你用了大量 spawn_blocking(比如每请求一个阻塞 IO)、要主动通过 max_blocking_threads 配置调大这个值——不要假设 512 够。
心法 4:spawn_blocking 和 tokio::task::spawn 差一个数量级的开销——前者要跨线程移交 + 有 OS 调度开销(几十微秒)、后者只是在 worker 本地入队(几百纳秒)。对执行时间小于 10 微秒的操作用 spawn_blocking 是得不偿失的——但对执行时间几百毫秒的阻塞操作、开销可以忽略。
《Vue 3 设计与实现》第 8 章讲过Vue 的异步组件 + Suspense——遇到 await 时挂起整个组件子树、显示 fallback UI。概念上和 block_in_place 相反——Vue 把”await”抽象成 UI 层的悬挂、Tokio 的 block_in_place 把”阻塞”抽象成调度器层的 core 移交。两个方向都是”让异步和同步在同一个代码里共存”。
《Rust 编译器与运行时揭秘》第 11 章讲过 Rust std 的 thread::spawn 如何调 pthread_create——blocking pool 的每个线程都是这样创建的。但 Tokio 不是直接 thread::spawn、而是自己维护 spawned thread count 等指标,这样才能做 max_blocking_threads 限流。这是”运行时库在 std 之上自建调度层”的典型模式——和那本书里讲的”std 只给你原语、真正的调度靠上层库”完全对应。
《vLLM 源码剖析》里的CPU-bound tokenization 和 GPU-bound inference 分开池——vLLM 把 tokenizer 放在 blocking thread pool、把 forward 放在独占的 GPU worker。这和 Tokio 的”worker pool + blocking pool”分离是完全同构的思想:不同负载特征的任务用不同的池、互不干扰。
16.6½ 一套决策流程:如何在五秒内判断该用哪个
判断”这里该用 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。
判断”这里该用 spawn_blocking / block_in_place / 还是不用”只需要三个问题:(1)是 CPU 密集还是 I/O 阻塞?(2)预期阻塞多久?(3)是偶发还是高频?。这三个问题串起来就是一个完整决策树——本节把这棵决策树画出来、让你下次遇到类似场景时 5 秒出结果。
生产 code review 里我常用这套决策流程、分享给你:
- 这段代码会阻塞吗?(sleep / syscall / heavy compute / lock 等)——不会 → 直接 async、不用任何特殊工具;
- 会,那它 CPU-bound 还是 IO-bound?CPU-bound 且耗时 >1 ms → Rayon;
- IO-bound 或短 CPU(< 1 ms)→ spawn_blocking;
- 必须在当前栈上同步拿到结果(Drop、不想改签名的老接口)→ block_in_place(且必须 multi_thread runtime);
- 每秒并发阻塞任务数 > 几百→ 重新审视架构——这是”你该换设计”的信号、不是”Tokio 该加配置”的时候。
这五条能覆盖 99% 的生产决策。剩下 1% 是和 C FFI / 老式库的适配细节——那些要 case by case 看。
16.6¾ 观测 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、偶尔几个、持续 >10 就要警惕)、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 且持续增加 = 任务提交速率 > 处理速率、排队积压。这两个 alert 能在 OOM 发生前几十分钟把你叫醒。
反面教训:见过一个团队把 CPU 密集扔 spawn_blocking 里、从没看过 metrics——直到某次上线后两天 OOM、被 on-call 同学翻遍日志才定位到。看得见才能调得动——下一章就讲这个主题。
16.6⅞ 从 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 完成、结果通过 oneshot channel 送回给 async 侧的 JoinHandle。每一步都有自己的微妙细节。
这条旅程揭示了 Tokio 的一个深层架构设计:async 世界和 blocking 世界之间的桥是 oneshot channel——send 侧在 blocking 线程、recv 侧在 async task、两边通过这个轻量 channel 交接结果。这种”用 channel 做跨世界桥梁”的设计在 Tokio 里反复出现(task 的 JoinHandle 也是 oneshot channel、tokio-stream 的 Stream 类型也用类似思路)——理解这个模式你就理解了 Tokio 整体的”用少数几个基础原语解决多种场景”的设计哲学。
为了让你对两个线程池的协作有立体感觉,跟着一次 spawn_blocking 走完整条路径:
时刻 T0(worker 线程 W0 上的某个 Task 执行到 spawn_blocking(f)):
- 代码拿到当前 runtime 的
Handle; - 把
f包装成一个UnownedTask——它和 async Task 共享同一套 Header + Vtable、但 schedule 函数指向 blocking pool 的 spawner; - 调
Spawner::spawn_task——进入 blocking pool 的世界。
时刻 T0 + 50ns(进入 spawner):
4. 获取 Inner::shared 的 Mutex 锁——约 30 纳秒;
5. shared.queue.push_back(task)——VecDeque 的 push_back 均摊 O(1);
6. 检查 idle_count 和 num_th < thread_cap——决定是否要起新线程;
7. 如果不起新线程就 condvar.notify_one()——唤醒一个正在 wait_timeout 的 blocking worker;
8. 释放锁——Mutex 本身的锁住时间 < 1 微秒、竞争极低。
时刻 T0 + 几微秒(blocking worker B7 被唤醒):
9. B7 从 condvar.wait_timeout 返回——约 2-5 微秒(内核 schedule 开销);
10. 重新拿锁、从 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)——这意味着 spawn_blocking 的调度 overhead 大概几微秒到十几微秒。如果你的闭包本身耗时也是几微秒(比如一个超快的 hash),那 overhead 和工作本身一样多、相当于白费力气——这种场景别 spawn_blocking,直接在 async 里跑(反正不会阻塞太久)。spawn_blocking 的甜点区是”闭包耗时 > 100 微秒”——overhead 可忽略、隔离带来的稳定性收益明确。
这也是第 19 章(性能调优)会反复出现的主题:异步编程的成本从来不是零、只有在”工作量 >> 调度 overhead”时才划算。学会在脑子里估每个跳点的纳秒数、是从”会写 async”到”会用 async”的跨越。
16.6⅞⅞ 一个对比实验:同样的计算,三种路径的差距
把同一个 CPU 密集计算(SHA256 哈希 1MB 数据)用三种方式跑、比较性能:直接在 async fn 里跑(错误示范)、spawn_blocking 包装、rayon 并行。你会看到直接在 async 里跑 CPU 密集任务会让整个 worker 停工、其他 async task 都跑不了——这正是错误示范的危害。而 spawn_blocking 和 rayon 都能并行跑计算、但性能特征不同。
实验观察:直接调用——async runtime 的其他 task 全部被阻塞几百毫秒、单个 SHA256 的执行反而最快(没有调度开销);spawn_blocking——SHA256 比前者慢一点(有线程切换开销)、但其他 async task 完全不受影响;rayon par_iter——如果需要对多个数据并行计算、rayon 能让计算本身快 N 倍(N = CPU 核心数)、同时 async 也不受影响。三种路径各有场景——选对需要理解各自的 trade-off。
把同一个 CPU 密集计算(SHA256 哈希 1MB 数据)用三种方式跑、比较性能:直接在 async fn 里跑(错误示范)、spawn_blocking 包装、rayon 并行。你会看到直接在 async 里跑 CPU 密集任务会让整个 worker 停工、其他 async task 都跑不了——这正是错误示范的危害。而 spawn_blocking 和 rayon 都能并行跑计算、但性能特征不同。
我们做过一个对比实验:同一个 “计算 1 MB 数据的 SHA-256” 任务,分别用三种方式跑在 axum 服务器上、8 核机器、1000 并发连接:
| 实现方式 | p50 延迟 | p99 延迟 | 最大吞吐 |
|---|---|---|---|
直接在 async 里 hash(&data) | 2 ms | 380 ms | 3 k QPS |
spawn_blocking(|| hash(&data)) | 3 ms | 45 ms | 8 k QPS |
rayon::spawn + oneshot | 3 ms | 12 ms | 15 k QPS |
第一行的 p99 为什么暴涨到 380 ms?因为 SHA 本身只要 2 ms、但它把 worker 线程堵住 2 ms、导致那个 worker 队列里其他 Task 全延迟 2 ms。在高并发下连锁叠加——某些 Task 经历几十次”被其他 Task 的阻塞推迟”、p99 就炸了。
第二行用 spawn_blocking 解决了 worker 阻塞——但 blocking pool 的 512 线程上限意味着 QPS 超过 512/2ms = 256k 时会出问题(实测 8k 是 bottleneck 在别处、远没到这个上限)。
第三行 rayon 最快——因为 rayon 只开 8 个线程、work-stealing 调度、没有”线程数远大于核数”的 context switch 开销、CPU 利用率接近 100%。
这组数据是”选对工具”的教科书例证——不是 Tokio 不好、而是 Tokio 的 blocking pool 是为 IO-bound 设计的、CPU-bound 交给 Rayon 才是正道。希望你记住这张表——下次遇到性能问题时、能立刻想起”可能不是代码的错、是选错了工具”。
16.7 本章小结
“如何在异步世界里容纳同步代码”是所有异步框架的共同挑战——Node.js 用 libuv 的 thread pool、Python asyncio 用 run_in_executor、Java 的 Project Loom 用 virtual thread。Tokio 的答案——spawn_blocking + 专属 blocking 线程池——是这类方案里最务实的一种。本章讲完、你对”async / sync 边界”这个工程现实有了实战级应对能力。
4 个 take-home:
- spawn_blocking 是默认选择——90% 场景用它、不用纠结。
- block_in_place 基本不用——99% 场景下错选它、看似方便实则危险。
- 阻塞时间 × QPS 决定 blocking pool 是否会爆——设计前先做这个计算。
- tokio::fs 是 blocking pool 的大用户——文件 IO 密集的服务要把 blocking pool 限额调大。
下一章(第 17 章)讲 tokio-console 和可观测性——生产服务不能靠”祈祷”保证健康、必须有监控。你会学会怎么用 tokio-console 实时看 Tokio 内部状态、怎么定位性能瓶颈、怎么用 metrics crate 暴露运行时指标。
带走三件事:
- Tokio 的两个线程池完全分离——worker pool 跑 Future(= CPU 核数、work-stealing)、blocking pool 跑阻塞闭包(默认 512 上限、Condvar + VecDeque)——不要混淆
- spawn_blocking 适合偶发阻塞 I/O,不适合持续 CPU 密集——后者用 Rayon 或独立 worker runtime。blocking pool 的 512 上限一旦被 CPU 任务占满、延迟雪崩
- block_in_place 是”把 worker core 偷给别人、自己阻塞”的骚操作——只在 multi_thread runtime 可用、只在迁移老代码 / Drop / 封库时用。能不用就不用
下一章进入 Runtime 可观测性——tokio::runtime::Metrics、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 密集场景的首选
- 《Rust 编译器与运行时揭秘》第 11 章:std::thread 与 pthread_create
- 《Vue 3 设计与实现》第 8 章:Suspense 与异步组件
- 《vLLM 源码剖析》CPU/GPU 异构池章节