Backoff
主线位置:一条消息。array channel 的
send/recv先用Backoff自旋、让出,is_completed()返回真之后才去注册Waker、park线程;一个任务里Injector等别人写完槽时也用它snooze。
flowchart LR s0["step 0..=6:自旋 1,2,4..64 次"] --> s7["step 7..=10:snooze 改为 yield_now;spin 仍自旋 64 次"] s7 --> done["step 11:is_completed() = true"] done --> park["调用方:注册 Waker、park"]
无锁代码里有两种「等」:一种是 CAS 撞上了别人,马上重试大概率能成;另一种是要等别的线程把一件事做完(写完一个槽、放开一把锁),重试多少次都没用,除非对方先走。Backoff 用两个方法区分这两种情况,并用第三个方法告诉调用方「等得够久了,该睡了」。整个类型是一个 Cell<u32>,不是 Sync,每次等待在栈上新建一个。
两个常量,三个方法
const SPIN_LIMIT: u32 = 6;
const YIELD_LIMIT: u32 = 10;
spin:
pub fn spin(&self) {
for _ in 0..1 << self.step.get().min(SPIN_LIMIT) {
hint::spin_loop();
}
if self.step.get() <= SPIN_LIMIT {
self.step.set(self.step.get() + 1);
}
}
pub fn snooze(&self) {
if self.step.get() <= SPIN_LIMIT {
for _ in 0..1 << self.step.get() {
hint::spin_loop();
}
} else {
#[cfg(not(feature = "std"))]
for _ in 0..1 << self.step.get() {
hint::spin_loop();
}
#[cfg(feature = "std")]
::std::thread::yield_now();
}
if self.step.get() <= YIELD_LIMIT {
self.step.set(self.step.get() + 1);
}
}
is_completed 就是 step > YIELD_LIMIT。
逐次调用展开:
| 第几次调用 | 调用前 step | spin() | snooze()(std) | 调用后 is_completed() |
|---|---|---|---|---|
| 1 | 0 | 自旋 1 次 | 自旋 1 次 | false |
| 2 | 1 | 2 | 2 | false |
| 3–7 | 2–6 | 4、8、16、32、64 | 4、8、16、32、64 | false |
| 8–10 | 7–9 | 64(step 停在 7) | yield_now() | false |
| 11 | 10 | 64 | yield_now() | true |
| 12 起 | 11(只经 snooze) | 64 | yield_now() | true |
几个容易忽略的点:
- 只调
spin()永远不会 completed。spin把 step 封在SPIN_LIMIT + 1 = 7,而is_completed要 step 到 11。spin的语义是「对方已经有进展,再试一下」,它不打算让你去睡。 no_std下snooze不会让出:没有线程可以让,step 7–10 退化成自旋1 << step次,也就是 128 到 1024 次(step 停在 11 后是 2048 次)。reset()把 step 清零,可以复用同一个Backoff。- 每次
spin_loop()在 aarch64 上是一条isb指令(core::hint::spin_loop),x86 上是pause。
谁在用,怎么用
除 backoff.rs 自己和文档示例外,各 crate 的 src/ 里 Backoff::new() 出现 27 次,用法分三类:
| 用法 | 例子 | 意思 |
|---|---|---|
CAS 失败后 spin() | array channel start_send 的 compare_exchange_weak 失败(array.rs:206-209);deque Injector::push | 别人刚改过,换新值再试 |
等别人写完 snooze() | array channel 槽的 stamp 还没更新(array.rs:223-226);list channel 等下一块被装上;Context::wait_packet 等对方把包指针写进来;AtomicCell 的 seqlock 拿不到锁 | 对方没走完,自己重试没用 |
is_completed() 作阻塞边界 | array channel send / recv(array.rs:359-371);list channel recv;select 的 run_ready | 先忙等一会儿,completed 之后改用 park |
第三类是 channel 的「先自旋后阻塞」:
// crossbeam-channel/src/flavors/array.rs,Channel::send
loop {
// Try sending a message several times.
let backoff = Backoff::new();
loop {
if self.start_send(token) {
let res = unsafe { self.write(token, msg) };
return res.map_err(SendTimeoutError::Disconnected);
}
if backoff.is_completed() {
break;
} else {
backoff.snooze();
}
}
// ... Context::with:注册到 senders、再查一次、cx.wait_until(deadline)
}
通道满了的发送方,先 snooze 11 次(大约一两微秒,见下面的实验),这期间接收方取走一条就能直接发出去,不用付一次 park / unpark 的系统调用。11 次之后仍然满,才进入 Context 与 Waker 那一套。
反过来,AtomicCell 的 seqlock 拿锁时只 snooze、从不检查 is_completed(seq_lock.rs:47-63):锁持有时间只有一次拷贝那么长,到 step 7 之后就一直 yield_now,不会睡下。
和 std mpmc 的 Backoff 对照
std 的 mpmc 自带一份 Backoff,同样 SPIN_LIMIT = 6,但曲线和接口都改了:
| crossbeam-utils | std mpmc::utils | |
|---|---|---|
| 「再试一下」 | spin():1 << min(step, 6),即 1, 2, 4, …, 64 | spin_light():min(step, 6)²,即 0, 1, 4, 9, 16, 25, 36 |
| 「等别人」 | snooze():step ≤ 6 自旋 1 << step,之后 yield_now | spin_heavy():step ≤ 6 自旋 step²,之后 yield_now |
| 判断该阻塞 | is_completed(),step > 10 | 没有;没有 YIELD_LIMIT |
reset | 有 | 没有 |
| step 上限 | spin 停在 7,snooze 停在 11 | 不封顶,一直加 |
std 的 array channel 也因此没有 crossbeam 那个「is_completed 之前反复 snooze」的循环,send 里 start_send 失败一次就去注册阻塞。这是两边吞吐不同的候选原因之一,在 和 std::sync::mpmc 对照 里用实验拆开。
spin 像按了电梯发现刚好被人叫走,再按一下,每次多数几秒,最多数到 64;它默认电梯很快会回来,不会让你去走楼梯。snooze 像电梯在别的楼层装货,你先站着等,等到第 8 次就开始让后面的人先上(yield_now),让到第 11 次,is_completed 告诉你:别等了,去大堂沙发上坐着(park),电梯到了有人叫你。
比方到这里为止:真电梯会自己来,park 的线程不会。叫醒它的是 channel 里另一端的 notify,这个约定在 Context 与 Waker 里讲。
实验 E9:每一步多长
断言:snooze 前 7 次调用耗时按 2 倍增长,第 8 次起变成一次 yield_now,第 11 次调用后 is_completed() 为真;spin 的耗时在第 7 次后封顶。
方法(exp-crossbeam/backoff-curve):对第 k 次调用,新建一个 Backoff、先不计时地调 k−1 次,再用 Instant 量第 k 次,重复 2001 次取中位数。同样量 spin,以及照抄 std 1.99.1 的 spin_heavy / spin_light(反向对照)。单线程,机器上没有别的负载与之竞争 CPU。
fn curve<B>(make: impl Fn() -> B, step: impl Fn(&B), k: usize) -> u128 {
let samples = (0..TRIALS).map(|_| {
let b = make();
for _ in 0..k { step(&b); }
let t = Instant::now();
step(black_box(&b));
t.elapsed().as_nanos()
}).collect();
median(samples)
}
结果:2026-11-01,rustc 1.99.1,--release,M2 Max(8 性能核 + 4 能效核,macOS 不能绑核),开始时 1 分钟负载 2.67(另有别的实验在机器上断续运行)。纳秒,中位数:
spin_loop: 9.59 ns each
yield_now: 129 ns each
timer resolution: smallest non-zero elapsed 41 ns
call snooze_ns done_after spin_ns std_heavy_ns std_light_ns
1 42 false 42 0 0
2 42 false 42 42 42
3 42 false 42 42 83
4 84 false 84 84 84
5 167 false 167 167 167
6 292 false 292 250 250
7 583 false 583 334 334
8 166 false 583 166 334
9 125 false 583 166 333
10 166 false 583 125 333
11 166 true 583 166 334
12 166 true 583 166 333
13 125 true 583 125 333
14 125 true 583 125 333
fresh Backoff -> is_completed(): 11 snooze calls, 1542 ns (median)
after 1000 spin(): is_completed=false Backoff { step: Cell { value: 7 }, is_completed: false }
after 1000 snooze(): Backoff { step: Cell { value: 11 }, is_completed: true }
本机 Instant 的分辨率是 41 ns(Apple Silicon 的 24 MHz 计数器),前三步低于分辨率,读成一个刻度。从第 4 次起:
- 前 7 次按 2 倍增长,第 7 次 583 ns,对上 64 次 × 9.6 ns 的
isb。第 8 次起snooze变成一次yield_now,约 125–166 ns;第 11 次之后is_completed()为真。断言成立。 - 让出比最后一级自旋还便宜。 机器上没有别的可运行线程时,
yield_now(一次系统调用)马上返回,只要 130 ns 左右,而第 7 步的 64 次自旋要 583 ns。曲线在第 8 步「掉下来」。机器忙的时候yield_now会真的把 CPU 让出去,耗时取决于调度,这里没有测。 - 从新建到 completed 一共约 1.5 µs(127 次
spin_loop加 4 次yield_now)。这就是 array channel 在 park 之前愿意付的忙等预算。负载约 4 时的预跑结果几乎相同(1584 ns)。 spin()第 7 次起封顶在 583 ns,调 1000 次后 step 仍是 7,is_completed()仍为false。- 反向对照:std 的
spin_heavy前 7 次是 0、1、4、9、16、25、36 次自旋,第 6、7 次(250、334 ns)明显低于 crossbeam 的 292、583 ns,之后同样是yield_now;spin_light封顶在 36 次自旋,约 333 ns。
std 的曲线更「平」:自旋阶段总共 91 次 spin_loop,crossbeam 是 127 次;而 std 的 array channel 根本不在阻塞前循环调用它(见上一节)。两边的差异有多少会反映到吞吐上,留给 和 std::sync::mpmc 对照 的二分实验。
小结
Backoff是一个Cell<u32>:spin自旋1 << min(step, 6)次,step 封顶 7;snooze前 7 步同样自旋,之后yield_now,step 封顶 11;is_completed是step > 10。- 只调
spin永远不会 completed;no_std下snooze不让出,改成更长的自旋。 - crossbeam 里三种用法:CAS 失败
spin,等对方写完snooze,is_completed作为「自旋 → park」的边界(array / list channel 的send/recv、select)。 - std mpmc 的
spin_light/spin_heavy是平方曲线,没有is_completed,array channel 少了阻塞前的自旋循环。