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_iter | par_bridge | 先 collect 再 par_iter |
|---|---|---|---|---|---|
| 约 76 ns | 10⁶ | 76.4 ms | 7.9 ms | 174.0 ms | 9.3 ms |
| 约 0.94 µs | 10⁶ | 940.6 ms | 99.8 ms | 673.9 ms | 100.7 ms |
| 约 9.4 µs | 10⁵ | 940.6 ms | 95.7 ms | 121.0 ms | 104.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后排序。
💬 关联讨论
发起讨论加载中...