Rust in Everything
基于 parking_lot 0.12.5

同一线程第二次 lock() 永远不返回,也没有报错

主线位置:主线 1。第二次 lock() 的快路径 CAS 失败,进 lock_slow:自旋 10 轮,置 PARKED_BIT,在停车场睡下等「持有者」解锁,而持有者就是自己(RawMutex 的一字节状态机)。锁里不记录谁持有它,没有办法发现这件事。

flowchart TB
  g1["第一次 lock():CAS 0 到 01,拿到守卫"] --> f["调用某个函数,里面再 lock() 同一把锁"]
  f --> cas["快路径 CAS 失败(状态 01)"]
  cas --> spin["lock_slow:自旋、yield 共 10 轮"]
  spin --> park["置 PARKED_BIT,park(addr)"]
  park --> sleep["睡在本线程的 pthread 条件变量上"]
  sleep -.->|"唯一能叫醒它的是第一次的守卫 drop,它在同一个线程的更早一帧里"| g1

现象

线程卡住,CPU 为零,没有任何输出。和 同一线程第二次 read() 卡住 不同,这里不需要第三个线程,每次都复现。典型写法是锁里调回调、锁里调另一个也会加这把锁的方法、或者 match lock.lock().field { .. } 的守卫活过了整个 match。

最小复现

exp-parking_lot/pitfalls(不开 deadlock_detection,2 秒看门狗),每种 3 次:

let m = parking_lot::Mutex::new(0u32);
let _a = m.lock();
let _b = m.lock(); // 永远不返回
用例结果(3 次)
relock-mutex:Mutex 加两次3/3 卡死,看门狗触发
relock-rw:RwLock 先 write() 再 read()3/3 卡死
relock-std:std::sync::Mutex 加两次(本机)3/3 卡死,没有 panic
try-lock(反向):lock() 后 try_lock()3/3 立即返回 None
remutex(反向):ReentrantMutex 加两次3/3 正常,两个守卫同时存在

卡住时用 sample 采两个进程的主线程(只保留相关帧):

relock-mutex(parking_lot)
  pitfalls::main  main.rs:42
    parking_lot::raw_mutex::RawMutex::lock_slow
      __psynch_cvwait                    (libsystem_kernel.dylib)

relock-std(std,macOS 上是 pthread mutex)
  pitfalls::main  main.rs:56
    std::sys::pal::unix::sync::mutex::Mutex::lock
      _pthread_mutex_firstfit_lock_slow  (libsystem_pthread.dylib)
        __psynch_mutexwait               (libsystem_kernel.dylib)

parking_lot 卡在条件变量上(每线程一个 parker),std 卡在 mutex 上。看到栈顶是 __psynch_cvwait 时,它可能是一次 Condvar::wait,也可能是一把 parking_lot 的锁,要看下一帧(ThreadParker 的各平台实现)。std 的自锁栈与 tauri 卷 插件锁死锁 里的一致。

源码出处

  • lock_api 的 Mutex::lock 文档写得很直接:「Attempts to lock a mutex in the thread which already holds the lock will result in a deadlock」(lock_api/src/mutex.rs:208-209)。
  • 状态只有一个字节,LOCKED_BIT 不带持有者信息(raw_mutex.rs:25-55);lock_slow 的 validate 只看状态是否为 11(209-289 行)。
  • std 的文档留了余地:「this function will not return on the second call (it might panic or deadlock, for example)」(sync/poison/mutex.rs:459-461)。本机是 deadlock。

本站其他卷里的实例:burn 的反向传播在持有自己那张图的锁时再调 backward(),上游 PR #5194 把清理扫描改成 try_lock(burn 卷);dioxus 的 SyncStorage 同线程拿着写锁再读(dioxus 卷)。

怎么改

  • 缩小临界区:守卫只包住读写数据的那几行,回调、IO、调用别的模块都放到锁外。let v = { let g = m.lock(); g.x.clone() }; 这种写法让守卫在块尾就析构。
  • 锁内需要的辅助函数接受 &mut T,而不是自己再加锁:把「加锁」和「对数据做事」分成两层。
  • 只在确实可能重入、又不能重构的地方用 try_lock,拿不到就跳过或延后(burn 的做法)。注意 try_lock 失败也可能是别的线程持有,不能当作「一定是自己」。
  • ReentrantMutex<RefCell<T>>:同线程再加锁只是计数加一(ReentrantMutex)。它只给 &T,改数据要经 RefCell,内外层同时可变借用会变成 already borrowed panic。死锁变成了 panic,问题本身还在,所以只适合「内层只读」或回调不会同时改的场景。
  • 调试时打开 deadlock_detection 并轮询 check_deadlock():同一线程对同一把 Mutex 加两次锁会被报告为一个只含 1 个线程的环(E8 的 self 模式约 61 ms 报告,死锁检测)。这个 feature 只用于调试,它会改变锁的开销。

小结

  • parking_lot 的 Mutex、RwLock(写锁,以及有写者排队时的读锁)都不可重入,同线程再加锁直接睡死,没有 panic。
  • 状态字节不记录持有者,锁无从发现「等的是自己」;lock_api 文档写明会死锁。
  • 实测:Mutex、RwLock 写后读、本机 std Mutex 都 3/3 卡死;parking_lot 栈顶 __psynch_cvwait,std 栈顶 __psynch_mutexwait。
  • 改法首选缩小临界区;ReentrantMutex 加 RefCell 只是把死锁换成借用 panic。

💬 关联讨论

发起讨论
加载中...