Rust in Everything
基于 parking_lot 0.12.5

一次 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:两个分支

condvar.rs:186-239:

    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 to notify_one are 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 就返回。

💬 关联讨论

发起讨论
加载中...