Rust in Everything
基于 parking_lot 0.13.0

延后唤醒与守卫状态

主线位置:主线 1 的解锁一侧。0.12.5 的一次有竞争的解锁是「unlock 的 CAS 失败 → unlock_slow → unpark_one → 在桶锁里的回调中改状态字 → 叫醒」,状态字在回调里才放掉锁位;0.13 把「放锁」挪到最前面。后半章讲 lock_api 0.5.0 的另一项改动:lock 返回一个代表本次加锁的值。

0.12.5:锁在叫醒的簿记期间一直被占着

unlock 的快路径(src/raw_mutex.rs 第 99–110 行)只在状态恰好是 LOCKED_BIT 时成功:

unsafe fn unlock(&self) {
    deadlock::release_resource(self as *const _ as usize);
    if self
        .state
        .compare_exchange(LOCKED_BIT, 0, Ordering::Release, Ordering::Relaxed)
        .is_ok()
    {
        return;
    }
    self.unlock_slow(false);
}

有 PARKED_BIT 时进 unlock_slow(第 291–323 行),状态字的改写放在 unpark_one 的回调里:公平时保持 LOCKED_BIT 交接(TOKEN_HANDOFF),否则写成 PARKED_BIT 或 0。回调在什么时候执行,看 unpark_one(core/src/parking_lot.rs 第 732–795 行):

  1. lock_bucket(key):取哈希表、算桶下标、锁桶的 WordLock、确认表没换(停车场哈希表)。
  2. 在桶的链表里找第一个 key 相同的线程摘下,再往后扫,看还有没有同 key 的(have_more_threads)。
  3. result.be_fair = fair_timeout.should_timeout():读一次时钟(eventual fairness)。
  4. 调用回调,状态字到这里才去掉 LOCKED_BIT。
  5. unpark_lock、放桶锁、unpark(系统调用)。

所以从 unlock 的 CAS 失败到第 4 步之间,mutex 一直是 LOCKED | PARKED。这期间新来的线程看到的是「锁被占、有人在排队」:lock_slow 不在有 PARKED_BIT 时自旋(RawMutex 的一字节状态机),于是直接去停车,而停车又要锁同一个桶。系统调用(第 5 步)倒是在放锁之后,不在这段窗口里。

0.13:先放锁,再叫人

0.13.0 的 unlock(src/raw_mutex.rs 第 84–88 行)转到 unlock_inner(第 153–165 行):

pub(crate) unsafe fn unlock_inner(&self) {
    let state = self.state.fetch_sub(LOCKED_BIT, Ordering::Release);
    if state & PARKED_BIT != 0 {
        self.unlock_slow();
    }
}

不管有没有人排队,第一步都是一次 fetch_sub(LOCKED_BIT, Release),锁立刻空出来;返回的旧值里有 PARKED_BIT 才去叫人。新的 unlock_slow(第 267–284 行)的回调只剩一件事:

let callback = |result: UnparkResult| {
    // Ownership has already been released. Preserve LOCKED_BIT since
    // another thread may have acquired the mutex in the meantime.
    if !result.have_more_threads {
        self.state.fetch_and(!PARKED_BIT, Ordering::Relaxed);
    }
    TOKEN_NORMAL
};

注释说明了为什么从 store 换成 fetch_and:回调执行时锁可能已经被别人拿走,不能覆盖 LOCKED_BIT。叫醒的线程总是拿到 TOKEN_NORMAL,回到 lock_slow 的循环重新抢;抢不到就再停车。状态表也相应改了「PARKED 且未 LOCKED」那一行的说明(第 37–39 行):「At least one of the parked threads is just about to be unparked, or a thread heading for parking might abort the park.」

公平解锁另走一条路:unlock_fair 的快路径仍是 compare_exchange(LOCKED_BIT, 0),失败后进 unlock_fair_slow(第 286–318 行),里面就是 0.12.5 unlock_slow 的 force_fair = true 分支:叫醒了线程就保持上锁、直接交接。

RwLock 做了同样的事(同一个提交 79f4ce9):unlock_exclusive 从 compare_exchange(WRITER_BIT, 0) 改成 fetch_sub(WRITER_BIT, Release)(src/raw_rwlock.rs 第 92–98 行),unlock_upgradable 改成 fetch_sub(ONE_READER | UPGRADABLE_BIT),二者共用 unlock_exclusive_or_upgradable_slow(第 546–559 行),回调注释「Preserve any reader count and ownership bits acquired while we were accessing the queue」。0.12.5 的对应代码见 读、写与写者优先。

