把 Steal::Retry 当成 Empty,窃取者提前收工
主线位置:一个任务的「窃取」那一步。
Retry从哪来、rayon 怎么处理,见 Steal、Retry 与批量窃取。
flowchart TB
st["stealer.steal()"] --> r{结果}
r -->|"Success(t)"| run["执行任务"]
r -->|Empty| stop["真的空了:可以停"]
r -->|Retry| rt["撞车或 buffer 被换:队列里很可能还有"]
rt -->|正确| again["重试或换受害者"]
rt -->|"当成 Empty"| early["提前收工:并行度丢失,结果仍正确"]
现象
窃取循环写成「拿到就干,拿不到就收工」:
loop {
match stealer.steal() {
Steal::Success(task) => run(task),
_ => break, // Retry 也当成没活了
}
}
或者 if let Steal::Success(t) = s.steal() { ... } else { break },或者 steal().success() 返回 None 就退出。程序算出的结果完全正确,测试也都过,只是多核跑起来不比单核快多少。
实测
实验 E4(exp-crossbeam/deque-retry/,release,每组 3 次):主人先 push 10 万个任务,每个任务空转 2000 次乘加;11 个窃取者用 Barrier 同时出发。正确写法只在 Empty 时退出,错误写法 Retry 也退出:
| 主人也 pop | 写法 | 某个窃取者最多拿到 | 主人 pop 到 | 因 Retry 退出的线程 | 耗时 |
|---|---|---|---|---|---|
| 否 | 正确 | 10 279 – 10 538 | 0 | 0 | 43.6 – 44.0 ms |
| 否 | 错误 | 99 896 – 99 992 | 0 | 10 | 376.6 – 380.4 ms |
| 是 | 正确 | 10 037 – 11 948 | 0 – 9 300 | 0 | 42.8 – 49.3 ms |
| 是 | 错误 | 37 – 227 | 99 751 – 99 947 | 11 | 375.9 – 384.8 ms |
所有组合都一个不少、一个不重。差别是谁干的活:错误写法下 11 个窃取者里 10 个第一次撞车就走了,剩下一个干完全部,耗时约 8.6 倍;主人也在 pop 时,窃取者全部退出,99.8% 的任务落到主人一个人身上。
任务没有工作量时,错误写法反而快 20 倍:单线程独占没有 CAS 竞争。这说明的是「对微小任务逐个窃取不划算」,不是错误写法更好(Steal、Retry 与批量窃取)。
从哪来
Steal<T> 有三个变体:Empty(窃取时队列是空的)、Success(T)、Retry(「The steal operation needs to be retried」)。Stealer::steal 在两种情况下返回 Retry:读完槽以后发现 buffer 已经被换掉,或者 CAS front 失败,即别的窃取者(或 FIFO 的主人)先拿走了这个位置。这两种情况下队列里很可能还有东西。
多个窃取者同时盯着一个队列时,Retry 很常见:E4 规划阶段的测量里,Retry 次数比成功次数还多(约 570 万次对 200 万)。把它当 Empty,就是在竞争最激烈、活最多的时候让线程收工。
Steal 标了 #[must_use],完全忽略返回值会有警告;但 _ => break 这种写法编译器帮不上忙。
怎么改
只在
Empty时停:loop { match stealer.steal() { Steal::Success(task) => run(task), Steal::Retry => continue, Steal::Empty => break, } }从多个来源窃取:用
Steal的FromIterator和or_else,Retry有「粘性」:只要有一个来源说Retry,整体就是Retry,外层find(|s| !s.is_retry())会整轮重来。crate 文档里的find_task就是这么写的(Steal、Retry 与批量窃取)。rayon-core 1.13.0 手写了等价的循环:遍历受害者时记下有没有遇到Retry,一轮下来什么都没偷到、且遇到过Retry,就再来一轮(registry.rs:873-904)。重试别无限紧转:
Retry说明有竞争,重试之间加一次Backoff::spin或换一个受害者,能减轻同一个front上的争抢。任务很小时:源队列是 FIFO 的话用
steal_batch_and_pop,一次 CAS 拿约一半(最多 32 个),E4 里耗时降到 1/27;LIFO 源只能逐个 CAS,批量几乎没有收益。
小结
Retry是「有东西但撞车了」,不是「空了」。- 当成
Empty:任务不丢不重,并行度没了;E4 里带工作量时慢 8.6 倍,结果照样正确,所以很难被测试发现。 - 只在
Empty时停;多来源用FromIterator+find(|s| !s.is_retry()),或者 rayon 那种带retry标志的循环。