在 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,长期等待用专门的线程。
💬 关联讨论
发起讨论加载中...