Rust in Everything
基于 parking_lot 0.12.5

公平解锁、bump 与 FairMutex

主线位置:主线 1 的解锁分支。unlock_slow(force_fair) 的回调里,force_fair || be_fair 为真就走 TOKEN_HANDOFF:锁不放开,直接交给被叫醒的线程。这一页讲谁会把 force_fair 设成 true,以及不设时 0.12.5 靠什么兜底。

flowchart TB
  u["MutexGuard drop"] --> raw["RawMutex::unlock"]
  uf["MutexGuard::unlock_fair / unlocked_fair"] --> rawf["RawMutex::unlock_fair"]
  fm["FairMutex 的守卫 drop"] --> rfm["RawFairMutex::unlock"] --> rawf
  b["MutexGuard::bump"] --> bump["RawMutex::bump:有 PARKED_BIT 才进 bump_slow"]
  raw -->|"快路径失败"| s0["unlock_slow(false)"]
  rawf -->|"快路径失败"| s1["unlock_slow(true)"]
  bump --> s1
  s0 --> cb{"回调:叫醒了线程,且 force_fair 或 be_fair?"}
  s1 --> cb
  cb -->|"是"| hand["TOKEN_HANDOFF:锁直接交给对方"]
  cb -->|"否"| norm["TOKEN_NORMAL:放开,大家重抢"]

unlock_fair 只差一个参数

raw_mutex.rs:119-139:

unsafe impl lock_api::RawMutexFair for RawMutex {
    #[inline]
    unsafe fn unlock_fair(&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(true);
    }

    #[inline]
    unsafe fn bump(&self) {
        if self.state.load(Ordering::Relaxed) & PARKED_BIT != 0 {
            self.bump_slow();
        }
    }
}

快路径和 unlock 一字不差:没人停车时公平与否没有区别,一次 CAS 放开。有人停车时 unlock_slow(true),回调里只要叫醒了线程就走交接分支(RawMutex 的一字节状态机那张三行表的第一行)。被叫醒的线程拿到 TOKEN_HANDOFF 直接从 lock_slow 返回,中间没有任何窗口让第三个线程插队,解锁方自己也插不了:它下一次 lock 看到的是 11,只能排到队尾。

公平只保证交给已经在停车的线程。正在自旋、还没置 PARKED_BIT 的线程不在队列里,不受保护。

bump:有人等才让

bump 只在 PARKED_BIT 置着时才进 bump_slow:

#[cold]
fn bump_slow(&self) {
    unsafe { deadlock::release_resource(self as *const _ as usize) };
    self.unlock_slow(true);
    self.lock();
}

unlock_slow(true) 把锁交给队头那个线程,自己再 lock(),排到它后面。没人等时 bump 只是一次 Relaxed 读。lock_api 里 trait 的默认实现是 unlock_fair(); lock();(lock_api/src/mutex.rs:106-109),注释说这两者「功能等价」,parking_lot 的版本只是省掉了没人等时的那次解锁加锁。

用途是长时间持锁的循环:每处理一批就 MutexGuard::bump(&mut guard),让等着的线程有机会进来,然后接着干。

守卫上的三个方法

lock_api/src/mutex.rs:628-684,只对 R: RawMutexFair 提供:

方法做什么
MutexGuard::unlock_fair(guard)raw.unlock_fair(),然后 mem::forget(guard),不再走 Drop
MutexGuard::unlocked_fair(&mut guard, f)公平地放开,执行 f,defer! 里重新 lock()(f panic 时也会重新加锁)
MutexGuard::bump(&mut guard)raw.bump()

这三个都是关联函数而不是方法(写成 MutexGuard::bump(&mut g)),避免和 T 自己的方法重名(守卫 Deref 到 T),lock_api 守卫上的 API 大多这样写,可升级读守卫的 with_upgraded 是少数例外。MappedMutexGuard 只有 unlock_fair,没有 unlocked_fair 和 bump:映射后的守卫拿不回原来的 Mutex,不能重新加锁(外壳与守卫)。

FairMutex:只换了 unlock

raw_fair_mutex.rs 全文 65 行:

pub struct RawFairMutex(RawMutex);

unsafe impl lock_api::RawMutex for RawFairMutex {
    const INIT: Self = RawFairMutex(<RawMutex as lock_api::RawMutex>::INIT);
    type GuardMarker = <RawMutex as lock_api::RawMutex>::GuardMarker;
    fn lock(&self) { self.0.lock() }
    fn try_lock(&self) -> bool { self.0.try_lock() }
    unsafe fn unlock(&self) { self.unlock_fair() }
    fn is_locked(&self) -> bool { self.0.is_locked() }
}

加锁路径完全相同,包括插队:一个线程在锁空出来(10)的瞬间来 lock,照样能抢在被叫醒者前面。但 FairMutex 的每次解锁都是公平解锁,有人停车时锁根本不会进入 10,而是直接交接;所以插队只可能发生在「没人停车」的时候。FairMutex<T> 是 lock_api::Mutex<RawFairMutex, T>(fair_mutex.rs:77),大小同样是 1 字节(E1)。

它的守卫类型是 lock_api::MutexGuard<'_, RawFairMutex, T>,与 parking_lot::MutexGuard(RawMutex)不是同一个类型,所以不能交给 Condvar::wait(实测是 E0308 类型不匹配,见 Condvar)。

probe-rs 的 GDB 服务端把 Session 包在 FairMutex 里(probe-rs 卷 gdb-server),那里每个 gdbstub 回调都要先拿这把锁。

