Rust in Everything
基于 parking_lot 0.12.5

ReentrantMutex

主线位置:主线 1 的一个外包装。同一线程第二次 lock 根本不碰 RawMutex,只是计数加一;其他线程的 lock 走的仍是 RawMutex 的一字节状态机里那条完整的竞争路径。

flowchart TB
  l["ReentrantMutex::lock"] --> id["id = get_thread_id.nonzero_thread_id()"]
  id --> own{"owner == id?(Relaxed 读)"}
  own -->|"是:本线程已持有"| inc["lock_count += 1(checked_add,溢出 panic)"]
  own -->|"否"| raw["mutex.lock():普通 RawMutex,可能停车"]
  raw --> set["owner = id;lock_count = 1"]
  u["守卫 drop:unlock"] --> dec["lock_count -= 1"]
  dec --> z{"变成 0?"}
  z -->|"是"| clr["owner = 0;mutex.unlock()"]
  z -->|"否"| keep["什么也不做"]

parking_lot 里所有的锁都不可重入:同一线程对 Mutex 加锁两次直接卡死,没有报错(坑 not-reentrant)。ReentrantMutex 是唯一的例外。它整个实现在 lock_api 里,parking_lot 只提供一个「怎么拿线程 id」的类型。

lock_api:两个字段加一把锁

lock_api/src/remutex.rs:64-149:

pub struct RawReentrantMutex<R, G> {
    owner: AtomicUsize,
    lock_count: Cell<usize>,
    mutex: R,
    get_thread_id: G,
}

#[inline]
fn lock_internal<F: FnOnce() -> bool>(&self, try_lock: F) -> bool {
    let id = self.get_thread_id.nonzero_thread_id().get();
    if self.owner.load(Ordering::Relaxed) == id {
        self.lock_count.set(
            self.lock_count
                .get()
                .checked_add(1)
                .expect("ReentrantMutex lock count overflow"),
        );
    } else {
        if !try_lock() {
            return false;
        }
        self.owner.store(id, Ordering::Relaxed);
        debug_assert_eq!(self.lock_count.get(), 0);
        self.lock_count.set(1);
    }
    true
}

pub unsafe fn unlock(&self) {
    let lock_count = self.lock_count.get() - 1;
    self.lock_count.set(lock_count);
    if lock_count == 0 {
        self.owner.store(0, Ordering::Relaxed);
        self.mutex.unlock();
    }
}
  • owner 为什么可以 Relaxed:一个线程只会拿 owner 和自己的 id 比。读到等于自己的 id,只可能是自己之前写进去的(同一线程内的读写有程序顺序);读到别的值,不管是多旧的值,都不等于自己的 id,于是去走 mutex.lock(),真正的同步由那把锁的 Acquire / Release 完成。
  • lock_count 是 Cell<usize>,不是原子:只有持有锁的线程会读写它。RawReentrantMutex 手写了 Sync(71-72 行),正确性靠这条「只有持有者碰」的约定。
  • 计数溢出用 checked_add(..).expect(..):在 64 位上要嵌套 2⁶⁴ 次,实际只可能出现在 mem::forget 守卫的循环里;溢出时 panic「ReentrantMutex lock count overflow」,而不是回绕成 0。
  • try_lock、try_lock_for、try_lock_until 都走同一个 lock_internal,只是换了「真正加锁」的那个闭包(107-119、191-204 行)。本线程已持有时它们总是立即成功。

unlock 里的 - 1 没有检查:它是 unsafe fn,前提是调用方确实持有锁,守卫保证这一点。

parking_lot:线程局部变量的地址

src/remutex.rs:12-28:

pub struct RawThreadId;

unsafe impl GetThreadId for RawThreadId {
    const INIT: RawThreadId = RawThreadId;

    fn nonzero_thread_id(&self) -> NonZeroUsize {
        // The address of a thread-local variable is guaranteed to be unique to the
        // current thread, and is also guaranteed to be non-zero. The variable has to have a
        // non-zero size to guarantee it has a unique address for each thread.
        thread_local!(static KEY: u8 = 0);
        KEY.with(|x| {
            NonZeroUsize::new(x as *const _ as usize)
                .expect("thread-local variable address is null")
        })
    }
}

