Python 多线程死锁是什么?常见原因和排查方法有哪些?
简化版
死锁是多个线程互相等待对方持有的资源,导致谁也无法继续执行。常见原因包括锁顺序不一致、忘记释放锁、持锁执行阻塞操作、同一线程重复获取普通 Lock;预防思路是统一加锁顺序、使用 with 自动释放、缩小临界区、必要时使用超时和日志排查。
详细版
典型死锁:
import threading
lock_a = threading.Lock()
lock_b = threading.Lock()
def task1():
with lock_a:
with lock_b:
pass
def task2():
with lock_b:
with lock_a:
pass
如果线程 1 拿到 lock_a,线程 2 拿到 lock_b,双方都等待对方释放,就会卡住。
常见原因:
- 多把锁获取顺序不一致;
acquire()后异常导致没有release();- 普通
Lock在同一线程内重复获取; - 持锁期间调用外部慢接口、阻塞 I/O 或回调;
- 线程池任务互相等待对方结果。
预防方法:
- 统一锁顺序;
- 优先使用
with lock:; - 使用
acquire(timeout=...)避免无限等待; - 临界区只包共享状态,不包耗时操作;
- 记录线程名、锁获取和释放日志。
完整版教学
一、死锁不是程序慢,而是等待关系闭环
程序卡住不一定是死锁,可能是 I/O 慢、CPU 忙、任务排队。但死锁有一个典型特征:多个执行单元形成等待闭环。
线程 A 等线程 B 手里的锁
线程 B 等线程 A 手里的锁
只要没有外部打破这个关系,程序就会一直等下去。
二、锁顺序不一致是最常见原因
假设有两把锁:账户 A 的锁和账户 B 的锁。转账时一个线程从 A 转 B,另一个线程从 B 转 A。
如果代码按调用顺序加锁:
def transfer(src, dst, amount):
with src.lock:
with dst.lock:
src.money -= amount
dst.money += amount
线程 1 拿 A 等 B,线程 2 拿 B 等 A,就可能死锁。
解决方法是统一排序,比如始终按账户 id 小的先加锁:
first, second = sorted([src, dst], key=lambda account: account.id)
with first.lock:
with second.lock:
...
三、忘记释放锁也会造成永久等待
手动加锁时:
lock.acquire()
do_something()
lock.release()
如果 do_something() 抛异常,release() 不会执行。其他线程之后再拿锁就会一直阻塞。
所以更推荐:
with lock:
do_something()
with 会在退出代码块时释放锁,即使出现异常也更安全。
四、持锁做慢操作很危险
持锁期间不要做不必要的慢操作:
with lock:
response = call_remote_service()
shared.append(response)
远程调用可能卡几秒甚至几十秒,这期间其他线程都拿不到锁。更好的方式是把慢操作移到锁外:
response = call_remote_service()
with lock:
shared.append(response)
临界区越短,死锁和性能问题越少。
五、线程池里的“等待 Future”也会死锁
死锁不只发生在显式 Lock 上。线程池也可能死锁:
from concurrent.futures import ThreadPoolExecutor
def task():
future = pool.submit(other_task)
return future.result()
with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(task).result()
线程池只有一个 worker,task 占着它并等待 other_task,但 other_task 没有空闲 worker 执行,于是卡住。
面试时提到这一点很加分,因为它说明你不只理解锁,还理解资源池等待。
六、如何排查死锁
常见排查思路:
- 给线程命名;
- 在获取锁和释放锁时打印日志;
- 使用
faulthandlerdump 线程栈; - 给
acquire()设置 timeout; - 观察是否所有线程都卡在等待锁、队列或 Future。
示例:
if lock.acquire(timeout=3):
try:
do_work()
finally:
lock.release()
else:
print("failed to acquire lock")
超时不是最终解决方案,但能帮助定位问题,避免完全无声卡死。
七、常见误区与追问
| 死锁诱因 | 具体表现 | 常见预防 |
|---|---|---|
| 锁顺序不一致 | A 等 B,B 等 A | 统一全局加锁顺序 |
| 忘记释放锁 | 异常后锁不释放 | 使用 with lock |
| 持锁慢操作 | 锁被长时间占用 | 缩小临界区 |
| 线程池内相互等待 | 工作线程等待同池 Future | 避免池内同步等待 |
- 误区:死锁就是程序运行慢。 死锁是等待关系形成闭环,相关线程永远等不到继续执行的条件。
- 误区:只要锁数量少就不会死锁。 两把锁顺序不一致就足以死锁;一把锁也可能因忘记释放造成永久阻塞。
- 误区:给 acquire 加 timeout 就彻底解决死锁。 超时能让程序有机会退出等待,但还要处理回滚、释放已持有锁和重试策略。
- 追问:如何定位 Python 线程死锁? 可用日志记录加锁顺序、
faulthandler.dump_traceback()、线程栈和锁等待点分析。 - 追问:为什么持锁做 I/O 危险? I/O 延迟不可控,持锁期间其他线程都无法进入临界区,容易扩大阻塞甚至触发链式等待。
- 追问:线程池死锁为什么隐蔽? 代码没有显式锁,但任务占满工作线程后又等待同一个池里的新任务,资源形成等待闭环。
记忆钩子:死锁排查先画等待图,只要出现“线程 A 等资源 B、线程 B 又等资源 A”的闭环,就要拆环。
八、加强记忆
死锁的本质是等待形成闭环。预防死锁抓四件事:锁顺序统一、用 with 保证释放、临界区尽量短、不要在资源池里互相等待。排查时看线程栈和锁日志,找到“谁拿着什么、又在等什么”。