Rust in Everything
基于 tokio 1.54.1

can call blocking only when running on the multi-threaded runtime

主线位置:主线之外。block_in_place 要把 worker 的 Core 交给另一个线程,current_thread 运行时没有这样的线程。

完整报错:

can call blocking only when running on the multi-threaded runtime
flowchart TB
  b[block_in_place] --> w{在哪里调用}
  w -->|multi_thread worker| ok1[交出 Core,原地阻塞]
  w -->|"multi_thread 的 block_on"| ok2[直接执行]
  w -->|运行时之外| ok3[直接执行]
  w -->|current_thread| p[panic]
  w -->|LocalSet 里| p

触发它的代码

最常见的场景是测试。代码在 #[tokio::main](默认多线程)下一直正常,写测试时 #[tokio::test] 默认是 current_thread:

use std::time::Duration;

fn load_index() -> usize {
  std::thread::sleep(Duration::from_millis(20)); // pretend: blocking file read
  42
}

async fn handler() -> usize {
  tokio::task::block_in_place(load_index)
}

fn main() {
  // LocalSet on a multi_thread runtime: also refused
  let rt = tokio::runtime::Runtime::new().unwrap();
  let local = tokio::task::LocalSet::new();
  let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
    rt.block_on(local.run_until(handler()))
  }));
  println!("inside LocalSet on multi_thread: panicked = {}", r.is_err());
}

#[cfg(test)]
mod tests {
  use super::*;

  #[tokio::test]
  async fn default_flavor() {
    assert_eq!(handler().await, 42);
  }

  #[tokio::test(flavor = "multi_thread")]
  async fn multi_thread_flavor() {
    assert_eq!(handler().await, 42);
  }
}

cargo test 的输出:

running 2 tests
tests::default_flavor --- FAILED
.
failures:

---- tests::default_flavor stdout ----

thread 'tests::default_flavor' (15649636) panicked at src/bin/bip.rs:9:3:
can call blocking only when running on the multi-threaded runtime
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::default_flavor

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

cargo run 的输出(多线程运行时,但在 LocalSet 里):

thread 'main' (15649458) panicked at src/bin/bip.rs:9:3:
can call blocking only when running on the multi-threaded runtime
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
inside LocalSet on multi_thread: panicked = true

所以「运行时是多线程的」还不够,调用点还不能在 LocalSet 里。#[tokio::main(flavor = "current_thread")] 下当然也一样。

📝NOTE
没有蠢问题

问: 多线程运行时里别的 worker 都还在跑,在 LocalSet 里 block_in_place 阻塞一下,别的线程不是照样能接手吗?

答: 别的线程能接手的只有 worker 的 Core,也就是那个 worker 的本地队列和 LIFO 槽。LocalSet 里的任务不在任何 Core 里,它们排在 LocalSet 自己的队列上,而且可以是 !Send 的,只能在当前这个线程上 poll。block_in_place 交得出 Core,交不出 LocalSet:这个线程一阻塞,同一个 LocalSet 里的其他任务就全部停住,和在 current_thread 上阻塞是一回事。所以 LocalSet 在 poll 期间把 allow_block_in_place 临时清成 false(LocalSet::poll、RunUntil::poll),让它报和 current_thread 同一句 panic。

源码里在哪

报错文本在 maybe_move_runtime 里,它返回 Err,由 block_in_place 在第 404 行 panic!。判断依据是线程本地的 EnterRuntime::Entered { allow_block_in_place }:current_thread 的 block_on 进入运行时时传 false,LocalSet 被 poll 期间也临时设成 false。

📝NOTE
冷知识:LocalSet 到 1.46.0 才把 block_in_place 完全挡住

