Rust in Everything
基于 tokio 1.54.1

阻塞线程池

主线位置:主线之外。上一章说过,预算管不住不 await 的代码,这些代码要搬出 worker。另外,worker 线程本身也是从这个池子里借的。
flowchart TB
  sb[spawn_blocking] --> task[BlockingTask:只 poll 一次的任务]
  task --> q[队列:Mutex 里的 VecDeque]
  q --> idle{有空闲线程?}
  idle -->|有| wake[条件变量叫醒一个]
  idle -->|没有,未到上限| spawn[新建线程]
  idle -->|没有,已到上限| waitq[留在队列里等]
  wake --> runf[线程取出任务,调用闭包]
  spawn --> runf
  runf -->|队列空了| sleep[等 10 秒]
  sleep -->|有新任务| runf
  sleep -->|超时| exit[线程退出]

spawn_blocking:把闭包包成任务

Spawner::spawn_blocking_inner:

let id = task::Id::next();
let fut =
    blocking_task::<F, BlockingTask<F>>(BlockingTask::new(func), spawn_meta, id.as_u64());

let (task, handle) = task::unowned(
    fut,
    BlockingSchedule::new(rt),
    id,
    task::SpawnLocation::capture(),
);

let spawned = self.spawn_task(Task::new(task, is_mandatory), rt);
(handle, spawned)

闭包被包成一个 future,BlockingTask:

fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<R> {
    let me = &mut *self;
    let func = me
        .func
        .take()
        .expect("[internal exception] blocking task ran twice.");

    crate::task::coop::stop();

    Poll::Ready(func())
}

第一次 poll 就调用闭包,直接返回 Ready。调用前用 coop::stop() 把预算设成不限。

用 future 包一层,是为了复用整套任务机制:一次分配、状态机、JoinHandle、JoinError、panic 捕获,全都和普通任务一样(见 03 任务)。两个区别:

  • 用 task::unowned 创建,不挂进 OwnedTasks。运行时关闭时不会去取消它们,阻塞的闭包本来也取消不了。
  • 调度器是 BlockingSchedule,它的 schedule 是 unreachable!()。任务只 poll 一次就完成,永远不会被唤醒、不会再进队列。
❗IMPORTANT
动脑筋:abort 一个 spawn_blocking 任务会怎样?

M2 Max、rustc 1.99.0。current_thread 运行时,max_blocking_threads(1),所以阻塞池只有一个线程:

let a = tokio::task::spawn_blocking(|| {
  std::thread::sleep(Duration::from_millis(100));
  println!("A closure ran");
});
// Give A time to start on the only blocking thread.
tokio::time::sleep(Duration::from_millis(20)).await;
let b = tokio::task::spawn_blocking(|| println!("B closure ran"));
a.abort();
b.abort();
println!("A: {:?}", a.await);
println!("B: {:?}", b.await);

程序一共打印几行?A: 和 B: 后面各是 Ok 还是 Err?答案在本页末尾。

一把锁、一个队列、一个条件变量

默认实现是 LockedImpl:一个 Mutex 里放 VecDeque<Task> 和线程管理状态,外加一个 Condvar。(1.54 还有一个用 enable_sharded_blocking_queue 打开的分片实现,是不稳定 API,这里不展开。)

📝NOTE
冷知识:分片队列上过默认路径,又被撤了下来

#7757(1.52.0)曾把这把锁换成最多 16 个分片、每片一把锁。PR 的说明:只有一把锁时,多个线程同时 spawn_blocking 竞争严重,16 个线程并发要比单线程慢约 18 倍。它给的基准里,16 线程从 247.8ms 降到 22.4ms,单线程从 13.3ms 变成 17.8ms。

1.52.0 发布后,issue #8056 报告这个改动会让测试卡住。#8057 直接整个回退,发了 1.52.1。回退说明里写:分片实现和 spawn_blocking 的其他部分耦合太紧,没法简单地关掉,把 NUM_SHARDS 设成 1 照样会卡;以后要再引入,应该先做成不稳定的、需要手动打开的 Builder 选项。#8337(1.54.0)就是这样加回来的:默认关闭,用不稳定的 Builder::enable_sharded_blocking_queue 或环境变量 TOKIO_UNSTABLE_SHARDED_BLOCKING_QUEUE 打开。所以默认实现仍然是本节的 LockedImpl。

