SpinWait
主线位置:主线 1 里 B 停车之前的那一段。
lock_slow发现锁被占、且没有人在排队时,spinwait.spin()一步、重读状态再试;spin()返回false(走完 10 步)或发现有人排队,才去置PARKED_BIT停车。
flowchart LR s1["第 1–3 次:自旋 2、4、8 次 spin_loop"] --> s4["第 4–10 次:thread_yield"] s4 --> s11["第 11 次起:返回 false"] s11 --> park["调用方:置 PARKED_BIT,park"]
README 第 3、4 条:「短临界区的微竞争靠自旋几次处理」「自旋几次失败后挂起线程,所以长短临界区都适用」。实现这两条的就是这个 74 行的文件。
代码
// Wastes some CPU time for the given number of iterations,
// using a hint to indicate to the CPU that we are spinning.
#[inline]
fn cpu_relax(iterations: u32) {
for _ in 0..iterations {
spin_loop()
}
}
/// A counter used to perform exponential backoff in spin loops.
#[derive(Default)]
pub struct SpinWait {
counter: u32,
}
impl SpinWait {
// ...
#[inline]
pub fn spin(&mut self) -> bool {
if self.counter >= 10 {
return false;
}
self.counter += 1;
if self.counter <= 3 {
cpu_relax(1 << self.counter);
} else {
thread_parker::thread_yield();
}
true
}
/// Spins without yielding the thread to the OS.
///
/// Instead, the backoff is simply capped at a maximum value. This can be
/// used to improve throughput in `compare_exchange` loops that have high
/// contention.
#[inline]
pub fn spin_no_yield(&mut self) {
self.counter += 1;
if self.counter > 10 {
self.counter = 10;
}
cpu_relax(1 << self.counter);
}
}
逐次调用展开:
| 第几次调用 | 调用前 counter | spin() 做什么 | 返回 |
|---|---|---|---|
| 1 | 0 | 自旋 1 << 1 = 2 次 | true |
| 2 | 1 | 自旋 4 次 | true |
| 3 | 2 | 自旋 8 次 | true |
| 4–10 | 3–9 | thread_yield() | true |
| 11 起 | 10 | 什么都不做 | false |
counter 先加一再移位,所以第一步是 2 次而不是 1 次。0.13 改成了 1、2、4(#540「core: fix SpinWait spin counts starting at 2 instead of 1」,0.13.0 的写法是 cpu_relax(1 << (self.counter - 1)))。自旋阶段总共 14 次 spin_loop,之后 7 次让出。thread_yield 在 unix 上是 std::thread::yield_now()(sched_yield),在 Windows 上是 Sleep(0)(ThreadParker 的各平台实现)。每次 spin_loop() 在 aarch64 上是一条 isb 指令(crossbeam 卷 Backoff 引过 core::hint::spin_loop 的实现)。
spin_no_yield 是另一条曲线:从不让出,每次自旋 1 << counter 次,counter 封顶 10,即 2、4、8、……、1024 次,之后一直 1024 次。它不返回 bool,没有「该停车了」的信号,调用方自己决定何时停。
谁在用,什么时候不转
| 调用方 | 用法 | 条件 |
|---|---|---|
RawMutex::lock_slow(raw_mutex.rs:228-232) | spin() | 只在没有 PARKED_BIT 时 |
WordLock::lock_slow(word_lock.rs:129-133) | spin() | 只在队列为空时 |
RawRwLock::lock_common(raw_rwlock.rs:1086-1090) | spin() | 只在 PARKED_BIT 和 WRITER_PARKED_BIT 都没有时 |
RawRwLock::wait_for_readers(raw_rwlock.rs:982-990) | spin() | 无条件:写者已经拿到 WRITER_BIT,只等剩下的读者退出 |
Once::call_once_slow(once.rs:235) | spin() | 只在没有 PARKED_BIT 时 |
RawRwLock 的读锁、可升级读锁(raw_rwlock.rs:717、771) | spin_no_yield() | 读者计数的 CAS 失败时,每次 try_lock 闭包里新建一个 SpinWait |
前四个「有人排队就不转」的条件是同一个判断:前面已经有线程在睡,锁放出来时多半会交给它们(普通解锁时它们被叫醒去抢,公平解锁时直接交接),后来者自旋只是浪费 CPU,不如直接排队。所以自旋只发生在「锁刚被拿走、还没人等」的那一小段窗口里,正是 README 说的「微竞争」。
spin_no_yield 的场景不同:很多读者同时对同一个 AtomicUsize 做 +ONE_READER 的 CAS,失败不是因为锁被占,而是因为别的读者刚改过计数。这时让出 CPU 没有意义(对方马上就改完了),但立刻重试又会继续撞,所以只做指数退避的自旋。注释:「This can be used to improve throughput in compare_exchange loops that have high contention.」crossbeam 卷的 ShardedLock 一章 实测「只读时 parking_lot RwLock 的吞吐远低于全写」,读者计数上的 CAS 冲突和这里的退避是候选解释,在 和 std RwLock 比 里讨论。
和 crossbeam Backoff 对照
parking_lot_core SpinWait | crossbeam-utils Backoff | |
|---|---|---|
| 自旋阶段 | spin() 第 1–3 次:2、4、8 次 | snooze() 第 1–7 次:1、2、4、…、64 次 |
| 让出阶段 | 第 4–10 次,7 次 | 第 8–11 次,4 次 |
| 「该阻塞了」 | spin() 返回 false(第 11 次调用) | is_completed()(第 11 次 snooze 之后) |
| 只自旋不让出 | spin_no_yield():2、4、…、1024,封顶 1024 | spin():1、2、…、64,封顶 64 |
| 自旋总次数(到该阻塞为止) | 14 次 spin_loop + 7 次让出 | 127 次 spin_loop + 4 次让出 |
两者的结构一样:先自旋、再让出、再交给调用方去阻塞。parking_lot 的自旋阶段短得多、让出阶段长;Backoff 反过来。下面的实验和 Backoff 一章的 E9 放在一起看。
实验 E11:每一步多长
断言:spin() 前 3 步只有几十纳秒,第 4–10 步各约一次 yield_now;机器忙时后 7 步变长、前 3 步不变。
方法(exp-parking_lot/spinwait-curve):对第 k 次调用,新建一个 SpinWait、先不计时地调 k−1 次,再用 Instant 量第 k 次,重复 10 000 次取中位数和 p90。本机 Instant 的分辨率是 41 ns,前 3 步接近一个刻度,所以另外批量测:20 万个新 SpinWait 各调前 k 次的平均耗时,相邻两项相减得到第 k 步。同时量单次 spin_loop(100 万次平均)和单次 yield_now(1 万次中位数)。单线程,不开 deadlock_detection。
结果:2026-11-01 22:59,开始时负载 1 分钟 1.88、5 分钟 2.94,rustc 1.99.1,--release,M2 Max(8 性能核 + 4 能效核,macOS 不能绑核)。单次 spin_loop 8.9–12.0 ns,单次 yield_now 中位数 125–167 ns。逐步中位数(ns,三轮):
| 第几次调用 | 做什么 | 单次计时中位数 | 批量差分(三轮) |
|---|---|---|---|
| 1 | 自旋 2 次 | 42、42、42 | 23.0、22.9、22.6 |
| 2 | 自旋 4 次 | 83、42、42 | 40.6、41.1、39.6 |
| 3 | 自旋 8 次 | 125、83、84 | 72.9、78.1、75.8 |
| 4–10 | yield_now | 每步 125–167 | — |
| 11 | 返回 false | 0(低于一个刻度) | — |
11 次调用的中位数加起来:1416、1083、1330 ns。
- 前 3 步的单次计时被 41 ns 的分辨率吞掉了,批量差分给出 23、40、75 ns 左右,正好是 2、4、8 次
spin_loop(每次约 9 ns)加几纳秒的调用开销。 - 第 4–10 步各是一次
yield_now,机器上有空闲核时它马上返回,约 125–167 ns,比第 3 步的自旋还贵一倍。 - 从新建到
spin()返回false,总共约 1.1–1.4 µs,这就是RawMutex在停车前愿意付的忙等预算。对照 Backoff 一章的 E9:crossbeam 从新建到is_completed()约 1.5 µs,两者量级相同,只是 parking_lot 把大部分时间花在让出上,crossbeam 花在自旋上。和 ThreadParker 一章 E12 的单向唤醒延迟(约 1.2–2.1 µs)也是同一量级:自旋预算大致等于一次睡眠唤醒的代价。
反向验证:再起 12 个忙转的线程占满全部 12 个核,同样的测量(每步 50 次,因为让出可能要等很久)。开始时负载 1 分钟 2.16、5 分钟 2.94(22:59):
| 第 1–3 步单次中位数(ns) | 第 4–10 步单次中位数(ns) | 单次 yield_now 中位数 | 11 次合计 | |
|---|---|---|---|---|
| 第一轮 | 42、83、84 | 917–958 | 916 ns | 6875 ns |
| 第二轮 | 42、83、84 | 375–958 | 416 ns | 3499 ns |
| 第三轮 | 42、83、84 | 375 | 375 ns | 2875 ns |
前 3 步和空载时完全一样(自旋不受别的线程影响;批量差分除第一轮第 1 步外也在空载值附近),后 7 步各变成 375–958 ns,是空载的 2–7 倍,11 次合计从约 1.3 µs 变成 2.9–6.9 µs。断言成立:测法能分辨「自旋」和「让出」两段。值得注意的是,即使 12 个核都被占满,一次 yield_now 也不到 1 µs 就回来了,并没有让出整个时间片。
小结
spin():10 步,前 3 步自旋 2、4、8 次,后 7 步thread_yield,第 11 次返回false;0.13 把前 3 步改成 1、2、4。spin_no_yield():只自旋,2 到 1024 次封顶,用在 RwLock 读者计数的 CAS 冲突上。RawMutex、WordLock、RawRwLock::lock_common、Once都只在「没人排队」时自旋;有人排队就直接停车。- 和 crossbeam
Backoff结构相同、曲线相反:parking_lot 自旋短、让出多。 - 实测(空载):前 3 步约 23、40、75 ns,后 7 步各约 125–167 ns,从新建到返回
false约 1.1–1.4 µs;12 个忙线程占满核时只有后 7 步变长(375–958 ns)。