Tokio 源码深度解析

第16章 spawn_blocking 与 block_in_place:把阻塞优雅地塞进 async 世界

作者 杨艺韬 · 10,096 字

第16章 spawn_blocking 与 block_in_place:把阻塞优雅地塞进 async 世界

本章要点

  • Tokio 有两个线程池:worker pool(默认 = CPU 核数,跑 Future)+ blocking pool(默认上限 512 线程,跑阻塞代码)——完全分离
  • spawn_blocking:把闭包扔到 blocking pool、返回一个 JoinHandle——本质是 BlockingPool::spawn_taskMutex<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_blockingblock_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_blockingblock_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_innerspawn_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_blockingblock_in_place
提交形式spawn_blocking(|| ...) 返回 JoinHandleblock_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 密集任务应该用 Rayonrayon::spawnrayon::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 密集 + 需并行rayonCPU 密集 + 不并行spawn_blockingI/O 阻塞 + 偶发spawn_blockingI/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 里我常用这套决策流程、分享给你:

  1. 这段代码会阻塞吗?(sleep / syscall / heavy compute / lock 等)——不会 → 直接 async、不用任何特殊工具;
  2. 会,那它 CPU-bound 还是 IO-bound?CPU-bound 且耗时 >1 ms → Rayon
  3. IO-bound 或短 CPU(< 1 ms)→ spawn_blocking
  4. 必须在当前栈上同步拿到结果(Drop、不想改签名的老接口)→ block_in_place(且必须 multi_thread runtime);
  5. 每秒并发阻塞任务数 > 几百重新审视架构——这是”你该换设计”的信号、不是”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)):

  1. 代码拿到当前 runtime 的 Handle
  2. f 包装成一个 UnownedTask——它和 async Task 共享同一套 Header + Vtable、但 schedule 函数指向 blocking pool 的 spawner;
  3. 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_countnum_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 ms380 ms3 k QPS
spawn_blocking(|| hash(&data))3 ms45 ms8 k QPS
rayon::spawn + oneshot3 ms12 ms15 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

  1. spawn_blocking 是默认选择——90% 场景用它、不用纠结。
  2. block_in_place 基本不用——99% 场景下错选它、看似方便实则危险。
  3. 阻塞时间 × QPS 决定 blocking pool 是否会爆——设计前先做这个计算。
  4. tokio::fs 是 blocking pool 的大用户——文件 IO 密集的服务要把 blocking pool 限额调大。

下一章(第 17 章)讲 tokio-console 和可观测性——生产服务不能靠”祈祷”保证健康、必须有监控。你会学会怎么用 tokio-console 实时看 Tokio 内部状态、怎么定位性能瓶颈、怎么用 metrics crate 暴露运行时指标。

带走三件事:

  1. Tokio 的两个线程池完全分离——worker pool 跑 Future(= CPU 核数、work-stealing)、blocking pool 跑阻塞闭包(默认 512 上限、Condvar + VecDeque)——不要混淆
  2. spawn_blocking 适合偶发阻塞 I/O不适合持续 CPU 密集——后者用 Rayon 或独立 worker runtime。blocking pool 的 512 上限一旦被 CPU 任务占满、延迟雪崩
  3. block_in_place 是”把 worker core 偷给别人、自己阻塞”的骚操作——只在 multi_thread runtime 可用、只在迁移老代码 / Drop / 封库时用。能不用就不用

下一章进入 Runtime 可观测性——tokio::runtime::Metricstokio-consoletracing 如何给 runtime 装上”X 光片”。你会看到为什么”看得见才能调得动”——所有性能调优的前提都是先有数据


延伸阅读