提交任务,LockedImpl::spawn_task:

let mut locked = self.mutex.lock();

if locked.thread_mgmt_state.shutdown {
    drop(locked);

    // Shutdown the task: it's fine to shutdown this task
    // (even if mandatory) because it was scheduled after the
    // shutdown of the runtime began.
    task.task.shutdown();
    return Err(SpawnError::ShuttingDown);
}

locked.queue.push_back(task);
metrics.inc_queue_depth();

if metrics.num_idle_threads() == 0 {
    on_no_idle(&mut locked.thread_mgmt_state)?;
} else {
    // Notify an idle worker thread. The notification counter
    // is used to count the needed amount of notifications
    // exactly. Thread libraries may generate spurious
    // wakeups, this counter is used to keep us in a
    // consistent state.
    metrics.dec_num_idle_threads();
    locked.num_notify += 1;
    self.condvar.notify_one();
}

有空闲线程就叫醒一个,没有就调 on_no_idle。后者在 Spawner::spawn_task 里:线程数已经到上限就什么也不做,任务留在队列里等;没到上限就新建一个线程。

num_notify 用来区分真唤醒和假唤醒:条件变量可能无缘无故醒来,线程醒来后只有看到 num_notify 不为 0,才认为是有人给了它任务。

线程的主循环,run_worker:

'main: loop {
    // BUSY
    while let Some(task) = locked.queue.pop_front() {
        metrics.dec_queue_depth();
        drop(locked);
        task.run();

        locked = self.mutex.lock();
    }

    // IDLE
    metrics.inc_num_idle_threads();
    // ...

    while !locked.thread_mgmt_state.shutdown {
        let lock_result = self.condvar.wait_timeout(locked, keep_alive).unwrap();

        locked = lock_result.0;
        let timeout_result = lock_result.1;

        if locked.num_notify != 0 {
            // We have received a legitimate wakeup,
            // acknowledge it by decrementing the counter
            // and transition to the BUSY state.
            locked.num_notify -= 1;
            // ...
            break;
        }

        // Even if the condvar "timed out", if the pool is
        // entering the shutdown phase, we want to perform
        // the cleanup logic.
        if !locked.thread_mgmt_state.shutdown && timeout_result.timed_out() {
            join_on_thread = locked.thread_mgmt_state.worker_timed_out(worker_thread_id);

            break 'main;
        }

        // Spurious wakeup detected, go back to sleep.
    }

    if locked.thread_mgmt_state.shutdown {
        // Drain the queue
        while let Some(task) = locked.queue.pop_front() {
            metrics.dec_queue_depth();
            drop(locked);

            task.shutdown_or_run_if_mandatory();

            locked = self.mutex.lock();
        }

        break;
    }
}
  • 忙:队列里有任务就一直取,执行时释放锁。
  • 闲:在条件变量上最多等 keep_alive,默认 10 秒,可以用 Builder::thread_keep_alive 改。等到了任务就回去干活,超时就退出线程。
  • 关闭:把队列里剩下的任务清掉。普通任务直接取消(shutdown),标为 mandatory 的任务照样执行。tokio::fs::File 的写入就是用 spawn_mandatory_blocking 提交的(1.54.0 起标准输出和标准错误也用它,标准输入不用),保证已经开始的写入不会因为运行时关闭而丢掉。
📝NOTE
冷知识:write_all 返回了,文件却是空的

issue #4296(tokio 1.14.0)照着 write_all 文档里的例子写:File::create 之后 write_all(b"some bytes").await?,不调 flush 就从 main 返回。用脚本反复运行,偶尔会得到一个空文件。

那时运行时关闭会取消阻塞池队列里所有还没开始的任务,文件写入也不例外。#4316(1.16.0)加了 mandatory 这个区分,PR 的说法是:不保证写入一定成功(执行时仍可能碰到操作系统错误),但至少会去尝试。stdout 和 stderr 是 2026 年 9 月的 #8506(1.54.0)才改成 mandatory 的。

