Rust in Everything
基于 rayon-core 1.13.0

任务里阻塞等另一个 rayon 任务,整个池卡住

主线位置:主线之外。rayon 的池大小固定,一个 worker 阻塞在 OS 原语上就少一个线程;所有 worker 都在等一个还排在队列里的任务时,没人去执行它。join 的文档在第 76–84 行就警告过「一个闭包阻塞等另一个闭包(比如用 channel)可能死锁」(join/mod.rs:76-84)。

flowchart TB
  op["scope 的 op(worker 0)"] -->|"spawn"| ws["等待者 w1..w(k-1)"]
  op -->|"最后 spawn"| setter["置标志的任务"]
  op --> b0["op 自己 Condvar::wait"]
  ws -->|"被空闲 worker 从队头偷走"| bk["worker 1..k-1 各自 Condvar::wait"]
  setter --> stuck["留在 worker 0 的 deque 里:没有空闲线程来取"]

现象

一个 rayon 任务里用 Condvar::wait、channel 的 recv()、Barrier::wait 之类的阻塞原语,等另一个 rayon 任务送来的信号。线程多、负载轻时总能跑完;线程少、或者等待者一多,整个池卡住,CPU 占用为零,所有 worker 都睡在 __psynch_cvwait(macOS)或 futex 上。

复现

实验 E12(exp-rayon/pitfalls/ 的 blocking),一个 Mutex<bool> + Condvar 的标志,5 秒看门狗。这一组是正确性实验,跑时 1 / 5 分钟负载在 4.5–5.4 / 4.9–5.6 之间(本机还有别的编译在跑)。

确定性版本(determ):k 线程的私有池,pool.scope 的 op(它本身就是 worker 上的一个任务)先 spawn k − 1 个等待者,最后 spawn 置标志的任务,然后自己也等标志。op 所在 worker 的 deque 里从老到新是「等待者 × (k − 1)、置标志者」,空闲 worker 从队头偷,先偷到的都是等待者。

k(线程数)等待者 + op结果(各 3 次)
10 + 13/3 卡死
21 + 13/3 卡死
43 + 13/3 卡死
1211 + 13/3 卡死
4(只 spawn 2 个等待者)2 + 13/3 正常,约 105 ms(其中 100 ms 是 op 故意睡的)
12(只 spawn 10 个等待者)10 + 13/3 正常

阻塞的任务数 ≥ 线程数就卡死,少一个就不会:多出来的那个 worker 偷完等待者后接着偷到置标志的任务。

普通写法(par):pool.install 里对 64 个元素 par_iter().for_each,第一个开始的元素 rayon::spawn 一个置标志的任务(它进的是这个 worker 的本地 deque),然后每个元素都阻塞等标志:

写法2 线程4 线程12 线程
par:Condvar::wait10/10 卡死10/10 卡死5/10 卡死
par-yield:轮询 rayon::yield_now()10/10 正常10/10 正常10/10 正常
par-thread:置标志者用 std::thread::spawn10/10 正常10/10 正常10/10 正常

12 线程时一半能跑完:取决于在所有 worker 都阻塞之前,有没有空闲的 worker 偷到那个置标志的任务;它压在并行迭代的切分任务上面,别人从队头偷时要先偷走更老的切分任务。

1 线程上的同一现象(自旋等另一个任务)在 yield_now 与 yield_local 里也测过(E16,spin_loop 3/3 卡死,yield_now 一次解开)。

从哪来

rayon 没有「worker 阻塞了就补一个线程」的机制。线程数在 Registry::new 时定死(ThreadPoolBuilder);一个任务在 OS 原语上睡着,那个 worker 就不再执行任何任务,它本地 deque 里的东西只能等别的 worker 来偷。rayon 自己的等待(join / scope 的 latch)不一样:wait_until_cold 在等的同时执行别的任务,所以嵌套多深都不会把线程耗尽。用户代码里的 Condvar / recv 绕开了这一层。

tokio 的阻塞池会按需开新线程,rayon 的设计是固定大小的计算池,阻塞操作应该放在池外(01 rayon 解决什么问题)。

怎么改

  • 让 rayon 来等:把「A 等 B 的结果」改成 join(A, B) 或在 scope 里 spawn B、scope 结束后再用结果,等待交给 latch。
  • 必须轮询时用 yield_now:while !ready() { if rayon::yield_now() != Some(Yield::Executed) { std::thread::yield_now() } }(E12 par-yield 10/10 正常)。代价和 join 的等待一样:被执行的任务跑在当前栈帧上(持着 Mutex 调 par_iter,偶尔卡死)。
  • 生产者放到池外:IO、channel 的生产端用普通线程或 tokio,rayon 任务只消费已经到手的数据(E12 par-thread 10/10 正常)。
  • IO 别进 rayon 池:要等网络、磁盘的任务,用 tokio 的 spawn_blocking 或专门的线程,计算部分再交给 rayon。
  • 加大线程数只是降低概率(12 线程仍有 5/10),不是修法。

小结

  • rayon 的池大小固定,worker 阻塞在 Condvar / recv 上就不再执行任何任务;阻塞的任务数达到线程数、而被等的任务还在队列里时,整个池卡死。
  • E12:确定性版本 1、2、4、12 线程都 3/3 卡死,少一个等待者就正常;par_iter 写法 2 / 4 线程 10/10、12 线程 5/10 卡死。
  • 改法:让 join / scope 等,或者轮询 yield_now,或者把生产者放到池外;别把 IO 放进 rayon 池。

💬 关联讨论

发起讨论
加载中...