死锁必须同时满足互斥、占有并等待、不可剥夺、循环等待四个条件;python中lock默认互斥且不可剥夺,rlock不防死锁反而易埋雷;用settrace记录锁操作、超时acquire是关键防御手段。

死锁发生的四个必要条件
死锁不是“线程卡住”就一定是,它必须同时满足:互斥、占有并等待、不可剥夺、循环等待。Python 的 threading.Lock 默认就是互斥且不可剥夺的;一旦两个线程各自持有一个锁、又去申请对方持有的锁,循环等待就成立了。
常见错误现象是程序突然“不动了”,Ctrl+C 后看到堆栈停在 lock.acquire() 上,且多个线程都卡在不同锁的等待点。
- 互斥:锁对象本身设计就是排他的,没法绕过
- 占有并等待:比如线程 A 调用
lock1.acquire()成功后,还没释放就调lock2.acquire() - 不可剥夺:Python 中
Lock和RLock都不支持强制释放,只能等持有者主动release() - 循环等待:A 等 B 的锁,B 等 A 的锁 —— 这是最容易被代码结构隐式引入的
用 threading.settrace 配合日志定位锁持有链
Python 没有内置的锁持有者快照机制,但可以利用 sys.settrace 或线程级 trace 钩子,在每次加锁/释放时记录线程 ID 和锁对象 ID,从而还原谁在什么时候拿了哪把锁。
实际操作建议:
立即学习“Python免费学习笔记(深入)”;
- 不要用
print()直接打日志(会干扰锁行为),改用线程安全的queue.Queue缓存事件 - 给每个
Lock实例打上可识别的 name 参数(如Lock(name="db_conn")),否则日志里全是<unlocked _thread.lock object at></unlocked>,毫无意义 - 在疑似死锁前启用 trace,比如启动时加一句
threading.settrace(trace_func),其中trace_func拦截call和return事件,专门捕获对acquire/release的调用
示例片段(仅示意关键逻辑):
def trace_func(frame, event, arg):
if event == "call" and "acquire" in frame.f_code.co_name:
lock_obj = frame.f_locals.get("self")
if isinstance(lock_obj, threading.Lock):
log_queue.put((threading.get_ident(), "acquire", id(lock_obj)))
RLock 不能防死锁,反而更容易埋雷
threading.RLock 允许同一线程重复 acquire,常被误以为“更安全”。但它只是缓解了单线程重入问题,对多线程间的交叉等待毫无帮助,反而因“不会报错”掩盖了设计缺陷。
使用场景误区:
- 把
RLock当成“万能锁”用在跨函数协作中,比如函数 A 拿了rlock,调用函数 B,B 又尝试拿同一把rlock—— 表面没问题,但若 B 改成去拿另一把锁,而另一线程正相反,死锁立刻成立 -
RLock的 acquire 计数器不对外暴露,无法通过日志判断“当前是否已持有”,排查时比普通Lock更难反推状态
性能影响很小,但可读性和可维护性明显下降:别人读代码时,无法从锁类型判断“这里是否本该只由一个线程进入”。
超时 + 重试是唯一靠谱的防御手段
Python 的 Lock.acquire(timeout=...) 是少数真正可用的死锁缓解机制。它不解决根本原因,但能把“无限等待”变成“有限等待 + 显式失败”,从而让问题浮出水面。
参数差异要注意:
-
timeout=None(默认)→ 无限阻塞,死锁即静默卡死 -
timeout=0→ 非阻塞,拿不到立即返回False,适合轮询场景 -
timeout>0→ 最多等这么久,超时返回False,必须手动处理失败分支
常见错误现象是写了 lock.acquire(timeout=1),但没检查返回值,结果锁没拿到就往下跑,引发数据竞争或异常。
建议做法:
- 所有
acquire()调用都显式带timeout,哪怕只是timeout=0.1 - 失败后不要直接
raise,而是记录上下文(哪个线程、哪段逻辑、哪两把锁冲突),再 sleep 后重试,或降级为只读操作 - 生产环境可配合信号量或健康检查端点,把“连续 N 次 acquire 超时”作为死锁预警指标
锁的顺序一致性、资源生命周期管理、以及超时后的清理逻辑,才是真正容易被忽略的复杂点。








