Rust in Everything
基于 crossbeam-deque 0.8.8

把 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 5380043.6 – 44.0 ms
否错误99 896 – 99 992010376.6 – 380.4 ms
是正确10 037 – 11 9480 – 9 300042.8 – 49.3 ms
是错误37 – 22799 751 – 99 94711375.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 标志的循环。

💬 关联讨论

发起讨论
加载中...