在 tokio 任务里调 par_iter,运行时停住
主线位置:主线 1 的入口,「不在任何池」那条路。机制在 in_worker:从哪进池,完整实验在 spawn 与 spawn_fifo。tokio 一侧的分析见 tokio 卷 阻塞在 async 里。
sequenceDiagram participant T as tokio worker participant I as rayon 注入队列 participant R as rayon worker T->>T: async fn 里 data.par_iter().sum() T->>I: in_worker_cold:StackJob 注入 T->>T: LockLatch::wait_and_reset,睡在 Condvar 上 R->>I: 取到任务,整棵 join 树在 rayon 池里展开 R->>T: LockLatch::set,唤醒 Note over T: 这段时间这个 tokio worker 上的其他任务一个都不跑
现象
一个 async handler 里对一大块数据直接 par_iter()。计算本身很快,但这段时间里同一个运行时上的定时器、心跳、其他请求都停了;在 current_thread 运行时上(或者 #[tokio::main(flavor = "current_thread")]、很多测试框架的默认)整个服务没有响应。
复现
实验 E5(exp-rayon/tokio-rayon/):cpu_job 是 400 万个元素的 par_iter().map(..).reduce(..),12 线程单次约 50 ms;连续算 5 次,同一运行时上一个 1 ms 心跳任务记录最大间隔,每组 3 次取中位数,1 / 5 分钟负载 2.34–2.54 / 2.75–2.87。单位 ms:
| tokio 运行时 | 调用方式 | rayon 12 线程 | rayon 8 线程 |
|---|---|---|---|
| current_thread | 不算(底数) | 2.6 | 2.3 |
| current_thread | 直接 par_iter | 245 | 317 |
| current_thread | spawn_blocking | 11.0 | 2.9 |
| current_thread | rayon::spawn + oneshot | 11.4 | 2.7 |
| multi_thread(2) | 直接 par_iter | 11.5 | 2.7 |
| multi_thread(2) | 另两种 | 11.3–12.0 | 2.7 |
- current_thread 上直接调,心跳停了约 5 次计算之和:5 次之间没有
.await,唯一的 worker 一直睡在LockLatch上。 - 另两种写法约 11 ms 是 rayon 的 12 个线程占满 12 个核、tokio 线程被 OS 挤掉;rayon 缩到 8 线程后都回到底数附近(反向验证)。
- multi_thread(2) 上直接调只占住一个 worker,心跳在另一个 worker 上照跑。但如果心跳任务是被阻塞的任务自己 spawn 的,它进了那个 worker 的 LIFO 槽,偷不走:同一程序加参数
inside,multi_thread(2) + 直接调 = 265 ms(257–278 ms)。
从哪来
全局的 in_worker 发现当前线程不是任何池的 worker,走 in_worker_cold:把整个操作包成 StackJob 注入全局队列,然后在线程本地的 LockLatch 上 wait_and_reset,即 Mutex + Condvar 阻塞。这对普通线程是对的(调用方要等结果),对 tokio worker 就是在 async 代码里做了阻塞调用,和 std::thread::sleep 没有区别。rayon 没有 async 接口,它的 API 都是同步的。
怎么改
rayon::spawn+tokio::sync::oneshot:let (tx, rx) = tokio::sync::oneshot::channel(); rayon::spawn(move || { let _ = tx.send(data.par_iter().map(f).sum::<u64>()); }); let total = rx.await?;计算整段交给 rayon worker,tokio worker 立刻回去跑别的任务。
spawn的闭包 panic 时没有 handler 会 abort,闭包里要么不 panic、要么自己catch_unwind后把结果送回(Rayon: detected unexpected panic; aborting)。tokio::task::spawn_blocking(move || data.par_iter()...):阻塞的是 tokio 阻塞池里的一个线程,计算仍由 rayon 池做(那个阻塞线程走的也是in_worker_cold)。多占一个线程,但 panic 会进JoinError,不会 abort。CPU 核数:rayon 池和 tokio worker 共用 CPU。延迟敏感的服务可以把 rayon 池设得比核数小(E5 里 8 线程时另两种写法的心跳回到 2.7 ms)。
小计算(几十微秒以内)直接在 async 里串行算,比任何一种跨线程交接都便宜(几十个元素的 par_iter 比串行慢几万倍)。
小结
- async 里直接
par_iter:tokio worker 走in_worker_cold,在LockLatch的Condvar上阻塞到整个并行计算结束。 - E5:current_thread 上心跳停 245 ms;multi_thread 上占住一个 worker,若被等的任务在它的 LIFO 槽里同样停 265 ms。
- 改法:
rayon::spawn+oneshot,或spawn_blocking;两者都不占 tokio worker。