Rust in Everything
基于 crossbeam-channel 0.5.17

在 tokio 任务里 recv,整个运行时卡住

主线位置:一条消息的阻塞部分,Context::wait_until 里的 thread::park(Context 与 Waker)。它停住的是整个 OS 线程。tokio 一侧「任务只在 .await 处让出」的道理在 tokio 卷的 在 async 里阻塞。

flowchart TB
  task["tokio 任务里调 r.recv()"] --> bo["Backoff 自旋约 11 次"]
  bo --> pk["thread::park:整个 worker 线程睡下"]
  pk --> ct{运行时}
  ct -->|current_thread| dl["发送方任务永远不被 poll:死锁"]
  ct -->|"多线程,还有空闲 worker"| ok["其他 worker 照常运行"]
  ct -->|"多线程,worker 全被占住"| stall["任务和定时器都停住"]

现象

在 #[tokio::main] 或 #[tokio::test](默认是 current_thread)里,一个任务往 crossbeam channel 发,另一个任务 recv:

let (s, r) = crossbeam_channel::bounded::<u32>(1);
tokio::spawn(async move {
  tokio::time::sleep(Duration::from_millis(10)).await;
  s.send(42).unwrap();
});
let v = r.recv(); // 永远不返回

程序卡住,CPU 不忙,也没有报错。换成多线程运行时它「能跑」,于是问题被带进生产环境,在负载上来、阻塞的任务多起来以后才出现。

实测

exp-crossbeam/pitfalls/src/bin/async_block.rs(crossbeam-channel 0.5.17、tokio 1.54.1,release,跑 3 次结果一致)。每种情形另起一个看门狗线程,2 秒后还没结束就打印一行并以退出码 2 结束进程:

[current_thread] watchdog: still blocked after 2s, exiting
[multi_thread workers=4 blocked=1] 10ms sleep task finished: Ok(16.279291ms)
[multi_thread workers=4 blocked=3] 10ms sleep task finished: Ok(13.000958ms)
[multi_thread all blocked] watchdog: still blocked after 2s, exiting
[current_thread + spawn_blocking] got Ok(42) after 16.556167ms
  • current_thread:只有一个线程,recv 把它 park 住,发送方任务永远不会被 poll,死锁。
  • 多线程、4 个 worker、1 个或 3 个任务阻塞在 recv:剩下的 worker 照常干活,一个 sleep(10ms) 的任务 13–17 ms 完成。看起来没事。
  • 多线程、4 个 worker 全部阻塞:一个 sleep(10ms) 的任务外面包了 tokio::time::timeout(500ms),2 秒后两者都没有完成。不只是新任务排不上,连定时器都没有推进。为什么 timeout 也不触发:推测是这个运行时的时间驱动由空闲的 worker 在 park 时推进,所有 worker 都在 crossbeam 的 park 里,没有谁去推进它(tokio 卷 driver 与 park),本页没有进一步验证。
  • current_thread + spawn_blocking:recv 挪到阻塞线程池上,16 ms 拿到值(10 ms 的 sleep 加上调度)。

从哪来

crossbeam 的阻塞是线程级的:recv 在 Backoff 自旋、让出约 11 次后,把当前线程的 Context 挂进 SyncWaker,然后 thread::park()(Context 与 Waker)。它不知道自己跑在一个 tokio worker 上,也没有 Waker 可以注册,等待期间这个 worker 不能 poll 任何别的任务。send 在有界 channel 满的时候同理。

crossbeam-channel 没有 async 接口;try_recv / try_send 不阻塞,但也不会在有消息时叫醒任务。

怎么改

  • 两边都是 async:换 tokio::sync::mpsc(或 flume,它同时有同步和 async 接口)。它们的等待者是任务的 Waker,不占线程(和 tokio mpsc、flume 对照)。
  • 一边是普通线程、一边是 async:tokio 的 mpsc 在同步一侧有 blocking_send / blocking_recv;或者保留 crossbeam,在 async 一侧用 spawn_blocking(move || r.recv())。spawn_blocking 的线程池有上限(默认 512),每个阻塞的 recv 占一个线程,长期等待的消费者不适合这样做,应该改成一个专门的普通线程。
  • 只是偶尔看一眼:try_recv() 不阻塞,可以在 async 代码里用;但别写成 loop { try_recv(); yield_now().await } 的忙等。
  • 测试:#[tokio::test] 默认 current_thread,最容易在这里死锁;改 flavor = "multi_thread" 只是把问题藏起来。

小结

  • crossbeam 的 recv / send 阻塞的是线程,在 tokio 任务里调用会占住 worker。
  • current_thread 运行时里会直接死锁;多线程运行时在阻塞的任务占满所有 worker 后整体停摆,实测连 timeout 都不触发。
  • async 两端用 tokio mpsc 或 flume;跨同步 / 异步边界用 blocking_* 或 spawn_blocking,长期等待用专门的线程。

💬 关联讨论

发起讨论
加载中...