退出的线程不会自己 join 自己。worker_timed_out 把自己的 JoinHandle 放进 last_exiting_thread,同时取出上一个退出线程的 JoinHandle,在退出前 join 它。这样任何时候最多只有一个已退出、还没被 join 的线程。

上限:512 个,另加 worker

max_blocking_threads 默认 512。multi_thread 总览讲过,worker 线程也是用 spawn_blocking 启动的,所以多线程运行时的线程池上限是 max_blocking_threads + worker_threads(第 2195 行),current_thread 运行时就是 max_blocking_threads(第 1769 行)。

到了上限,新任务在队列里排队,队列没有长度限制。

实验:600 个阻塞任务

默认配置的多线程运行时,提交 600 个各睡 200 毫秒的阻塞任务,统计用到了多少个线程:

use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

#[tokio::main]
async fn main() {
  let ids = Arc::new(Mutex::new(HashSet::new()));
  let start = Instant::now();
  let mut hs = Vec::new();
  for _ in 0..600 {
    let ids = ids.clone();
    hs.push(tokio::task::spawn_blocking(move || {
      ids.lock().unwrap().insert(std::thread::current().id());
      std::thread::sleep(Duration::from_millis(200));
    }));
  }
  for h in hs {
    h.await.unwrap();
  }
  println!(
    "600 blocking tasks ran on {} threads in {:?}",
    ids.lock().unwrap().len(),
    start.elapsed()
  );
}
600 blocking tasks ran on 512 threads in 408.186625ms

前 512 个任务各起一个线程,剩下 88 个排队,等第一批睡完再跑,所以总耗时是两个 200 毫秒。

512 是一个很大的数。阻塞池是为「偶尔阻塞一下」设计的,比如一次文件读写、一次 DNS 解析。如果要跑大量 CPU 密集的计算,开几百个线程只会互相抢 CPU,更合适的是用固定大小的线程池(比如 rayon),或者自己用 Semaphore 限制同时提交的数量。

block_in_place:把 Core 交出去

spawn_blocking 要求闭包是 'static + Send,有时做不到:阻塞的代码要借用当前任务里的局部变量。block_in_place 换了个思路:阻塞代码留在当前线程上执行,把 worker 的身份(Core)交给另一个线程。

💡TIP
打个比方:收银员要接一个长电话

收银台(Core:本地队列、LIFO 槽等)只有一套,站在台后的人(线程)可以换。收银员要接一个很长的电话(阻塞闭包),电话又不能带走(闭包借用了当前任务的局部变量)。于是他把收银台的钥匙挂回墙上(cx.worker.core.set(core)),从后屋叫一个人来顶班(spawn_blocking(move || run(worker))),自己站在原地打电话。

电话打完,钥匙还在墙上,说明顶班的人还没到。他把钥匙拿回来接着收银(Reset 的 Drop 取回 Core),顶班的人到了看见钥匙不在,就回后屋去了。钥匙已经被拿走的话,他把手头这一单收完,就回后屋待命(本次 poll 结束后 run 返回)。

比方到这里为止:真收银员挂钥匙之前,手上的东西自然会交代清楚;tokio 要专门做两件事才算交代完:唤醒 defer 里的任务,把 LIFO 槽里的任务挪进本地队列。下面会讲为什么。

入口是 block_in_place,交接在 maybe_move_runtime 里。先判断能不能交:

