Rust in Everything
基于 crossbeam-utils 0.8.23

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,每次等待在栈上新建一个。

两个常量,三个方法

backoff.rs 第 5 行:

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);
    }
}

snooze:

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。

逐次调用展开:

第几次调用调用前 stepspin()snooze()(std)调用后 is_completed()
10自旋 1 次自旋 1 次false
2122false
3–72–64、8、16、32、644、8、16、32、64false
8–107–964(step 停在 7)yield_now()false
111064yield_now()true
12 起11(只经 snooze)64yield_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-utilsstd mpmc::utils
「再试一下」spin():1 << min(step, 6),即 1, 2, 4, …, 64spin_light():min(step, 6)²,即 0, 1, 4, 9, 16, 25, 36
「等别人」snooze():step ≤ 6 自旋 1 << step,之后 yield_nowspin_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 对照 里用实验拆开。

💡TIP
打个比方:等电梯

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 少了阻塞前的自旋循环。

💬 关联讨论

发起讨论
加载中...