不设 force_fair 时:最终公平

普通 unlock 传 false,回调里还剩一个 result.be_fair。它来自 unpark_one 所在桶的 FairTimeout:距上一次「该公平了」超过一个 0–1 ms 的随机间隔就为真,于是这一次解锁变成交接(eventual fairness)。Mutex 的文档把效果写成「平均每 0.5 ms 强制一次公平解锁」,「任何长于 1 ms 的临界区总是公平解锁」(mutex.rs:27-33)。两点要对照代码读:

  • 计时器在桶上,不在锁上:哈希到同一个桶的不同锁共享这一个计时器。
  • 只在 unpark_one 真的找到停车线程时才看计时器;没人停车,就没有「公平」这回事。

实验 E3:一个线程能连续拿多少次

断言:0.12.5 的最终公平把单个线程连续独占的时间限制在约 1 ms;0.13.0(已删掉这个机制)和本机 std(macOS 上是 pthread mutex)没有这个上限;unlock_fair / FairMutex 应当严格轮转。

方法(exp-parking_lot/eventual-fairness,对照列 exp-parking_lot/v013-eventual-fairness,两者同一份源码,都不开 deadlock_detection):T 个线程循环「加锁 → 把自己的编号记进锁里的 Vec → 忙等 hold → 解锁」,锁外不做事,立即重新加锁;总次数到了就停。统计「这次的持有者和上一次相同」的比例(自取比例)、最长连续次数(max run)和各线程份额。M2 Max(8P + 4E),macOS 不能绑核;每组跑 3 次。

let mut g = pl.lock();
g.owners.push(id as u8);
busy(hold_ns);
if imp == "plfair" {
    parking_lot::MutexGuard::unlock_fair(g);
} else {
    drop(g);
}

结果(22:45–22:48,开始时 1 / 5 分钟负载 2.05 / 2.93,结束时 3.54 / 3.77。)

① T=4、持锁 1 µs,20 万次(3 轮):

吞吐(千次 / 秒)自取比例最长连续份额
0.12.5 Mutex600–61048%–60%89–148各 25%
0.12.5 unlock_fair362–3720%9–10各 25%
0.12.5 FairMutex360–3690%9–16各 25%
std(pthread)498–50671%–72%53–22324%–27%
0.13.0 Mutex614–62642%–52%56–98各 25%
0.13.0 unlock_fair / FairMutex354–3690%8–14各 25%

4 个线程、1 µs 的临界区时,0.12.5 与 0.13.0 看不出区别。公平解锁把吞吐压到约 6 成(约 60 万 → 36 万次 / 秒),自取比例严格为 0。「最长连续」不是 1,是因为公平只保证交给已经在停车的线程:别的线程都还在自旋、没停车时,解锁方照样能自己再拿到。

② T=2、锁外不做事,持锁时间从 10 µs 扫到 10 ms,看最长连续次数(3 轮):

持锁总次数0.12.5 Mutexstd(pthread)0.13.0 Mutex
10 µs100 00095 / 95 / 3668 388 / 21 950 / 37 10817 048 / 17 753 / 19 805
100 µs100 00042 / 17 / 1013 974 / 11 217 / 10 36253 351 / 99 563 / 83 217
1 ms2 0001 / 1 / 12 000 / 1 545 / 1 7782 000 / 2 000 / 2 000
10 ms3001 / 1 / 1299 / 300 / 268300 / 300 / 300
  • 0.12.5 的兜底:10 µs 时最长连续 95 次,95 × 10 µs ≈ 1 ms,正好是 FairTimeout 的 0–1 ms 上限;第三轮出现一次 366(约 3.7 ms),推测是那段时间另一个线程没有在停车(在自旋或没被调度),计时器只在 unpark_one 叫醒了线程时才起作用。100 µs 时最长 10–42 次(1–4 ms)。
  • 持锁 1 ms、10 ms 时 0.12.5 严格交替(最长连续 1,份额 50 / 50):每次解锁时距上次公平解锁都已超过 1 ms,be_fair 恒为真,每次都交接。这验证了文档那句「长于 1 ms 的临界区总是公平解锁」。
  • 0.13.0 和本机 std 没有上限:持锁 1 ms、10 ms 时 0.13.0 三轮都是一个线程拿走了全部次数(份额 100 / 0);std 也多次出现一个线程拿走全部或接近全部。100 µs 时 0.13.0 有一轮 99 563 / 100 000 次被一个线程连续拿走。
  • 反向验证:unlock_fair / FairMutex 自取比例恒为 0(①);0.13.0 是天然的「关掉最终公平」对照组。

小结

  • unlock_fair 与 unlock 的快路径相同,差别只在慢路径传给 unlock_slow 的 true:叫醒了线程就交接,锁不经过「放开」状态。
  • bump 只在有人停车时才「公平地放开再加锁」,没人等时是一次 Relaxed 读。
  • FairMutex = RawFairMutex(RawMutex),只把 unlock 换成 unlock_fair;守卫类型不同,不能配 Condvar。
  • 普通 unlock 由桶上的 FairTimeout 兜底,每 0–1 ms 强制一次交接,且只在有停车线程时生效。
  • E3(本机):0.12.5 在 2 线程长临界区下把连续独占限制在约 1 ms,临界区 ≥ 1 ms 时严格交替;0.13.0 与本机 std 会让一个线程独占全部次数;公平解锁把 4 线程短临界区的吞吐压到约 6 成。

💬 关联讨论

发起讨论
加载中...