match (
    crate::runtime::context::current_enter_context(),
    maybe_cx.is_some(),
) {
    (context::EnterRuntime::Entered { .. }, true) => {
        // We are on a thread pool runtime thread, so we just need to
        // set up blocking.
        had_entered = true;
    }
    (
        context::EnterRuntime::Entered {
            allow_block_in_place,
        },
        false,
    ) => {
        // We are on an executor, but _not_ on the thread pool.  That is
        // _only_ okay if we are in a thread pool runtime's block_on
        // method:
        if allow_block_in_place {
            had_entered = true;
            return Ok(());
        } else {
            // This probably means we are on the current_thread runtime or in a
            // LocalSet, where it is _not_ okay to block.
            return Err(
                "can call blocking only when running on the multi-threaded runtime",
            );
        }
    }
    (context::EnterRuntime::NotEntered, true) => {
        // This is a nested call to block_in_place (we already exited).
        // All the necessary setup has already been done.
        return Ok(());
    }
    (context::EnterRuntime::NotEntered, false) => {
        // We are outside of the tokio runtime, so blocking is fine.
        // We can also skip all of the thread pool blocking setup steps.
        return Ok(());
    }
}
当前在哪结果
多线程运行时的 worker 线程上交出 Core,下面细讲
多线程运行时的 block_on 里(主线程)不用交,这个线程本来就不是 worker,直接执行
current_thread 运行时里panic:can call blocking only when running on the multi-threaded runtime
已经在 block_in_place 里(嵌套调用)直接执行
不在任何运行时里直接执行

current_thread 那一行,allow_block_in_place 来自 enter_runtime 的第二个参数:current_thread 的 block_on 传的是 false,多线程的 block_on 和 worker 线程传的是 true。current_thread 只有一个线程,没有别的线程能接手 Core,阻塞就等于停掉整个运行时,所以 tokio 直接拒绝。

在 worker 线程上,交接分三步:

// Since deferred tasks don't stay on `core`, make sure to wake them
// before blocking.
cx.defer.wake();

// Get the worker core. If none is set, then blocking is fine!
let mut core = match cx.core.borrow_mut().take() {
    Some(core) => core,
    None => return Ok(()),
};

// If we heavily call `spawn_blocking`, there might be no available thread to
// run this core. Except for the task in the lifo_slot, all tasks can be
// stolen, so we move the task out of the lifo_slot to the run_queue.
if let Some(task) = core.lifo_slot.take() {
    core.run_queue
        .push_back_or_overflow(task, &*cx.worker.handle, &mut core.stats);
}

// We are taking the core from the context and sending it to another
// thread.
take_core = true;

// ...

// In order to block, the core must be sent to another thread for
// execution.
//
// First, move the core back into the worker's shared core slot.
cx.worker.core.set(core);

// Next, clone the worker handle and send it to a new thread for
// processing.
//
// Once the blocking task is done executing, we will attempt to
// steal the core back.
let worker = cx.worker.clone();
runtime::spawn_blocking(move || run(worker));
  1. 唤醒 defer 里的任务。它们存在线程本地的 Context 里,不跟着 Core 走,现在不唤醒,就要等这个线程阻塞完才有机会。
  2. 把 LIFO 槽里的任务挪进本地队列。注释说明了原因:如果阻塞池已经被 spawn_blocking 占满,新线程可能要等很久才能起来,Core 就一直没人跑。本地队列里的任务能被别的 worker 偷走,LIFO 槽里的不能。
  3. 把 Core 放回 Worker::core,再用 spawn_blocking 起一个线程执行 run(worker)。新线程从 Worker::core 取到 Core,接着当这个 worker。
📝NOTE
冷知识:第 1、2 步都是后来补的

第 2 步来自 2024 年 5 月的 #6596(1.38.0),PR 说明和今天的注释几乎一字不差:大量调用 spawn_blocking 时,可能没有空闲线程来跑这个 Core;Core 里除了 LIFO 槽,其他任务都能被偷走,所以先把 LIFO 槽里的任务挪进本地队列,避免它在这种边缘情况下饿死。

第 1 步来自 2026 年 2 月的 #7879(1.50.0),修的是 issue #7877。issue 描述的场景:任务 1 在循环里 select! 一个 mpsc。它的预算用完,select! 返回 Pending,waker 进了 defer;channel 上次被看时不是空的,所以 channel 自己没有登记 waker。任务 2 恰好在同一个线程上运行,给任务 1 发了一个请求,再用 block_in_place 等结果。任务 1 的 waker 还在这个线程的 defer 里,要等 block_in_place 结束才会被唤醒,而 block_in_place 在等任务 1。issue 的说法是:这可能导致无限期的停顿。

