Cannot drop a runtime in a context where blocking is not allowed
主线位置:请求主线在阻塞客户端里的那一段:调用线程把请求交给后台线程,然后在
wait::timeout里 park。后台线程和运行时怎么搭起来、默认 30 秒超时怎么分段,见 阻塞客户端。这一页只看它被放进 async 代码时会怎样。
sequenceDiagram participant W as 运行时线程 participant B as 阻塞 Client participant G as 后台线程 participant S as 服务端任务 W->>B: send B->>G: 交出请求 Note over W: park 等结果 G->>S: 连接 127.0.0.1 Note over S: 和 W 同一个运行时 Note over S: 没人 poll Note over W: 2 秒后 TimedOut
实验 crate:exp-reqwest/pita-lab,src/bin/p02_blocking.rs。服务端 testsrv 和调用代码跑在同一个运行时上,这正是 #[tokio::test] 里起测试服务器的形状。客户端设了 2 秒超时。M2 Max、macOS 27、rustc 1.99.0。
现象
代码看起来没什么问题,只是在一个 async 块里用了阻塞客户端:
rt.block_on(async {
let srv = testsrv::start(testsrv::Opts::default()).await;
let url = srv.url("/a");
let c = reqwest::blocking::Client::builder().no_proxy().timeout(Duration::from_secs(2)).build().unwrap();
let r = c.get(url).send().map(|r| r.status());
// ...
});
debug 构建,多线程和单线程运行时都一样,build() 就 panic:
thread 'main' (8114043) panicked at .../tokio-1.54.1/src/runtime/blocking/shutdown.rs:51:21:
Cannot drop a runtime in a context where blocking is not allowed. This happens when a runtime is dropped from within an asynchronous context.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
RUST_BACKTRACE=1 里 reqwest 的帧:
7: reqwest::blocking::wait::enter
at .../reqwest-0.13.5/src/blocking/wait.rs:88:21
8: reqwest::blocking::wait::timeout::<tokio::sync::oneshot::Receiver<core::result::Result<(), reqwest::error::Error>>, core::result::Result<(), reqwest::error::Error>, tokio::sync::oneshot::error::RecvError>
at .../reqwest-0.13.5/src/blocking/wait.rs:13:5
9: <reqwest::blocking::client::ClientHandle>::new
at .../reqwest-0.13.5/src/blocking/client.rs:1476:15
10: <reqwest::blocking::client::ClientBuilder>::build
at .../reqwest-0.13.5/src/blocking/client.rs:116:9
release 构建不 panic,结果取决于运行时:
multi: Ok(200 OK) after 7.379417ms, server reqs=1
current: Err(error sending request for url (http://127.0.0.1:52570/a)) after 2.010322584s, server reqs=0
reqwest::Error { kind: Request, url: "http://127.0.0.1:52570/a", source: TimedOut }
current-ext: Ok(200 OK) after 2.1705ms, server reqs=1
- 多线程运行时上「成功」了,只是一个 worker 线程被占了 7 ms。
- 单线程运行时上,服务端一个请求都没收到,2 秒后报超时。这个错误和「服务器真的不回」长得一模一样。默认配置要等 30 秒,
timeout(None)就永远等下去。 current-ext还是单线程运行时,只是服务端挪到另一个线程自己的运行时上,请求又成功了。
原因
debug 的 panic 是 reqwest 借来的检查。 阻塞客户端每次等结果都走 wait::timeout,它第一行调 enter:
fn enter() {
// Check we aren't already in a runtime
#[cfg(debug_assertions)]
{
let _enter = tokio::runtime::Builder::new_current_thread()
.build()
.expect("build shell runtime")
.enter();
}
}
在运行时里建一个空运行时再丢掉,tokio 会 panic,于是报的是「Cannot drop a runtime」,而不是「不要在 async 里用阻塞客户端」。build() 在 ClientHandle::new 里等后台线程启动,也经过 wait::timeout,所以最先 panic 的是 build()。整段检查包在 #[cfg(debug_assertions)] 里,release 构建什么都不查。
release 下调用线程直接 park。 wait::timeout 的主体是一个循环:poll 一次 future,没好就 thread::park_timeout(deadline - now)。调用线程就是运行时的线程,它 park 的时候,这个线程上的其他任务都停着:
- 多线程运行时:服务端任务被别的 worker 偷走执行,请求能完成,代价是一个 worker 被阻塞。
- 单线程运行时:唯一的线程在 park,服务端的任务没人 poll,请求没人处理,直到
wait::timeout的截止时间到了才返回TimedOut。
所以死锁的条件是「请求依赖的东西也跑在调用方这个运行时上」:本机测试服务器、同进程的 mock、或者另一个在等这个线程的任务。current-ext 那一行说明,服务端不在这个运行时上时请求能完成,只是调用方的整个运行时停了那么久。
修法
在 async 代码里用异步 Client。 这是唯一干净的做法,.send().await 不占线程。
非用阻塞客户端不可(比如调一个只提供阻塞接口的库),放进 spawn_blocking:
let r = tokio::task::spawn_blocking(move || {
let c = reqwest::blocking::Client::builder().no_proxy().timeout(Duration::from_secs(2)).build()?;
c.get(url).send().map(|r| r.status())
})
.await
.unwrap();
fix: Ok(200 OK) after 1.708458ms, server reqs=1
这是 release、单线程运行时;debug 构建同样是 Ok(200 OK),服务端 reqs=1。spawn_blocking 的线程不是运行时的 worker,enter 的检查通过,park 也不会挡住服务端任务。Client 的 drop 会 join 后台线程,同样是阻塞操作,要和创建、使用一起放在阻塞线程里。
怎么检查
- 跑 debug 测试。
cargo test默认是 debug 构建,上面的 panic 在测试里就会出现;只在 release 下跑的集成测试和压测看不到它,只会看到莫名其妙的超时。 - 搜代码:
grep -rn 'reqwest::blocking' src/,逐个看调用点是不是在async fn里、或者被 async 代码同步调用。 - 自己的包装加断言: 如果项目里有一层包装阻塞客户端的函数,开头加
debug_assert!(tokio::runtime::Handle::try_current().is_err()),比 reqwest 的报错更直白。 - 一个请求在单线程运行时上正好卡满客户端的超时、服务端却说没收到请求,先怀疑这个坑。
小结
- 在运行时里用
reqwest::blocking,debug 构建在build()时 panic,报错说的是「丢掉运行时」,来自wait::enter借用的 tokio 检查。 - release 构建没有检查:调用线程在 worker 上 park;多线程运行时里只是占住一个 worker,单线程运行时里同运行时的服务端没人 poll,请求等满超时(默认 30 秒,
timeout(None)永远)。 - 修法是用异步
Client;必须用阻塞接口时,建、用、丢都放进spawn_blocking。