0.2.19 之前,在 spawn_blocking 的闭包里调 block_in_place 会撞上一个断言失败(issue #2327)。#2410(0.2.19)重新划了界:运行时之外(不用 tokio、在 spawn_blocking 闭包里)可以阻塞,多线程运行时的 block_on 里也可以;「在 LocalSet 和 basic scheduler 的 block_on 里阻塞仍然不允许」。LocalSet 这一侧的实现,是在 RunUntil::poll 里清掉标志。

这留了个口子:直接 .await 一个 LocalSet 走的是 LocalSet::poll,LocalSet 被 drop 时析构任务走的是 Drop for LocalSet,两条路都不经过 RunUntil。2025 年的 issue #7368 报告「spawn_local 任务里的 block_in_place 不 panic」,#7372(1.46.0)给这两处也加上了同样的守卫。1.54.1 里一共三处:LocalSet::poll、Drop for LocalSet、RunUntil::poll。

完整的判断表、为什么 current_thread 必须拒绝(只有一个线程,阻塞就等于整个运行时停摆),以及多线程上 Core 怎么交接,阻塞线程池的「block_in_place:把 Core 交出去」一节已经讲过。allow_block_in_place 这个标志在哪里设、LocalSet 怎么改它,见线程本地上下文。

怎么修

测试改成多线程

只是测试里报错、生产代码跑在多线程运行时上,最小的改动是:

#[tokio::test(flavor = "multi_thread")]
async fn multi_thread_flavor() {
  assert_eq!(handler().await, 42);
}

上面的输出里这个测试通过了。需要固定线程数可以加 worker_threads = 2。

改用 spawn_blocking

spawn_blocking 在任何运行时上都能用,包括 current_thread 和 LocalSet 里:

use std::time::Duration;
use tokio::runtime::{Handle, RuntimeFlavor};

fn load_index() -> usize {
  std::thread::sleep(Duration::from_millis(20));
  42
}

// block_in_place on multi_thread, spawn_blocking elsewhere (not LocalSet-aware)
async fn load_index_async() -> usize {
  match Handle::current().runtime_flavor() {
    RuntimeFlavor::MultiThread => tokio::task::block_in_place(load_index),
    _ => tokio::task::spawn_blocking(load_index).await.unwrap(),
  }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  println!("spawn_blocking on current_thread: {}", tokio::task::spawn_blocking(load_index).await.unwrap());
  println!("flavor-aware helper on current_thread: {}", load_index_async().await);
}
spawn_blocking on current_thread: 42
flavor-aware helper on current_thread: 42

代价是闭包要 Send + 'static,不能借用局部变量。借用是选 block_in_place 的主要理由,如果阻塞代码不需要借用,直接用 spawn_blocking 最简单。

按运行时类型选

上面的 load_index_async 用 Handle::current().runtime_flavor() 在多线程上走 block_in_place,其他情况走 spawn_blocking。它挡不住 LocalSet 的情况。在默认的 #[tokio::main] 里:

let local = tokio::task::LocalSet::new();
let flavor = local.run_until(async { Handle::current().runtime_flavor() }).await;
println!("runtime_flavor() inside LocalSet: {flavor:?}");
println!("{}", local.run_until(load_index_async()).await);
runtime_flavor() inside LocalSet: MultiThread

thread 'main' (15666345) panicked at src/bin/bip_local.rs:11:35:
can call blocking only when running on the multi-threaded runtime

LocalSet 里看到的运行时类型还是 MultiThread,于是走了 block_in_place,照样 panic。代码可能在 LocalSet 里被调用,就只用 spawn_blocking。

情况改法
只有测试报错#[tokio::test(flavor = "multi_thread")]
库代码,不知道调用方用什么运行时spawn_blocking
必须借用局部变量,确定在多线程运行时的普通任务里block_in_place
想两边兼顾,且不会在 LocalSet 里调用按 runtime_flavor() 选择

小结

  • block_in_place 只在多线程运行时的普通任务里(或多线程的 block_on 里、运行时之外)可用。current_thread 和 LocalSet 里会 panic。
  • #[tokio::test] 默认是 current_thread,这是最常见的触发场景。
  • 不需要借用时,spawn_blocking 在哪都能用,是更通用的选择。
  • 按 runtime_flavor() 分支可以兼顾两种运行时,但识别不出 LocalSet。

💬 关联讨论

发起讨论
加载中...