然后当前线程退出运行时上下文,执行闭包。闭包结束时,Reset 的 Drop 尝试把 Core 取回来:

impl Drop for Reset {
    fn drop(&mut self) {
        with_current(|maybe_cx| {
            if let Some(cx) = maybe_cx {
                if self.take_core {
                    let core = cx.worker.core.take();

                    // ...

                    let mut cx_core = cx.core.borrow_mut();
                    assert!(cx_core.is_none());
                    *cx_core = core;
                }

                // Reset the task budget as we are re-entering the
                // runtime.
                coop::set(self.budget);
            }
        });
    }
}

Worker::core 里有没有 Core,取决于新线程有没有赶在闭包结束前启动:

  • 还在(新线程还没来得及取):拿回来,当前线程接着当 worker,好像什么都没发生过。新线程起来后发现 Worker::core 是空的,run 直接返回。
  • 已经被取走:当前线程没有 Core 了,但手里这个任务的 poll 还没结束,它会在没有 Core 的状态下继续跑到下一个 .await。这期间它唤醒的任务都进注入队列。poll 返回后,run_task 发现 Context 里没有 Core,返回 Break,run 结束,这个线程回到阻塞池。

所以一个任务调用 block_in_place 之后,它的后半段可能和前半段不在同一个线程上,执行它的线程也可能不再是一个 worker。

实验:block_in_place 让别的任务不用等

一个 worker 的运行时,任务 A 阻塞 300 毫秒;50 毫秒后,主线程 spawn 任务 B。分别用 std::thread::sleep 直接阻塞,和包在 block_in_place 里阻塞:

use std::time::{Duration, Instant};

fn main() {
  for use_block_in_place in [false, true] {
    let rt = tokio::runtime::Builder::new_multi_thread()
      .worker_threads(1)
      .enable_all()
      .build()
      .unwrap();
    let start = Instant::now();
    rt.block_on(async move {
      let a = tokio::spawn(async move {
        println!("  A starts on {:?}", std::thread::current().id());
        if use_block_in_place {
          tokio::task::block_in_place(|| std::thread::sleep(Duration::from_millis(300)));
        } else {
          std::thread::sleep(Duration::from_millis(300));
        }
        println!("  A ends   on {:?} at {:?}", std::thread::current().id(), start.elapsed());
      });
      tokio::time::sleep(Duration::from_millis(50)).await;
      let b = tokio::spawn(async move {
        println!("  B runs   on {:?} at {:?}", std::thread::current().id(), start.elapsed());
      });
      b.await.unwrap();
      a.await.unwrap();
    });
    println!("block_in_place = {use_block_in_place} done");
  }
}
  A starts on ThreadId(2)
  A ends   on ThreadId(2) at 305.087833ms
  B runs   on ThreadId(2) at 305.278458ms
block_in_place = false done
  A starts on ThreadId(3)
  B runs   on ThreadId(4) at 51.76075ms
  A ends   on ThreadId(3) at 301.761542ms
block_in_place = true done

直接阻塞时,唯一的 worker 被 A 占住,B 等了 300 毫秒,在 A 结束后才在同一个线程上运行。用 block_in_place 时,Core 交给了新线程 4,B 在 50 毫秒时立刻在新线程上运行;A 的闭包仍在原来的线程 3 上执行完。

在 current_thread 上调用:

#[tokio::main(flavor = "current_thread")]
async fn main() {
  tokio::task::block_in_place(|| {});
}
thread 'main' (15533453) panicked at src/bin/bip_ct.rs:3:3:
can call blocking only when running on the multi-threaded runtime

这是「10 常见坑」里的一条,见 current_thread 上的 block_in_place。#[tokio::test] 默认用的就是 current_thread,在测试里碰到这个 panic 的人最多。

关闭:等所有阻塞线程结束

BlockingPool::shutdown:

pub(crate) fn shutdown(&mut self, timeout: Option<Duration>) {
    // The function can be called multiple times. First, by explicitly
    // calling `shutdown` then by the drop handler calling `shutdown`. This
    // prevents shutting down twice.
    let inner = &self.spawner.inner;
    let (last_exited_thread, workers) = match inner.inner_impl.begin_shutdown(&inner.metrics) {
        Some(x) => x,
        None => return,
    };

    if self.shutdown_rx.wait(timeout) {
        let _ = last_exited_thread.map(thread::JoinHandle::join);

        // ...

        for (_id, handle) in workers {
            let _ = handle.join();
        }
    }
}

begin_shutdown 设置关闭标志,notify_all 叫醒所有空闲线程,让它们清掉队列后退出。

怎么知道所有线程都退出了?blocking/shutdown.rs 用了一个小技巧:一个 oneshot channel,发送端包在 Arc 里,每个线程启动时拿一份克隆,退出时 drop。所有克隆都 drop 以后,oneshot 的接收端收到「发送端已关闭」,wait 就返回了。

Runtime 的 Drop 不带超时,会一直等。正在执行的阻塞闭包取消不了,如果它永远不返回,drop 运行时就永远卡住。这时用:

等待本身是阻塞的。在异步上下文里 drop 一个运行时(比如在一个 tokio 任务里 drop 另一个 Runtime),wait 进不了阻塞区域,会 panic:

Cannot drop a runtime in a context where blocking is not allowed. This happens when a runtime is dropped from within an asynchronous context.

shutdown_background 不等,所以没有这个问题,Runtime 的文档也推荐在这种场合用它。

动脑筋答案

完整程序:

use std::time::Duration;

fn main() {
  let rt = tokio::runtime::Builder::new_current_thread()
    .max_blocking_threads(1)
    .enable_all()
    .build()
    .unwrap();
  rt.block_on(async {
    let a = tokio::task::spawn_blocking(|| {
      std::thread::sleep(Duration::from_millis(100));
      println!("A closure ran");
    });
    // Give A time to start on the only blocking thread.
    tokio::time::sleep(Duration::from_millis(20)).await;
    let b = tokio::task::spawn_blocking(|| println!("B closure ran"));
    a.abort();
    b.abort();
    println!("A: {:?}", a.await);
    println!("B: {:?}", b.await);
  });
}

M2 Max、rustc 1.99.0、--release,连跑 5 次,输出都一样:

A closure ran
A: Ok(())
B: Err(JoinError::Cancelled(Id(2)))

一共 3 行,B closure ran 没有出现。

  • A 已经在跑。 abort 走到 transition_to_notified_and_cancel 的「正在运行」分支,只是给状态加上 CANCELLED,打断不了正在执行的闭包。闭包睡完、打印,BlockingTask::poll 返回 Ready。Harness 看到 poll 返回 Ready,就按正常完成处理,不再看 CANCELLED,所以 a.await 拿到 Ok(())。A closure ran 排在 A: 前面,是因为 a.await 要等闭包结束。
  • B 还在队列里。 任务创建时就带着 NOTIFIED 位,abort 只加上 CANCELLED,不需要再调度(BlockingSchedule::schedule 本来也是 unreachable!())。阻塞线程跑完 A 后取出 B,transition_to_running 看到 CANCELLED,直接取消任务,闭包一次也没被调用,b.await 得到 Cancelled。

所以「阻塞的闭包取消不了」说的是已经开始执行的闭包。还在队列里排队的 spawn_blocking 任务,abort 是有效的。

小结

  • spawn_blocking 把闭包包成一个只 poll 一次的任务,复用任务的分配、JoinHandle 和 panic 捕获,但不挂进 OwnedTasks,关闭时不会被取消。
  • 线程池是一把锁、一个 VecDeque、一个条件变量。有空闲线程就叫醒一个,没有就新建,到上限就排队;空闲 10 秒的线程退出。
  • 上限默认 512,多线程运行时另加 worker_threads,因为 worker 也是这个池子里的线程。
  • block_in_place 把 Core 交给一个新线程,当前线程原地执行阻塞代码;结束后能拿回 Core 就接着当 worker,拿不回就在本次 poll 结束后退出。current_thread 上调用会 panic。
  • drop 运行时会等所有阻塞线程结束;等不了的时候用 shutdown_timeout 或 shutdown_background。

💬 关联讨论

发起讨论
加载中...