一次有竞争的加锁
主线位置:主线 1 的全程。本页只画时序和关键行,细节在后面:RawMutex 的一字节状态机、SpinWait、停车场哈希表、park 与四种 unpark、ThreadParker 的各平台实现、eventual fairness。
场景:线程 A 持有一把 parking_lot::Mutex,线程 B 来加锁。B 先自旋、再停车;A 解锁时叫醒 B。
快路径:一次 CAS
#[inline]
fn lock(&self) {
if self
.state
.compare_exchange_weak(0, LOCKED_BIT, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
self.lock_slow(None);
}
unsafe { deadlock::acquire_resource(self as *const _ as usize) };
}
#[inline]
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);
}
状态是一个 AtomicU8,LOCKED_BIT = 0b01,PARKED_BIT = 0b10。加锁只在状态恰好为 0 时成功;解锁只在状态恰好是 LOCKED_BIT(没人在等)时走快路径。两个 deadlock::* 调用在不开 deadlock_detection 时是空函数。所以解锁进慢路径的唯一原因是 PARKED_BIT 被置上了,而这一位是等待者在停车前自己置的。
时序
sequenceDiagram
participant A as 线程 A(持锁)
participant S as 状态字 AtomicU8
participant B as 线程 B
participant K as 桶(WordLock + 链表)
participant P as B 的 ThreadParker
B->>S: CAS 0 → LOCKED 失败(读到 LOCKED)
B->>B: lock_slow:没有 PARKED_BIT,SpinWait 最多 10 步
B->>S: CAS LOCKED → LOCKED | PARKED
B->>K: park(addr):lock_bucket
B->>S: 桶锁里 validate:state == LOCKED | PARKED?
B->>K: 是:挂到链表尾,prepare_park,放桶锁
B->>P: park(),睡下
A->>S: unlock:CAS LOCKED → 0 失败(有 PARKED_BIT)
A->>K: unlock_slow → unpark_one(addr):lock_bucket,摘下 B
A->>S: 桶锁里 callback 写状态
alt be_fair(约每 0.5 ms 一次)
A->>A: 状态保持 LOCKED,返回 TOKEN_HANDOFF
else 普通
A->>S: 写成 PARKED 或 0,返回 TOKEN_NORMAL
end
A->>P: 桶锁里 unpark_lock,放桶锁,unpark
P-->>B: 醒来,ParkResult::Unparked(token)
alt TOKEN_HANDOFF
B->>B: 已经持有锁,直接返回
else TOKEN_NORMAL
B->>S: 回到循环开头重新抢,可能被别人抢先
end
B:从 CAS 失败到睡下
lock_slow 是一个循环,每圈按顺序问四件事:
- 锁空着吗? 空着就 CAS 抢,即使有人在排队(第 214 行注释「Grab the lock if it isn't locked, even if there is a queue on it」)。这就是插队(barging):刚来的线程可以抢在已经睡了很久的线程前面。
- 没人在排队吗? 没有
PARKED_BIT时,SpinWait::spin()一步,然后重读状态再来一圈。spin最多返回 10 次true:前 3 步分别自旋 2、4、8 次spin_loop,后 7 步各yield_now一次(SpinWait)。已经有人在排队就不自旋,直接去停车:既然前面有人在睡,锁放出来时多半会交给它们,自旋只是浪费。 - 置
PARKED_BIT。 一次Relaxed的 CAS,失败就重读重来。 - 停车。
// Park our thread until we are woken up by an unlock
let addr = self as *const _ as usize;
let validate = || self.state.load(Ordering::Relaxed) == LOCKED_BIT | PARKED_BIT;
let before_sleep = || {};
let timed_out = |_, was_last_thread| {
// Clear the parked bit if we were the last parked thread
if was_last_thread {
self.state.fetch_and(!PARKED_BIT, Ordering::Relaxed);
}
};
// ...
match unsafe {
parking_lot_core::park(
addr,
validate,
before_sleep,
timed_out,
DEFAULT_PARK_TOKEN,
timeout,
)
} {
// The thread that unparked us passed the lock on to us
// directly without unlocking it.
ParkResult::Unparked(TOKEN_HANDOFF) => return true,
// We were unparked normally, try acquiring the lock again
ParkResult::Unparked(_) => (),
// The validation function failed, try locking again
ParkResult::Invalid => (),
// Timeout expired
ParkResult::TimedOut => return false,
}
key 就是这把锁自己的地址。进了 park:
with_thread_data取本线程的ThreadData。线程第一次停车时才创建它,这一刻可能建表或扩表(停车场哈希表)。lock_bucket(addr):用斐波那契哈希算出桶,锁住桶的WordLock,确认哈希表在这期间没被换掉。- 在桶锁里调
validate:状态必须仍是LOCKED | PARKED。如果 A 在 B 置位之后、锁桶之前已经解锁(状态变成了PARKED或 0),validate返回false,park返回Invalid,B 回到循环开头重抢。 - 通过就把自己挂到桶链表的尾部、
prepare_park,放桶锁,调before_sleep(这里是空的),然后在自己的ThreadParker上睡下。本机 macOS 上是pthread_cond_wait,用sample采到的栈顶是RawMutex::lock_slow→_pthread_cond_wait→__psynch_cvwait(ThreadParker 的各平台实现)。
validate 为什么能防止丢唤醒:A 要叫醒 B,必须先拿同一个桶锁;而 B 检查状态和挂进链表是在同一次持桶锁期间完成的。所以只有两种顺序——A 先拿到桶锁(那时 B 还没挂上去,但 A 已经改了状态,B 的 validate 会失败),或 B 先挂好(A 一定能在链表里找到它)。没有「B 检查完、还没挂上,A 就来找人」的空隙。这和 rayon 卷 sleep 模块 里「先拿 is_blocked 锁再登记」是同一个思路,只是锁换成了全局的桶锁。
A:解锁与交接
A 的 unlock CAS 失败,进 unlock_slow:
#[cold]
fn unlock_slow(&self, force_fair: bool) {
// Unpark one thread and leave the parked bit set if there might
// still be parked threads on this address.
let addr = self as *const _ as usize;
let callback = |result: UnparkResult| {
// If we are using a fair unlock then we should keep the
// mutex locked and hand it off to the unparked thread.
if result.unparked_threads != 0 && (force_fair || result.be_fair) {
// Clear the parked bit if there are no more parked
// threads.
if !result.have_more_threads {
self.state.store(LOCKED_BIT, Ordering::Relaxed);
}
return TOKEN_HANDOFF;
}
// Clear the locked bit, and the parked bit as well if there
// are no more parked threads.
if result.have_more_threads {
self.state.store(PARKED_BIT, Ordering::Release);
} else {
self.state.store(0, Ordering::Release);
}
TOKEN_NORMAL
};
// ...
unsafe {
parking_lot_core::unpark_one(addr, callback);
}
}
unpark_one 锁住同一个桶,从链表头往后找第一个 key 等于 addr 的线程摘下来,再往后扫一遍看还有没有同 key 的线程(have_more_threads),算出 be_fair,然后在桶锁里调上面这个 callback:
- 普通情况:把状态写成
PARKED_BIT(后面还有人在等)或 0,返回TOKEN_NORMAL。锁此刻已经空出来,谁先 CAS 谁拿到。 - 公平情况(
unlock_fair传force_fair = true,或者这个桶的公平计时器到期,be_fair = true):状态保持LOCKED_BIT,返回TOKEN_HANDOFF,锁直接归 B,中间没有任何线程能插进来。没有更多等待者时顺手清掉PARKED_BIT。
callback 返回的 token 写进 B 的 ThreadData,然后 unpark_lock 先「锁住」B 的 parker(macOS 上就是锁住 B 的那个 pthread mutex),放桶锁,最后才真正 unpark。顺序的理由在 park 与四种 unpark:系统调用不放在桶锁里,但必须保证 B 在被叫醒前不会因为超时自己退出、释放 ThreadData。
callback 里写状态用的是 store,而不是 CAS:此刻状态是 LOCKED | PARKED,只有持锁的 A 能清 LOCKED_BIT,而 PARKED_BIT 只在桶锁里被清(raw_mutex.rs:48-54 的状态表注释),A 正持着桶锁,所以没有别人能同时改它。别的线程这时最多是读到 LOCKED | PARKED、去锁桶、在桶锁上等 A。
注意 A 在整个 unpark_one 期间(锁桶、扫链表、跑回调)锁都还是 LOCKED 的,新来的线程只能自旋或停车。0.13 改成先放锁再去叫醒,见 延后唤醒与守卫状态。
B:醒来之后
B 从 park 返回 Unparked(token):
TOKEN_HANDOFF:lock_slow直接返回true,B 已经持有锁。TOKEN_NORMAL:spinwait.reset(),重读状态,回到循环开头。从 A 写状态到 B 真正被调度起来,中间隔着一次线程唤醒(本机用 parking_lot_core 的park/unpark_one乒乓,单向延迟三轮的中位数在 1.4–2.1 µs 之间,E12,见 ThreadParker 的各平台实现)。这段时间里 A 自己或第三个线程 C 都可能 CAS 成功;B 回来发现锁又被占了,就再自旋(没人排队时)或再停车。
这是吞吐和公平的交换:允许插队,锁在交接时不会空等那 1–2 µs 的唤醒;代价是 B 可能连续好几次被抢先。parking_lot 0.12.5 用每个桶的公平计时器兜底,大约每 0.5 ms 强制一次 TOKEN_HANDOFF,把一个线程连续独占的时间限制在 1 ms 左右(eventual fairness)。
小结
- 快路径:加锁一次
0 → LOCKED的 CAS,解锁一次LOCKED → 0的 CAS;解锁进慢路径的唯一原因是有人置了PARKED_BIT。 lock_slow:能抢就抢(即使有人排队);没人排队才自旋,最多 10 步;置PARKED_BIT;以锁地址为 key 停车,validate在桶锁里确认状态仍是LOCKED | PARKED。unlock_slow:unpark_one在桶锁里调回调;普通解锁放开锁、返回TOKEN_NORMAL,公平解锁保持上锁、返回TOKEN_HANDOFF。- 叫醒顺序:桶锁里写 token、
unpark_lock,放桶锁,再unpark。 - 拿到
TOKEN_NORMAL的线程回去重抢,可能被插队;eventual fairness 每 0.5 ms 左右强制一次交接。