Steal、Retry 与批量窃取
主线位置:任务主线里「窃取者拿到结果之后」。
steal返回的不是Option<T>,而是三态的Steal<T>;调度循环怎么处理Retry,决定了窃取是否可靠。批量窃取把「偷一个」变成「偷一批搬进自己的队列」。
flowchart TB
attempt["steal() / steal_batch*()"] --> r{"结果"}
r -->|"Success(t)"| run["执行 t"]
r -->|"Empty"| next["队列确实空:去下一个来源,或者睡觉"]
r -->|"Retry"| again["有东西,但 CAS 输了或 buffer 刚换:重试"]
again --> attempt
Steal:三态
#[must_use]
#[derive(PartialEq, Eq, Copy, Clone)]
pub enum Steal<T> {
/// The queue was empty at the time of stealing.
Empty,
/// At least one task was successfully stolen.
Success(T),
/// The steal operation needs to be retried.
Retry,
}
Retry 的含义是「这一次没偷到,但不能断定队列是空的」。它从哪来:
| 来源 | 原因 |
|---|---|
Stealer::steal 和批量版本 | CAS front 失败(主人或别的窃取者先改了),或读槽时用的 buffer 已经被换掉 |
Stealer 从 LIFO 源批量窃取 | 第一个任务就没偷到 |
Injector 的三个窃取方法 | CAS head 失败。这里用的是 compare_exchange_weak,在 LL/SC 架构上还可能假失败 |
Empty 才是「我看的那一刻队列是空的」。#[must_use] 防止调用者直接忽略结果。
几个组合子:is_empty / is_success / is_retry / success()(转成 Option),以及 or_else(第 2209 行):
pub fn or_else<F>(self, f: F) -> Steal<T>
where
F: FnOnce() -> Steal<T>,
{
match self {
Steal::Empty => f(),
Steal::Success(_) => self,
Steal::Retry => {
if let Steal::Success(res) = f() {
Steal::Success(res)
} else {
Steal::Retry
}
}
}
}
注意 Retry 的「粘性」:前一个来源返回了 Retry,后一个来源返回 Empty,合起来仍是 Retry。只要有一处「可能还有」,整体就不能说「空」。
FromIterator:一串来源合成一个结果
impl<T> FromIterator<Steal<T>> for Steal<T> {
/// Consumes items until a `Success` is found and returns it.
///
/// If no `Success` was found, but there was at least one `Retry`, then returns `Retry`.
/// Otherwise, `Empty` is returned.
fn from_iter<I>(iter: I) -> Steal<T>
where
I: IntoIterator<Item = Steal<T>>,
{
let mut retry = false;
for s in iter {
match &s {
Steal::Empty => {}
Steal::Success(_) => return s,
Steal::Retry => retry = true,
}
}
if retry {
Steal::Retry
} else {
Steal::Empty
}
}
}
碰到第一个 Success 就返回,迭代器是惰性的,后面的窃取者根本不会被调用。crate 文档里的 find_task 示例就是靠这个写成的(lib.rs 第 52–76 行):
fn find_task<T>(
local: &Worker<T>,
global: &Injector<T>,
stealers: &[Stealer<T>],
) -> Option<T> {
// Pop a task from the local queue, if not empty.
local.pop().or_else(|| {
// Otherwise, we need to look for a task elsewhere.
iter::repeat_with(|| {
// Try stealing a batch of tasks from the global queue.
global.steal_batch_and_pop(local)
// Or try stealing a task from one of the other threads.
.or_else(|| stealers.iter().map(|s| s.steal()).collect())
})
// Loop while no task was stolen and any steal operation needs to be retried.
.find(|s| !s.is_retry())
// Extract the stolen task, if there is one.
.and_then(|s| s.success())
})
}
本地 pop → 全局批量窃取 → 逐个窃取其他线程,整体结果是 Retry 就从头再来一遍,直到得到 Success 或确定的 Empty。
rayon 怎么写
rayon-core 1.13.0 没有用这个 collect 惯用法,而是手写了等价的循环(registry.rs 第 873–904 行):
loop {
let mut retry = false;
let start = self.rng.next_usize(num_threads);
let job = (start..num_threads)
.chain(0..start)
.filter(move |&i| i != self.index)
.find_map(|victim_index| {
let victim = &thread_infos[victim_index];
match victim.stealer.steal() {
Steal::Success(job) => Some(job),
Steal::Empty => None,
Steal::Retry => {
retry = true;
None
}
}
});
if job.is_some() || !retry {
return job;
}
}
语义和 FromIterator 一样(第一个成功就停、有 Retry 就整轮重来),多了一个随机起点,避免所有空闲线程都先去偷同一个受害者。全局注入队列和本线程的广播队列也是「Retry 就继续循环」(第 450–458 行、第 749–763 行)。rayon 只用单个 steal(),没有用批量窃取。rayon 卷会展开这个调度循环。
实验 E4:窃取不丢不重,以及 Retry 不能当 Empty
断言:(1)并发 push / pop / steal 下每个任务恰好被取一次;(2)把 Retry 当成 Empty 退出的窃取者,会让工作集中到少数线程上。
规划阶段已经跑过第一条(exp-crossbeam/survey/src/bin/deque_steal.rs):主人 push 200 万个(每 push 3 个 pop 1 个),11 个窃取者循环 steal,FIFO 和 LIFO 都取到 2000000 个、总和正确;Retry 约 570 万次,比成功次数还多。
方法(exp-crossbeam/deque-retry/):主人先 push 好 n 个任务,11 个窃取者用 Barrier 同时出发,循环 steal:
- 正确写法:
Success继续,Retry继续,Empty退出。 - 错误写法:
Retry和Empty都退出。
另一组让主人在窃取者出发的同时也从自己那头 pop。每个任务可以附带一段空转(2000 次整数乘加,用 black_box 防优化),模拟真实工作。统计每个线程拿到多少、总共多少、Retry 多少次、耗时。release,每组 3 次。耗时那一批测量开始时 1 分钟负载 2.89、结束时 4.34(包括本实验自己的 12 个线程)。M2 Max(8P + 4E)。
结果(3 次的范围):
| 每个任务的工作 | 主人也 pop | 写法 | 任务数 | 某个窃取者最多拿到 | 主人 pop 到 | 因 Retry 退出的线程 | 耗时 |
|---|---|---|---|---|---|---|---|
| 空转 2000 次 | 否 | 正确 | 100 000 | 10 279 – 10 538 | 0 | 0 | 43.6 – 44.0 ms |
| 空转 2000 次 | 否 | 错误 | 100 000 | 99 896 – 99 992 | 0 | 10 | 376.6 – 380.4 ms |
| 空转 2000 次 | 是 | 正确 | 100 000 | 10 037 – 11 948 | 0 – 9 300 | 0 | 42.8 – 49.3 ms |
| 空转 2000 次 | 是 | 错误 | 100 000 | 37 – 227 | 99 751 – 99 947 | 11 | 375.9 – 384.8 ms |
| 无 | 否 | 正确 | 1 000 000 | 93 819 – 96 265 | 0 | 0 | 197.0 – 202.3 ms |
| 无 | 否 | 错误 | 1 000 000 | 1 000 000 | 0 | 10 | 9.6 – 9.8 ms |
所有组合里任务都一个不少(「剩余」全为 0),也没有重复。差别在谁干的活:
- 错误写法下,11 个窃取者里有 10 个在第一次撞车时就退出了,剩下一个独自干完全部(每任务有工作量时耗时 8.6 倍)。主人也 pop 时,11 个窃取者全部很快退出,99.8% 的任务由主人一个人做完。
- 任务没有工作量时,错误写法反而快 20 倍:单线程独占时没有任何 CAS 竞争,而 12 个线程争一个
front每次成功前平均要撞约 1.5 次(100 万次成功对应约 150 万次Retry)。这说明的是「对微小任务逐个窃取不划算」,不是错误写法更好。
在调度器里,「窃取者以为没活干」的后果是它去睡觉;这里的实验没有睡眠机制,所以表现为「过早退出」。不管哪种,并行度都没了,而程序仍然算出正确结果,只是慢,这是这类 bug 难被发现的原因。常见坑有一条专门讲它。
反向验证:同一组任务、同样的线程数,正确写法下每个窃取者拿到约 1 万个(10 万 ÷ 11 ≈ 9 091,加上竞争带来的不均),退出前一律看到的是 Empty;错误写法的「因 Retry 退出」计数是 10 或 11,说明退出确实是 Retry 造成的,而不是队列真的空了。
批量窃取
单个 steal 每偷一个任务都要 pin、fence、CAS 一次。批量窃取一次 CAS 认领一批,搬进调用者自己的 Worker:
| 方法 | 偷多少 | 返回 |
|---|---|---|
steal_batch(dest) | 约一半,不超过 32 | Steal<()> |
steal_batch_with_limit(dest, limit) | 约一半,不超过 limit | Steal<()> |
steal_batch_and_pop(dest) | 约一半,不超过 32;其中一个直接返回,其余进 dest | Steal<T> |
steal_batch_with_limit_and_pop(dest, limit) | 同上,不超过 limit | Steal<T> |
steal_batch_with_limit(第 761 行)的骨架:
- 偷自己(
Arc::ptr_eq(&self.inner, &dest.inner)):直接按dest是否为空返回Empty/Success(()),什么都不搬。这是 0.7.3 的 #448(「Stop stealing from the same deque」)。 - 读
front、fence(借 pin)、读back,和steal一样。 batch_size = min((len + 1) / 2, limit),即向上取整的一半;dest.reserve(batch_size),不够就扩容。- 按源队列的 flavor 分两种做法(下面)。
- Release fence + 写
dest.back,把搬进来的任务发布给dest的窃取者。
steal_batch_with_limit_and_pop 的 batch_size 是 min((len - 1) / 2, limit - 1),再加上直接返回的那一个,总数同样不超过 limit。
源是 FIFO:一次 CAS 认领一批
Flavor::Fifo => {
// Copy the batch from the source to the destination buffer.
match dest.flavor {
Flavor::Fifo => {
for i in 0..batch_size {
unsafe {
let task = buffer.deref().read(f.wrapping_add(i));
dest_buffer.write(dest_b.wrapping_add(i), task);
}
}
}
Flavor::Lifo => {
for i in 0..batch_size {
unsafe {
let task = buffer.deref().read(f.wrapping_add(i));
dest_buffer.write(dest_b.wrapping_add(batch_size - 1 - i), task);
}
}
}
}
// Try incrementing the front index to steal the batch.
// If the buffer has been swapped or the increment fails, we retry.
if self.inner.buffer.load(Ordering::Acquire, guard) != buffer
|| self
.inner
.front
.compare_exchange(
f,
f.wrapping_add(batch_size),
Ordering::SeqCst,
Ordering::Relaxed,
)
.is_err()
{
return Steal::Retry;
}
dest_b = dest_b.wrapping_add(batch_size);
}
先把整批拷进 dest 的 buffer(写在 dest.back 之后,dest 的窃取者还看不见),再一次 CAS 把 front 推过整批。CAS 失败就当没拷过:dest.back 没动,那些槽等于没写。FIFO 源的主人也从 front 取(fetch_add),所以一次 CAS 就能独占整段。
源是 LIFO:只能一个一个来
LIFO 源的主人从 back 取,而且 len > 0 时不碰 front(Chase-Lev 双端队列)。如果窃取者一次把 front 推过 k 个,主人可能同时已经从 back 那头把其中几个拿走了,CAS front 照样成功,同一个任务就被取了两次。所以 LIFO 源只能逐个偷:每偷一个之前都重新 fence、重新读 back、确认还够,再 CAS front + 1(第 850–923 行)。中途 CAS 失败就停下,已经偷到的算数;一个都没偷到才返回 Retry。
搬过去以后的顺序
批量窃取要保证:dest 的主人 pop 这批任务的顺序,和源队列的主人本来会 pop 它们的顺序一致。所以写进 dest 时要看两边的 flavor:FIFO 源搬进 LIFO 目标时倒着写;LIFO 源搬进 FIFO 目标时偷完再整体翻转一次。这是 2019 年 1 月 4d574d40(「Use correct order when stealing between FIFO and LIFO queues」)定下的。实测(deque-retry order,源队列 push 1 到 8,steal_batch 一次):
| 源 | 目标 | 源主人本来的 pop 顺序 | 目标主人 pop 到 |
|---|---|---|---|
| FIFO | FIFO | 1, 2, …, 8 | 1, 2, 3, 4 |
| FIFO | LIFO | 1, 2, …, 8 | 1, 2, 3, 4 |
| LIFO | FIFO | 8, 7, …, 1 | 4, 3, 2, 1 |
| LIFO | LIFO | 8, 7, …, 1 | 4, 3, 2, 1 |
窃取者总是从 front(最老的一头)偷,所以偷到的是 1 到 4;四种组合下目标队列 pop 的相对顺序都和源主人一致。
上限 32 是怎么来的
MAX_BATCH = 32(第 39 行)。这个上限从一开始就有:2018 年 11 月合并进仓库时是 128,2019 年 1 月 13 日 e97ac760(「Polish up」)改成 32;1 月 17 日 af8ce60f(「Tweak batch size」)让 Injector::steal_batch_and_pop 用 MAX_BATCH + 1。2022 年 8 月 34dd9d5d(「limit batch steal size」,#903,0.8.3)没有改这个数,而是加了 *_with_limit 系列方法让调用者自己定上限,原来的四个方法改成用 MAX_BATCH 调它们;Injector 那里的 MAX_BATCH + 1 也是这时加上了 TODO 注释(Injector)。
实验 E4(续):steal 与 steal_batch_and_pop
方法(deque-retry drain_batch):主人先 push 100 万个,11 个窃取者各带一个自己的 FIFO Worker,循环调 steal() 或 steal_batch_and_pop(&local),拿到后把 local 里的也 pop 干净,Empty 时退出。统计返回 Success 的调用次数、Retry 次数、耗时。每组 3 次,负载同上一批。
| 源 flavor | 方法 | 成功调用次数 | Retry 次数 | 耗时 |
|---|---|---|---|---|
| FIFO | steal | 1 000 000 | 141 万 – 145 万 | 191.9 – 194.7 ms |
| FIFO | steal_batch_and_pop | 31 255 | 5.4 万 – 5.8 万 | 7.0 – 7.2 ms |
| LIFO | steal | 1 000 000 | 138 万 – 146 万 | 189.4 – 196.1 ms |
| LIFO | steal_batch_and_pop | 27.7 万 – 29.7 万 | 177 万 – 189 万 | 172.6 – 184.2 ms |
- FIFO 源:100 万 ÷ 31 255 ≈ 32,每次正好拿满上限,三次运行完全一样;耗时降到 1/27。
- LIFO 源:批量窃取几乎没有收益,平均每次成功只拿到 3.4 – 3.6 个,
Retry反而更多。原因就是上一节:LIFO 源只能逐个 CAS,11 个线程在同一个front上一个一个地抢。
所有组合都取到 1000000 个、总和正确。换成规划阶段 survey 那种「主人边 push 边 pop、队列几乎总是快空」的形状(deque-retry batch),两种方法的成功调用次数差不多(约 142 万对 134 万),批量窃取派不上用场;批量的收益只在源队列攒了足够多任务时出现。
小结
Steal<T>有三态:Retry是「有东西但撞车了」,不能当Empty。or_else和FromIterator都让Retry有粘性:只要有一个来源说「可能还有」,整体就是Retry。FromIterator碰到第一个Success就停;crate 文档的find_task用它写成「本地 → 全局 → 逐个窃取,Retry就整轮重来」。rayon 手写了等价的循环,并且随机选起点。- 实验 E4:把
Retry当Empty,任务不丢不重,但 11 个窃取者里 10 个第一次撞车就退出,有工作量时耗时 8.6 倍;主人也在 pop 时 99.8% 的任务落到主人一个人头上。 - 批量窃取一次最多 32 个(
*_with_limit可调)。FIFO 源一次 CAS 认领整批,实测调用次数降到 1/32、耗时降到 1/27;LIFO 源只能逐个 CAS,几乎没有收益。 - 搬过去的一批,目标主人 pop 的顺序和源主人本来的顺序一致。