一次 notify_all
主线位置:主线 2 的全程。本页只画时序和关键行,细节在后面:Condvar、park 与四种 unpark、停车场哈希表(
lock_bucket_pair)、RawMutex 的一字节状态机。
场景:N 个线程在同一个 Condvar 上 wait,配的是同一把 Mutex;线程 T 调 notify_all。std 的 Condvar::notify_all 会把 N 个线程全部叫醒,它们醒来后第一件事都是去抢那把 mutex,只有一个能抢到,其余再睡一次——这就是惊群(按 std 的语义如此;本机 macOS 上实测并不明显,见下面的 E6)。parking_lot 的做法是:至多叫醒一个,其余的不叫醒,直接从 condvar 的等待队列挪到 mutex 的等待队列上(requeue),之后由 mutex 的每次 unlock 一个一个叫醒。
wait:以 condvar 的地址停车
Condvar 只有一个字段(condvar.rs:90-92):
pub struct Condvar {
state: AtomicPtr<RawMutex>,
}
它记的是「现在和哪把 mutex 配对」,没有等待者时是 null。等待队列不在 condvar 里,而在停车场:key 是 condvar 自己的地址。wait 进 wait_until_internal,传给 park 三个回调:
let validate = || {
// Ensure we don't use two different mutexes with the same
// Condvar at the same time. This is done while locked to
// avoid races with notify_one
let state = self.state.load(Ordering::Relaxed);
if state.is_null() {
self.state.store(lock_addr, Ordering::Relaxed);
} else if state != lock_addr {
bad_mutex = true;
return false;
}
true
};
let before_sleep = || {
// Unlock the mutex before sleeping...
unsafe { mutex.unlock() };
};
validate在 condvar 的桶锁里把state设成这把 mutex 的地址;已经是别的 mutex 就失败,醒来后 panic「attempted to use a condition variable with more than one mutex」(第 349 行)。before_sleep在挂进队列、放开桶锁之后才解锁 mutex。所以「解锁 mutex」和「开始等待」之间没有空隙:任何在 mutex 解锁之后发出的notify都必须先拿 condvar 的桶锁,而这时等待者已经在链表里了。state上的读写全是Relaxed:它们都发生在 condvar 那个桶的桶锁里(validate和下面notify_all的validate),桶锁的加锁 / 解锁提供了先后关系。notify_all开头那次锁外的load只用来判断「有没有人在等」,读到旧值的后果在下面讲。
notify_all:两个分支
pub fn notify_all(&self) -> usize {
// Nothing to do if there are no waiting threads
let state = self.state.load(Ordering::Relaxed);
if state.is_null() {
return 0;
}
self.notify_all_slow(state)
}
#[cold]
fn notify_all_slow(&self, mutex: *mut RawMutex) -> usize {
// Unpark one thread and requeue the rest onto the mutex
let from = self as *const _ as usize;
let to = mutex as usize;
let validate = || {
// ...
if self.state.load(Ordering::Relaxed) != mutex {
return RequeueOp::Abort;
}
// Clear our state since we are going to unpark or requeue all
// threads.
self.state.store(ptr::null_mut(), Ordering::Relaxed);
// Unpark one thread if the mutex is unlocked, otherwise just
// requeue everything to the mutex. ...
if unsafe { (*mutex).mark_parked_if_locked() } {
RequeueOp::RequeueAll
} else {
RequeueOp::UnparkOneRequeueRest
}
};
let callback = |op, result: UnparkResult| {
// If we requeued threads to the mutex, mark it as having
// parked threads. The RequeueAll case is already handled above.
if op == RequeueOp::UnparkOneRequeueRest && result.requeued_threads != 0 {
unsafe { (*mutex).mark_parked() };
}
TOKEN_NORMAL
};
let res = unsafe { parking_lot_core::unpark_requeue(from, to, validate, callback) };
res.unparked_threads + res.requeued_threads
}
unpark_requeue(from, to, …) 先用 lock_bucket_pair 同时锁住 condvar 的桶和 mutex 的桶(按桶下标从小到大加锁,两个 key 落在同一个桶时只锁一次),在两把桶锁里调 validate。validate 看 mutex 此刻是否被锁着,选出两种操作之一。
分支一:mutex 被锁着(RequeueAll)
最常见的写法是持着 mutex 调 notify_all:
let mut g = m.lock();
*g = true;
cv.notify_all();
// g 在这里才释放
这时 mutex 一定是锁着的。mark_parked_if_locked 给 mutex 补上 PARKED_BIT,返回 true,选 RequeueAll:一个都不叫醒。
sequenceDiagram participant T as 通知者 T(持 mutex) participant CB as condvar 的桶 participant MB as mutex 的桶 participant M as mutex 状态 participant W as 等待者 W1..WN T->>CB: notify_all → unpark_requeue:lock_bucket_pair T->>MB: (同时锁住) T->>M: validate:state 清空;mark_parked_if_locked → LOCKED | PARKED T->>CB: RequeueAll:摘下所有 key = condvar 的线程 T->>MB: 改 key 为 mutex 地址,接到 mutex 桶的链表尾 T->>CB: callback 返回 TOKEN_NORMAL;放两把桶锁 Note over T,W: 返回 0 + N = N;此时没有任何线程醒来 T->>M: 释放守卫:unlock CAS 失败(有 PARKED_BIT) T->>MB: unlock_slow → unpark_one(mutex):叫醒 W1 W->>M: W1 拿到锁,从 wait 返回;W1 解锁时再叫醒 W2……
N 个线程原地从一条队列换到另一条,key 从 condvar 地址改成 mutex 地址((*current).key.store(key_to, Relaxed),第 935 行)。之后 T 释放 mutex,unlock 看到 PARKED_BIT 进慢路径,unpark_one(mutex) 叫醒 W1;W1 从 park 返回,回到 wait_until_internal,拿到 TOKEN_HANDOFF 就已经持有 mutex,拿到 TOKEN_NORMAL 就调 mutex.lock() 去抢(第 352–357 行);W1 用完解锁,又叫醒 W2。被叫醒的线程每次都只有一个,而且醒来时锁多半是空的。
分支二:mutex 没被锁(UnparkOneRequeueRest)
如果 T 先释放 mutex 再 notify_all,mark_parked_if_locked 返回 false,选 UnparkOneRequeueRest:叫醒第一个,其余挪走,callback 给 mutex 补 PARKED_BIT。
sequenceDiagram participant T as 通知者 T(不持 mutex) participant CB as condvar 的桶 participant MB as mutex 的桶 participant M as mutex 状态 participant W1 as 第一个等待者 T->>CB: unpark_requeue:lock_bucket_pair T->>M: validate:state 清空;mutex 空着,选 UnparkOneRequeueRest T->>CB: 摘下 W1 准备叫醒;W2..WN 改 key、接到 mutex 桶 T->>M: callback:mark_parked(requeued 不为 0 时) T->>W1: 桶锁里 unpark_lock,放桶锁,unpark W1->>M: 醒来拿到 TOKEN_NORMAL,mutex.lock() Note over W1,M: W1 解锁时看到 PARKED_BIT,叫醒 W2,依次类推
validate 注释里说「检查之后 mutex 又被锁上,这种竞争无所谓」:W1 醒来会自己去 lock(),锁着就在 mutex 上停车,和其他被挪过去的线程排在一起。
返回值
两个分支都返回 unparked_threads + requeued_threads,N 个等待者时恒为 N(分支一 0 + N,分支二 1 + (N − 1))。文档说它是「the number of threads woken up」(第 179 行),实际是「被叫醒的加被挪走的」,后者此刻还在睡。notify_one 同理:mutex 被锁着时选 RequeueOne,一个都不叫醒、只挪走一个,返回值照样是 true。
E6 的实测(N 个等待者,每个拿到锁后持锁 20 µs,不开 deadlock_detection,详见 Condvar):返回值在所有配置下都恒为 N;全部做完的总时间约 N × 31 µs,由持锁工作串行决定,parking_lot 和 std 相差不到 10%;差别在开头——从通知到第一个等待者拿到锁,N = 64 时 parking_lot 19–27 µs、std 103–132 µs,N = 16 时 13–16 µs 对 39–42 µs;进程上下文切换 parking_lot 约 2N(N = 64 时 128),std 136,N = 1 时两边都是 2。循环 N 次 notify_one 与一次 notify_all 在各项指标上分不开。本机 std 的惊群并不明显,推测是 macOS 内核对条件变量的 broadcast 也做了类似 requeue 的处理,未验证。
锁外那次 load
notify_all 开头在锁外读 state,为 null 就直接返回 0,不碰停车场。这是「没人在等时 notify 几乎免费」的来源。读到旧值的两种情况都没有害处:
- 读到非 null,但等待者已经全部离开:进慢路径后
validate在桶锁里重读,state已被清空或换成了另一把 mutex,返回Abort,什么都不做。 - 读到 null,但有一个等待者正在
wait:它的validate还没在桶锁里把state写进去,也就是还没挂进队列,更没解锁 mutex。如果通知者遵守「改条件和 notify 都在持 mutex 时做」或者「先改条件再 notify」,等待者在wait之前检查条件时就会看到新值;这和 std 的约定相同,notify不会被缓存(notify_one的文档:「Calls tonotify_oneare not buffered in any way」)。
小结
Condvar只存「配对的 mutex」一个指针;等待队列在停车场里,key 是 condvar 的地址。wait的validate在桶锁里记下 mutex,before_sleep在入队之后才解锁 mutex,所以不丢通知。notify_all用unpark_requeue同时锁两个桶:mutex 被锁着时RequeueAll,一个都不叫醒;没被锁时叫醒一个、挪走其余;之后由 mutex 的unlock逐个叫醒。- 返回值是「叫醒的 + 挪走的」,N 个等待者时恒为 N,不是「醒了几个」。
- 没人等待时
notify_*只读一次state就返回。