Rust in Everything
基于 parking_lot_core 0.9.12

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 行的文件。

代码

spinwait.rs:11-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);
    }
}

逐次调用展开:

第几次调用调用前 counterspin() 做什么返回
10自旋 1 << 1 = 2 次true
21自旋 4 次true
32自旋 8 次true
4–103–9thread_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 SpinWaitcrossbeam-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,封顶 1024spin():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、4223.0、22.9、22.6
2自旋 4 次83、42、4240.6、41.1、39.6
3自旋 8 次125、83、8472.9、78.1、75.8
4–10yield_now每步 125–167—
11返回 false0(低于一个刻度)—

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、84917–958916 ns6875 ns
第二轮42、83、84375–958416 ns3499 ns
第三轮42、83、84375375 ns2875 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)。

💬 关联讨论

发起讨论
加载中...