阻塞线程池
主线位置:主线之外。上一章说过,预算管不住不 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 一次就完成,永远不会被唤醒、不会再进队列。
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,这里不展开。)
#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 起标准输出和标准错误也用它,标准输入不用),保证已经开始的写入不会因为运行时关闭而丢掉。
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)交给另一个线程。
收银台(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));
- 唤醒
defer里的任务。它们存在线程本地的Context里,不跟着Core走,现在不唤醒,就要等这个线程阻塞完才有机会。 - 把 LIFO 槽里的任务挪进本地队列。注释说明了原因:如果阻塞池已经被
spawn_blocking占满,新线程可能要等很久才能起来,Core就一直没人跑。本地队列里的任务能被别的 worker 偷走,LIFO 槽里的不能。 - 把
Core放回Worker::core,再用spawn_blocking起一个线程执行run(worker)。新线程从Worker::core取到Core,接着当这个 worker。
第 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 的人最多。
关闭:等所有阻塞线程结束
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 运行时就永远卡住。这时用:
shutdown_timeout(d):最多等d,超时后放弃等待,还没结束的线程被泄漏,继续在后台跑。shutdown_background():相当于shutdown_timeout(0),完全不等。
等待本身是阻塞的。在异步上下文里 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。