源码没有说明为什么不用 std::thread::ThreadId;取一个线程局部变量的地址只需要一次 TLS 访问,推测这是选它的原因。注释里那句「必须非零大小」是关键:零大小类型的变量可以都放在同一个地址上。GetThreadId 的 safety 契约(35-55 行)只要求「同时活着的线程 id 互不相同」,已退出线程的 id 可以被复用,TLS 地址正好满足这一点。

ReentrantMutex<T> 是 lock_api::ReentrantMutex<RawMutex, RawThreadId, T>(src/remutex.rs:41)。RawThreadId 是零大小类型,RawMutex 1 字节,加上两个 usize,ReentrantMutex<()> 本机 24 字节、ReentrantMutex<u64> 32 字节(E1)。

守卫:只给 &T,永远 !Send

lock_api/src/remutex.rs:618-621:

pub struct ReentrantMutexGuard<'a, R: RawMutex, G: GetThreadId, T: ?Sized> {
    remutex: &'a ReentrantMutex<R, G, T>,
    marker: PhantomData<(&'a T, GuardNoSend)>,
}

两处和 MutexGuard 不同:

  1. 只实现 Deref,没有 DerefMut。同一线程可以同时持有两个守卫,如果都能给 &mut T,就有两个可变引用指向同一处。要改数据,里面套一个 RefCell(文档原话「Use a RefCell if you need this」),借用冲突由 RefCell 在运行时报 panic。
  2. 标记写死成 GuardNoSend,不看 R::GuardMarker。MutexGuard 的 Send 性由 RawMutex::GuardMarker 决定,开了 send_guard feature 就是 Send;可重入守卫无论如何都不是:守卫要是跑到别的线程去 drop,owner 里记的还是原线程的 id,lock_count 就乱了(GuardSend 与 send_guard)。

ReentrantMutex 的 Sync 只要求 T: Send,不要求 T: Sync(225-228 行):任一时刻只有一个线程拿得到 &T,所以 ReentrantMutex<RefCell<T>> 可以在线程之间共享。

公平解锁与 bump

unlock_fair 只在计数降到 0 时调用内层 mutex.unlock_fair()。bump(179-189 行)只在 lock_count == 1 时生效:暂存 owner,把 owner 和计数清零,调内层 bump(可能把锁交给别人再拿回来),再恢复 owner 和计数 1。嵌套持有(计数大于 1)时 bump 什么也不做,因为外层还有守卫指望锁一直在手里。这段计数处理是 lock_api 0.4.11 修过的(CHANGELOG「Fixed ReentrantMutex::bump lock count. (#390)」,提交 e23efd5「Reset lock_count appropriately in bump()」)。

什么时候用

本站几个卷里遇到过的「同线程重入自己的锁」:burn 反向传播重入自己那张图的锁(burn 卷,上游改成了 try_lock)、dioxus SyncStorage 拿着写锁再读(dioxus 卷)。这类问题改成 ReentrantMutex<RefCell<T>> 只是把死锁换成了 RefCell 的 already borrowed panic:借用冲突本身还在。它真正适合的是「同一线程的外层已经拿着锁、内层只读」或「回调可能重入但不会同时改」的场景;能缩小临界区、把回调挪到锁外,通常是更好的改法。

小结

  • RawReentrantMutex = owner: AtomicUsize + lock_count: Cell<usize> + 一把普通 RawMutex + 取线程 id 的 G;本线程已持有时只把计数加一,不碰内层锁。
  • parking_lot 的 RawThreadId 用一个 thread_local! 里 u8 的地址当 id:非零,活着的线程之间唯一。
  • 守卫只有 Deref,要改数据套 RefCell;守卫恒为 GuardNoSend,与 send_guard 无关。
  • 计数溢出 panic;bump 只在计数为 1 时让出锁(lock_api 0.4.11,#390)。
  • 本机 ReentrantMutex<()> 24 字节。

💬 关联讨论

发起讨论
加载中...