Rust in Everything
基于 reqwest 0.13.5

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。

💬 关联讨论

发起讨论
加载中...