Rust in Everything
基于 rayon 1.12.0

par_bridge 打乱了顺序,还比顺序执行慢

主线位置:主线 2 的变体,源头是一个顺序 Iterator。实现和实验在 par_bridge。

flowchart TB
  it["Mutex<Fuse<Iter>>"] --> l0["叶子 0:lock、next、unlock、处理"]
  it --> l1["叶子 1:lock、next、unlock、处理"]
  it --> ln["……最多线程数 + 1 个叶子"]
  l0 --> out["下游按叶子左右顺序拼接"]
  l1 --> out
  ln --> out
  out --> mix["每个叶子拿到的元素在原序列里交错:乱序"]

现象

对一个普通迭代器(文件行、channel、外部库的迭代器)用 .par_bridge():

  • collect::<Vec<_>>() 出来的顺序和输入不同,每次运行都不一样;
  • 每个元素的处理很轻时,比直接顺序跑还慢。

复现

exp-rayon/par-bridge/(par_bridge):(0..100_000u64).par_bridge().collect::<Vec<_>>(),12 线程四次运行,有 99973–100000 个位置上的元素不等于下标;1 线程池里是 0 个(反向验证)。

每元素工作量和耗时(12 线程,5 次中位数,1 / 5 分钟负载 1.97 / 2.98):

每元素工作量n顺序par_iterpar_bridge先 collect 再 par_iter
约 76 ns10⁶76.4 ms7.9 ms174.0 ms9.3 ms
约 0.94 µs10⁶940.6 ms99.8 ms673.9 ms100.7 ms
约 9.4 µs10⁵940.6 ms95.7 ms121.0 ms104.7 ms

从哪来

IterBridge::drive_unindexed 建一个 IterParallelProducer,把迭代器放进一个 Mutex<Fuse<Iter>>,split 只是把计数从线程数减到 0,最多切出线程数 + 1 个叶子(12 线程实测 13 个)。每个叶子的 fold_with 循环「加锁 → next() → 解锁 → 处理」,一直取到迭代器耗尽。

  • 不保序:元素归谁取决于谁先抢到锁;下游 collect 按叶子的左右顺序拼接,而每个叶子拿到的元素在原序列里是交错的。par_bridge 返回的是 unindexed 迭代器,没有 enumerate。
  • 慢:12 个线程每取一个元素都抢同一把锁。1.6.1 起「一次只取一个元素」,等锁的线程阻塞而不是自旋。每元素 1 µs 时反推锁大约每 0.67 µs 交出一个元素;工作越轻、争用越激烈,单个元素的代价越高(推测是锁在线程间交接要经过内核阻塞与唤醒,没有用 profiler 拆分)。

怎么改

  • 元素能放进内存:先 collect::<Vec<_>>() 再 into_par_iter()。多一次顺序收集,换来 indexed 切分、零锁、保序;上表里每一档都和 par_iter 相当。
  • 要顺序:在原迭代器上先 .enumerate(),处理后按下标排序,或者按下标写回预分配的槽位。
  • 源头本来就是流(IO、channel,事先不知道有多少):每个元素的处理在几微秒以上才用 par_bridge;处理很轻时先攒批(chunks 成 Vec 后再 par_bridge 批),把锁的次数降下来。
  • 迭代器的 next() 里调 rayon 不会因为重入锁死(1.6.1 的 threads_started 标志),但嵌套执行仍然存在(par_bridge)。

小结

  • par_bridge 是一把锁后面的共享迭代器,每个叶子逐个抢元素:不保序(10⁵ 个元素几乎全部错位,1 线程时 0 个),每元素一次加锁。
  • 每元素约 76 ns 时比顺序慢一倍多,约 1 µs 时只快 1.4 倍,约 10 µs 才接近 par_iter。
  • 能先 collect 就先 collect;要顺序就 enumerate 后排序。

💬 关联讨论

发起讨论
加载中...