sequenceDiagram
  participant A as 持锁线程 A
  participant S as 状态字
  participant B as 桶(WordLock + 队列)
  participant C as 新来的线程 C
  Note over A,C: 0.12.5
  A->>S: CAS(LOCKED→0) 失败(有 PARKED)
  A->>B: lock_bucket,摘下等待者,扫队列,读时钟
  C->>S: 看到 LOCKED|PARKED,不自旋
  C->>B: 去停车,等桶锁
  A->>S: 回调里 store(PARKED 或 0)
  A->>B: 放桶锁,unpark
  Note over A,C: 0.13
  A->>S: fetch_sub(LOCKED),锁立刻空出
  C->>S: CAS 抢到锁,直接进临界区
  A->>B: lock_bucket,摘下等待者
  A->>S: 回调里 fetch_and(!PARKED)(如果没人了)
  A->>B: 放桶锁,unpark

竞争下快多少(E2)

E2 的完整方法和尾延迟在 和 std Mutex 比:exp-parking_lot/mutex-throughput(0.12.5 与 std)和 exp-parking_lot/v013-mutex-throughput(0.13.0),同一份源码,都不开 deadlock_detection;T 个线程共享一把 Mutex<u64>,每次「加锁、加一、解锁」,锁外可选忙等;ns/op = 墙钟 / 总次数,每格 3 轮的中位数,M2 Max,开跑时 1 / 5 分钟负载 2.66 / 2.99。这里只摘吞吐:

场景0.12.5std(macOS,pthread)0.13.00.12.5 / 0.13.0
T=16.17.55.71.07
T=4,锁外 028.725.120.31.41
T=12,锁外 029.516.813.92.12
T=12,锁外 200 ns175.3322.4160.91.09

差距的形状和上面的机制对得上:

  • 锁外什么都不做时差距最大:T=12 时 0.13.0 的吞吐约是 0.12.5 的 2.1 倍,T=4 时约 1.4 倍。这时每个线程放锁后立刻又来抢,解锁几乎每次都带着 PARKED_BIT、都走慢路径。0.12.5 每次有竞争的解锁都把锁多占一段「锁桶 + 扫队列 + 读时钟」的时间,新来的线程在这段时间里只能去排队;0.13.0 放锁在前,这段簿记和下一个人的临界区重叠起来。0.12.5 在这种场景下甚至比本机 std 还慢(29.5 对 16.8),0.13.0 则比 std 快。
  • 锁外有 200 ns 工作时只快约 9%(175.3 对 160.9):锁的争用没那么密,有竞争的解锁占比下降,省下的那段窗口在总时间里的比重也跟着下降。这时 0.12.5 比 std 快约 1.8 倍,0.13.0 约 2.0 倍。
  • 所以延后唤醒的收益主要出现在高竞争、短临界区的场景;临界区外有实际工作时,升级带来的吞吐提升是个位数百分比。

尾延迟不跟吞吐走:T=12、锁外 0 时单次 lock() 的 p99,std 7.4 µs、0.12.5 22.7 µs、0.13.0 17.0 µs;std 三轮里各有一次单次加锁耗时 3.7–5.7 ms,两个 parking_lot 版本的最大值都在 0.3–0.6 ms(完整分位数表见 和 std Mutex 比)。这组实验比较的是两个完整版本,没有把 #545 那一处改动单独隔离出来;上面的解释是机制层面的,不是逐项消融的结果。

无竞争路径为什么也变快:先看反汇编

上表 T=1 那一行,0.13.0 也比 0.12.5 快(5.7 对 6.1 ns);规划阶段那组 0.12.5 开着 deadlock_detection 的数字里,这个差距一度是 2 ns(7.0 对 4.9)。单线程时竞争路径根本不走,差别只能来自快路径。exp-parking_lot/v013-asm/ 在同一个二进制里依赖两个版本(pl12 = { package = "parking_lot", version = "=0.12.5" }、pl13 = { package = "parking_lot", version = "=0.13.0" },都不开 deadlock_detection),各写一个 #[inline(never)] 函数 *m.lock() += 1,release 构建后用 objdump -d 看(本机 aarch64,Apple 目标默认带 LSE 原子指令):

