Rust in Everything
基于 rayon-core 1.13.0

在 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.62.3
current_thread直接 par_iter245317
current_threadspawn_blocking11.02.9
current_threadrayon::spawn + oneshot11.42.7
multi_thread(2)直接 par_iter11.52.7
multi_thread(2)另两种11.3–12.02.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。

💬 关联讨论

发起讨论
加载中...