← 返回题目列表

Python 多线程死锁是什么?常见原因和排查方法有哪些?

高频 中等 第 2 / 27 题 更新于 2026/07/27
死锁Lock多线程排查

简化版

死锁是多个线程互相等待对方持有的资源,导致谁也无法继续执行。常见原因包括锁顺序不一致、忘记释放锁、持锁执行阻塞操作、同一线程重复获取普通 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 执行,于是卡住。

面试时提到这一点很加分,因为它说明你不只理解锁,还理解资源池等待。

六、如何排查死锁

常见排查思路:

  • 给线程命名;
  • 在获取锁和释放锁时打印日志;
  • 使用 faulthandler dump 线程栈;
  • 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 保证释放、临界区尽量短、不要在资源池里互相等待。排查时看线程栈和锁日志,找到“谁拿着什么、又在等什么”。