_pl12_lock_inc_unlock:                     _pl13_lock_inc_unlock:
  stp   x20, x19, [sp, #-0x20]!              stp   x20, x19, [sp, #-0x20]!
  stp   x29, x30, [sp, #0x10]                stp   x29, x30, [sp, #0x10]
  add   x29, sp, #0x10                       add   x29, sp, #0x10
  mov   w8, #0x0                             mov   w8, #0x0
  mov   w20, #0x1                            mov   w9, #0x1
  casab w8, w20, [x0]        ; lock          casab w8, w9, [x0]          ; lock
  cmp   w8, #0x0                             cmp   w8, #0x0
  b.ne  -> lock_slow                         b.ne  -> lock_slow
  ldr   x8, [x0, #0x8]       ; += 1          ldr   x8, [x0, #0x8]        ; += 1
  add   x8, x8, #0x1                         add   x8, x8, #0x1
  str   x8, [x0, #0x8]                       str   x8, [x0, #0x8]
  caslb w20, wzr, [x0]       ; unlock        mov   w8, #-0x1
  cmp   w20, #0x1                            ldaddlb w8, w8, [x0]        ; unlock
  b.ne  -> unlock_slow                       tbnz  w8, #0x1, -> unlock_slow
  ldp   x29, x30, [sp, #0x10]                ldp   x29, x30, [sp, #0x10]
  ldp   x20, x19, [sp], #0x20                ldp   x20, x19, [sp], #0x20
  ret                                        ret

两版的快路径都是 17 条指令(从入口到 ret):加锁都是一条 casab(带 acquire 的字节 CAS);解锁 0.12.5 是 caslb(带 release 的字节 CAS)加比较,0.13.0 是 ldaddlb(带 release 的字节原子加,加 −1)加测试位。指令数和原子操作的个数都相同。

反向验证:给 0.12.5 打开 deadlock_detection(--features dd)重新构建,同一个函数里多出两次函数调用,加锁后 bl ...deadlock_impl16acquire_resource、解锁前 bl ...deadlock_impl16release_resource,每次都要取线程局部的 ThreadData 并读写一个 Vec(死锁检测)。0.13.0 的那个函数不受影响。

再用同一个程序做单线程计时(每个版本 5000 万次,函数不内联,含调用开销;每组 3 次取中位数;拿到共用的计时锁后开跑,开始时 1 分钟 / 5 分钟负载 2.54 / 2.93,结束时 2.65 / 2.94):

构建0.12.50.13.0std
都不开 deadlock_detection6.35 ns(6.35–6.48)5.98 ns(5.98–6.13)7.04 ns(7.00–7.07)
0.12.5 开 deadlock_detection7.83 ns(7.79–7.90)6.15 ns(5.97–6.15)7.07 ns(7.01–7.12)
都不开,三者倒序执行(排除先后顺序的影响)6.31 ns(6.30–6.31)5.99 ns(5.96–6.04)7.06 ns(7.04–7.08)

结论分两层:

  • 规划阶段那 2 ns 的差距,大部分来自 deadlock_detection:条件对等时两版只差约 0.3 ns(E2 的 T=1 是 6.1 对 5.7 ns,差 0.4 ns,同一量级);给 0.12.5 开上这个 feature,差距扩大到约 1.7 ns。这也是所有计时实验都要去掉该 feature 的原因。
  • 剩下的约 0.3 ns(5%)从指令数上解释不了。两版指令条数相同,区别只在解锁用的是 caslb + cmp 还是 ldaddlb + tbnz。推测:M2 上带 release 的原子加比带 release 的 CAS 略快,或者是两个函数的代码对齐不同;本卷没有做能区分这两种解释的实验。规划时「与解锁从 CAS 改成 fetch_sub 有关」的推测,在这一层上仍未证实,也未被证伪。

#544:每次加锁的守卫状态

lock_api 0.5.0 的 RawMutex(lock_api/src/mutex.rs 第 20–83 行):

pub unsafe trait RawMutex {
    const INIT: Self;

    /// Per-acquisition state stored in the lock guard.
    ///
    /// This type also determines whether guards are `Send` and `Sync`.
    /// Stateless implementations can use [`GuardSend`](crate::GuardSend) or
    /// [`GuardNoSend`](crate::GuardNoSend).
    type Guard;

    fn lock(&self) -> Self::Guard;
    fn try_lock(&self) -> Option<Self::Guard>;
    unsafe fn unlock(&self, guard: Self::Guard);
    fn is_locked(&self) -> bool;
}

和 0.4.14 比(raw trait 与契约):

0.4.140.5.0
type GuardMarker;,只用在 PhantomData 里type Guard;,真的存进守卫:MutexGuard 多一个字段 guard: ManuallyDrop<R::Guard>(第 578–583 行)
fn lock(&self)fn lock(&self) -> Self::Guard
fn try_lock(&self) -> boolfn try_lock(&self) -> Option<Self::Guard>
unsafe fn unlock(&self)unsafe fn unlock(&self, guard: Self::Guard),消费本次加锁的状态
unsafe fn bump(&self)unsafe fn bump(&self, guard: &mut Self::Guard),借用并更新
is_locked 有默认实现必须实现,文档注明「The result is a momentary snapshot」
RwLock 一个 GuardMarkerSharedGuard / ExclusiveGuard / UpgradableGuard 三个;upgrade(&self, UpgradableGuard) -> ExclusiveGuard 这类模式转换返回新模式的状态
GuardSend(()) / GuardNoSend(*mut ()) 不会被构造多了 const fn new() 和 Default,无状态的实现直接把它们当 Guard 返回;GuardNoSend 的字段改成 PhantomData<*mut ()>

提交说明:「This replaces raw lock guard markers with acquisition state, using separate shared, exclusive, and upgradable states for rwlocks. Unlocks and mode transitions consume this state, and it is borrowed for bump and condvar waits. Fixes #526.」(#526 的内容本卷离线无法查看。)parking_lot 自己的 raw 锁仍然是无状态的:type Guard = crate::GuardMarker;(src/raw_mutex.rs 第 56 行),send_guard 的选择方式和 0.12.5 一样。

trait 的 Safety 一节同时改写,新增的要求包括(原文):「Implementations must provide mutual exclusion, with acquire semantics on successful locking and release semantics on unlocking」;「A caller with exclusive ownership of the mutex object may move or drop it even while it is locked; this must also be safe」;「If a lock operation unwinds, it must not leave the caller holding a new acquisition」。

自定义锁的迁移要点

对着 给自己的锁套外壳 那个自旋锁,0.5.0 文档里的同一个例子(lock_api/src/lib.rs 第 25–67 行)改成了:

unsafe impl RawMutex for RawSpinlock {
    const INIT: RawSpinlock = RawSpinlock(AtomicBool::new(false));

    // A spinlock guard can be sent to another thread and unlocked there
    type Guard = GuardSend;

    fn lock(&self) -> Self::Guard {
        loop {
            if let Some(guard) = self.try_lock() {
                return guard;
            }
        }
    }

    fn try_lock(&self) -> Option<Self::Guard> {
        self.0
            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
            .ok().map(|_| GuardSend::new())
    }

    unsafe fn unlock(&self, _guard: Self::Guard) {
        self.0.store(false, Ordering::Release);
    }

    fn is_locked(&self) -> bool {
        self.0.load(Ordering::Relaxed)
    }
}

逐条:

  1. type GuardMarker = X 改成 type Guard = X;lock / try_lock 返回 X::new()。
  2. unlock 多一个参数,无状态的实现忽略它即可。
  3. 补上 is_locked。
  4. RwLock 要拆成三种状态,模式转换(upgrade、downgrade)的签名都变了。
  5. 用到 make_guard_unchecked、force_unlock、force_unlock_fair 的地方要传入状态(例如 force_unlock(&self, guard: R::Guard),第 315 行)。

0.4 和 0.5 是两个不兼容的大版本,trait 是两套。本机 registry 里最新的 spin 0.12.3 仍然依赖 lock_api = "0.4",所以写作时 spin 的 lock_api 类型与 parking_lot 0.13 不在同一套 trait 上:对 R: lock_api::RawMutex 写泛型代码、想同时接受两者,要等 spin 迁移或自己做适配。

小结

  • #545:0.12.5 有竞争的解锁要到 unpark_one 的回调里才放掉锁位,中间隔着锁桶、扫队列、读时钟,新来的线程只能去排队;0.13 先 fetch_sub 放锁再叫人,回调只在没人时清 PARKED_BIT,叫醒的线程重新抢锁。公平解锁另走 unlock_fair_slow,行为不变。RwLock 的写锁、可升级读锁同样改了。
  • E2:锁外不做事时 0.13.0 的吞吐是 0.12.5 的约 2.1 倍(T=12,13.9 对 29.5 ns/op)和约 1.4 倍(T=4);锁外有 200 ns 工作时只快约 9%(160.9 对 175.3)。收益集中在高竞争、短临界区。
  • 无竞争快路径两版都是 17 条指令、两次原子操作;规划阶段 T=1 的 2 ns 差距大部分来自 0.12.5 一侧开着的 deadlock_detection,条件对等时只差 0.3–0.4 ns,来源未查明(推测与 ldaddlb / caslb 或代码对齐有关)。
  • #544:lock 返回本次加锁的状态,unlock 消费它;RwLock 有三种状态。parking_lot 自己的锁仍无状态,用户代码不受影响;自定义锁要改 Guard、lock / try_lock / unlock 的签名并实现 is_locked。

💬 关联讨论

发起讨论
加载中...