同一线程第二次 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 borrowedpanic。死锁变成了 panic,问题本身还在,所以只适合「内层只读」或回调不会同时改的场景。- 调试时打开
deadlock_detection并轮询check_deadlock():同一线程对同一把Mutex加两次锁会被报告为一个只含 1 个线程的环(E8 的self模式约 61 ms 报告,死锁检测)。这个 feature 只用于调试,它会改变锁的开销。
小结
- parking_lot 的
Mutex、RwLock(写锁,以及有写者排队时的读锁)都不可重入,同线程再加锁直接睡死,没有 panic。 - 状态字节不记录持有者,锁无从发现「等的是自己」;lock_api 文档写明会死锁。
- 实测:
Mutex、RwLock写后读、本机 stdMutex都 3/3 卡死;parking_lot 栈顶__psynch_cvwait,std 栈顶__psynch_mutexwait。 - 改法首选缩小临界区;
ReentrantMutex加RefCell只是把死锁换成借用 panic。
💬 关联讨论
发起讨论加载中...