公平解锁、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 只差一个参数
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 Mutex | 600–610 | 48%–60% | 89–148 | 各 25% |
0.12.5 unlock_fair | 362–372 | 0% | 9–10 | 各 25% |
0.12.5 FairMutex | 360–369 | 0% | 9–16 | 各 25% |
| std(pthread) | 498–506 | 71%–72% | 53–223 | 24%–27% |
0.13.0 Mutex | 614–626 | 42%–52% | 56–98 | 各 25% |
0.13.0 unlock_fair / FairMutex | 354–369 | 0% | 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 Mutex | std(pthread) | 0.13.0 Mutex |
|---|---|---|---|---|
| 10 µs | 100 000 | 95 / 95 / 366 | 8 388 / 21 950 / 37 108 | 17 048 / 17 753 / 19 805 |
| 100 µs | 100 000 | 42 / 17 / 10 | 13 974 / 11 217 / 10 362 | 53 351 / 99 563 / 83 217 |
| 1 ms | 2 000 | 1 / 1 / 1 | 2 000 / 1 545 / 1 778 | 2 000 / 2 000 / 2 000 |
| 10 ms | 300 | 1 / 1 / 1 | 299 / 300 / 268 | 300 / 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 成。