Rust in Everything
基于 parking_lot 0.12.5

一次有竞争的加锁

主线位置:主线 1 的全程。本页只画时序和关键行,细节在后面:RawMutex 的一字节状态机、SpinWait、停车场哈希表、park 与四种 unpark、ThreadParker 的各平台实现、eventual fairness。

场景:线程 A 持有一把 parking_lot::Mutex,线程 B 来加锁。B 先自旋、再停车;A 解锁时叫醒 B。

快路径:一次 CAS

raw_mutex.rs:65-75 与 :99-110:

    #[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 是一个循环,每圈按顺序问四件事:

  1. 锁空着吗? 空着就 CAS 抢,即使有人在排队(第 214 行注释「Grab the lock if it isn't locked, even if there is a queue on it」)。这就是插队(barging):刚来的线程可以抢在已经睡了很久的线程前面。
  2. 没人在排队吗? 没有 PARKED_BIT 时,SpinWait::spin() 一步,然后重读状态再来一圈。spin 最多返回 10 次 true:前 3 步分别自旋 2、4、8 次 spin_loop,后 7 步各 yield_now 一次(SpinWait)。已经有人在排队就不自旋,直接去停车:既然前面有人在睡,锁放出来时多半会交给它们,自旋只是浪费。
  3. 置 PARKED_BIT。 一次 Relaxed 的 CAS,失败就重读重来。
  4. 停车。
            // 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 左右强制一次交接。

💬 关联讨论

发起讨论
加载中...