Python 死锁排查全攻略:从线程卡死到锁依赖定位与工程化修复
Python 死锁排查全攻略从线程卡死到锁依赖定位与工程化修复在 Python 并发开发中有一种故障比异常更让人头疼。它没有Traceback没有明显报错CPU 可能也不高日志甚至停留在一句看似正常的开始处理任务……然后程序就再也没有然后了。服务进程还活着线程也还活着请求却迟迟不返回重启以后暂时恢复运行一段时间又再次“假死”。这类问题背后一个非常典型的原因就是——死锁Deadlock。Python 的threading模块让多线程开发非常方便但多个线程共享内存也意味着它们可能同时竞争Lock、RLock、Condition、Semaphore 等同步资源。当前 Python 官方文档明确说明一个线程获取普通Lock后其他再次获取该锁的操作会阻塞直到锁被释放acquire()也支持超时等待。(Python documentation)真正危险的是如果持有锁的人也正在等待另一个永远不会释放给它的锁程序就可能永久僵住。本文不只解释“什么叫死锁”而是从 Python 实战角度完整回答Python 死锁是怎么产生的如何快速判断程序是慢还是死锁怎样抓取所有线程的调用栈如何根据线程栈还原锁依赖关系Lock和RLock到底怎么选如何通过锁顺序、超时、消息队列等方式从设计层消灭死锁线上程序偶发卡死时应该按照什么顺序排查如果你正在学习 Python 并发编程这是一份实战型 Python 教程如果你已经维护生产系统它也可以直接作为一份死锁排障清单。一、什么是死锁先来看一个现实中的例子。有两个人小王拿着会议室钥匙等待投影仪。 小李拿着投影仪等待会议室钥匙。如果双方都不愿意先释放自己手中的资源小王 → 等小李 小李 → 等小王两个人就会永远等待。程序里的死锁完全类似。假设Thread-A 已获得 lock_a Thread-B 已获得 lock_b接下来Thread-A 等待 lock_b Thread-B 等待 lock_a形成Thread-A │ ▼ lock_b ▲ │ Thread-B │ ▼ lock_a ▲ │ └──────── Thread-A这就是一个循环等待链。二、一个可以复现的 Python 死锁看看下面的代码importthreadingimporttime lock_athreading.Lock()lock_bthreading.Lock()defworker_a():print(A: 获取 lock_a)withlock_a:time.sleep(0.5)print(A: 等待 lock_b)withlock_b:print(A: 完成)defworker_b():print(B: 获取 lock_b)withlock_b:time.sleep(0.5)print(B: 等待 lock_a)withlock_a:print(B: 完成)t1threading.Thread(targetworker_a,nameworker-a)t2threading.Thread(targetworker_b,nameworker-b)t1.start()t2.start()t1.join()t2.join()print(程序结束)运行以后很可能看到A: 获取 lock_a B: 获取 lock_b A: 等待 lock_b B: 等待 lock_a然后程序停止继续执行。此时worker-a 持有 lock_a ↓ 等待 lock_b worker-b 持有 lock_b ↓ 等待 lock_a双方都无法继续。主线程又执行了t1.join()t2.join()于是主线程也在等待它们退出。最终整个程序看起来像“卡死”了一样。Python 官方甚至专门禁止线程join()自己因为这本身就会造成死锁并会抛出RuntimeError。(Python documentation)三、死锁成立通常需要什么条件理解死锁时可以记住四个经典条件。1. 互斥某个资源同一时刻只能被一个线程持有。例如lockthreading.Lock()线程 A 获得锁后线程 B 只能等待。2. 持有并等待线程拿着一个资源同时等待另一个资源。例如withlock_a:# 已经持有 Awithlock_b:...如果拿不到lock_b线程不会自动释放lock_a。3. 资源不能被强制抢走正常情况下别人不能随意把线程正在使用的资源夺走。因此等待线程只能继续等。4. 循环等待最终形成A 等 B B 等 C C 等 A死锁真正麻烦的往往就是这个环。所以排查死锁时最关键的问题不是“哪个线程卡住了”而是“它在等待谁那个线程又在等待谁”把等待关系连起来问题通常就清楚了。四、第一类常见死锁锁顺序不一致刚才的示例本质上就是线程 A lock_a → lock_b 线程 B lock_b → lock_a解决办法非常经典统一锁获取顺序。比如团队规定任何地方都必须 lock_a → lock_b修改defworker_a():withlock_a:withlock_b:do_something()defworker_b():withlock_a:withlock_b:do_other_thing()这样即使两个线程同时执行Thread-A → lock_a Thread-B → 等待 lock_a Thread-A → lock_b Thread-A → 完成并释放 Thread-B → 获得 lock_a循环等待自然消失。因此多锁系统中最好建立明确的Lock Ordering例如user_lock ↓ order_lock ↓ inventory_lock ↓ payment_lock整个项目统一遵守。这条规范往往比“出了问题再调试”便宜得多。五、第二类死锁自己把自己锁住来看importthreading lockthreading.Lock()defouter():withlock:inner()definner():withlock:print(Hello)outer()问题在哪outer()已经获取lock随后调用inner()。inner()又尝试withlock:普通threading.Lock并不是可重入锁。于是线程自己持有 lock ↓ 自己再次等待 lock ↓ 没人能够释放形成自锁。六、RLock 能解决什么如果业务设计确实需要同一个线程重复进入受保护区域可以使用threading.RLock()例如importthreading lockthreading.RLock()defouter():withlock:inner()definner():withlock:print(正常执行)outer()RLock会记录锁的拥有线程 递归获取层数同一个线程可以重复获取它但获取多少次就必须相应释放多少次。官方文档因此建议优先通过with使用RLock以减少获取、释放次数不匹配导致的问题。(Python documentation)但是有一个很重要的误区RLock ≠ 万能死锁修复器它主要解决同一个线程重复进入同一把锁如果问题是线程 A拿着 X 等 Y 线程 B拿着 Y 等 X换成RLock一样可能死锁。七、真正遇到“程序卡住”时先别急着改代码线上排查最忌讳的是程序卡住 ↓ 怀疑死锁 ↓ 凭感觉修改锁 ↓ 重启这样很容易把最宝贵的现场毁掉。正确顺序应该是程序卡住 ↓ 保存现场 ↓ 抓所有线程栈 ↓ 找到等待位置 ↓ 还原锁依赖 ↓ 找到循环等待 ↓ 再修改代码其中最重要的一步就是抓线程栈。八、神器一faulthandlerPython 自带了一个非常实用但经常被忽略的模块faulthandler它可以在超时、信号或者故障发生时输出 Python traceback而且官方明确说明其实现能够在 Python 发生死锁时转储调用栈。(Python documentation)最简单的方式importfaulthandler faulthandler.enable()也可以启动程序时直接python-Xfaulthandler app.py官方还支持通过PYTHONFAULTHANDLER环境变量启用。(Python documentation)九、让程序卡住 10 秒就自动打印线程栈排查偶发死锁我非常推荐importfaulthandler faulthandler.dump_traceback_later(timeout10,repeatTrue)含义是10 秒后输出 traceback 如果 repeatTrue 以后每隔 10 秒继续输出于是如果程序真的卡死你可能看到Thread worker-a: File demo.py, line 16, in worker_a with lock_b: Thread worker-b: File demo.py, line 27, in worker_b with lock_a: Thread MainThread: File demo.py, line 38, in module t1.join()这时几乎已经破案worker-a → 等 lock_b worker-b → 等 lock_a MainThread → 等 worker-a如果连续几次 dump 都停在相同位置死锁嫌疑就非常高。faulthandler的 traceback 默认输出到sys.stderr也可以指定日志文件。官方当前实现的转储有帧数和线程数上限因此超大规模线程程序仍需要配合其他诊断方法。(Python documentation)十、神器二sys._current_frames()如果需要自己编写诊断工具可以使用sys._current_frames()它会返回线程 ID → 当前栈顶 framePython 官方甚至直接指出这个函数特别适合调试死锁因为它不需要被死锁线程主动配合。(Python documentation)可以写一个简单的线程栈检查器importsysimportthreadingimporttracebackdefdump_threads():framessys._current_frames()forthreadinthreading.enumerate():print(f\n f{thread.name}fid{thread.ident}fnative_id{thread.native_id}f )frameframes.get(thread.ident)ifframe:traceback.print_stack(frame)调用dump_threads()就可以看到活跃线程分别卡在哪里。threading.enumerate()会返回当前活跃的Thread对象而get_native_id()/Thread.native_id可以帮助把 Python 线程和操作系统线程对应起来。(Python documentation)这在生产环境中非常实用。十一、拿到线程栈后怎么看不要从第一行开始漫无目的地读。重点搜索这些位置lock.acquire with lock Condition.wait Event.wait Semaphore.acquire queue.get Thread.join Future.result然后建立一张表Thread已持有正在等待worker-alock_alock_bworker-block_block_amain无worker-a接着画等待图worker-a │ ▼ lock_b │ ▼ worker-b │ ▼ lock_a │ └────→ worker-a只要形成闭环就找到了死锁链条。这比单纯盯着日志有效得多。十二、给 acquire() 设置 timeout避免“永久等待”默认lock.acquire()可能无限等待。而 Python 的Lock.acquire()原生支持lock.acquire(timeout...)如果超过指定时间仍没有获得锁会返回False。(Python documentation)例如importloggingiflock.acquire(timeout3):try:process()finally:lock.release()else:logging.error(获取 lock 超时疑似锁竞争或死锁)这比lock.acquire()然后永久挂住更容易诊断。不过要注意timeout 不是死锁设计的替代品。它主要用于失败保护 故障暴露 系统降级真正的解决方案仍然应该消除循环锁依赖。十三、不要在持锁状态下做慢 I/O真实系统中经常出现withlock:update_state()requests.get(url)write_database()send_message()问题是 HTTP、数据库、RPC 都可能非常慢。如果请求等待 20 秒lock 也被占用 20 秒其他几十个线程可能全部堵住。这未必是严格意义上的死锁却会表现得和死锁非常相似线程池耗尽 请求越来越慢 最终服务雪崩更好的设计通常是withlock:snapshotbuild_snapshot()resultslow_network_call(snapshot)withlock:update_result(result)即锁内只维护共享状态 锁外执行慢操作当然如果整个操作必须满足严格事务性还应该重新设计业务边界而不是为了缩短锁时间破坏数据一致性。十四、Condition 用错也可能造成“永久等待”例如condition.wait()如果负责通知的线程condition.notify()永远没有执行等待者就可能一直睡下去。对于带业务条件的场景更推荐withcondition:condition.wait_for(lambda:task_ready,timeout5)官方Condition.wait_for()支持 predicate 和 timeout用来等待某个条件变为真。(Python documentation)工程上还要同时检查谁负责修改 predicate 修改时有没有持有正确的 Condition 修改后有没有 notify / notify_all 等待方是否考虑超时和退出条件很多所谓“死锁”最终其实是条件通知丢失或者状态设计错误。十五、生产环境建议给线程和锁“起名字”线程不要全叫Thread-1 Thread-2 Thread-3而应该threading.Thread(targetconsume_order,nameorder-consumer-01)Python 当前版本还会在支持的平台上把线程名称设置到操作系统线程名称中这会让系统级诊断更加方便。(Python documentation)日志则最好至少包含时间 线程名 线程 ID 业务 ID 锁名 操作 耗时例如16:01:03 worker-7 order83921 acquire inventory_lock 16:01:03 worker-7 acquired inventory_lock 16:01:08 worker-7 waiting payment_lock出了问题你就可以直接恢复锁依赖关系。十六、如何让死锁更容易在测试环境复现最难处理的死锁往往是生产环境一个月出现一次 本地怎么都复现不了不要依赖sleep()碰运气。可以使用threading.Barrier人为控制两个线程同时到达危险位置importthreading lock_athreading.Lock()lock_bthreading.Lock()barrierthreading.Barrier(2)defworker_a():withlock_a:barrier.wait()lock_b.acquire()defworker_b():withlock_b:barrier.wait()lock_a.acquire()Barrier本身就是让固定数量线程相互等待直到所有参与线程都抵达屏障以后再继续执行的同步原语。(Python documentation)这样可以把一个“偶尔死锁”的问题变成“稳定复现”而稳定复现是解决并发 Bug 的巨大胜利。十七、工程上如何从源头减少死锁我通常推荐下面六条 Python 最佳实践。第一统一锁顺序项目规定A → B → C任何代码都不能C → A第二尽量减少同时持有多把锁如果设计可以从拿 A 拿 B 修改 释放 B 释放 A重构成锁 A → 获取快照 释放 A 计算 锁 B → 提交结果 释放 B通常更容易推理。第三使用with推荐withlock:update()而不是lock.acquire()update()lock.release()因为update()一旦抛异常手写释放逻辑非常容易遗漏。官方也明确推荐对Lock、RLock等同步对象使用上下文管理协议。(Python documentation)第四等待操作尽量考虑 timeout包括lock.acquire(timeout5)condition.wait(timeout5)thread.join(timeout5)目的不是“用超时治愈死锁”而是避免系统无声地永久冻结。第五减少共享可变状态如果线程之间本质上是在传递任务Producer ↓ Consumer优先考虑queue.Queue而不是自己组合list Lock Condition EventPython 官方将queue明确定位为线程间安全交换数据的接口。(Python documentation)第六把锁当作“架构资源”锁不是哪里报错就在哪里补一个而应该像数据库事务一样被设计。一个成熟项目应该能够回答这把锁保护什么数据 谁可以获取它 允许和哪些锁同时持有 获取顺序是什么 最大持有时间是多少 是否允许跨网络 I/O 持锁 发生超时时如何降级回答不了这些问题系统规模扩大以后并发风险通常会迅速增长。十八、一套可以直接使用的死锁排查流程当 Python 服务疑似死锁时我建议按照下面的顺序处理① 不要立即重启 ↓ ② 保存当前日志与进程信息 ↓ ③ dump 所有线程 traceback ↓ ④ 找长期停留在 acquire/wait/join 的线程 ↓ ⑤ 写出“已持有锁 → 等待锁” ↓ ⑥ 构造 Wait-For Graph ↓ ⑦ 查找循环依赖 ↓ ⑧ 检查锁获取顺序 ↓ ⑨ 检查嵌套调用是否重复获取 Lock ↓ ⑩ 检查 Condition/Event/Queue 等待关系 ↓ ⑪ 在测试环境构造稳定复现 ↓ ⑫ 重新设计锁顺序或共享状态 ↓ ⑬ 加 timeout、监控和现场转储能力 ↓ ⑭ 写回归测试千万不要只做重启 → 好了 → 工单关闭因为死锁通常不是服务器“状态不好”。它是程序同步关系中的结构性问题。十九、死锁、活锁、饥饿不要混为一谈最后再区分三个很容易混淆的概念。死锁 Deadlock大家都在等待 谁也无法继续活锁 Livelock大家都在不断行动 却始终完成不了工作比如两个线程不断礼让资源你先 不你先 还是你先 ……CPU 可能还挺忙。饥饿 Starvation系统整体一直在运行 但某个线程长期得不到资源例如高优先级任务不断抢占资源使某个普通线程始终没有机会执行。所以程序没响应 ≠ 一定死锁诊断时一定要抓实际线程状态而不是只凭现象猜测。二十、总结排查死锁的核心不是“找卡住的线程”而是找等待环Python 死锁看起来复杂其实可以浓缩为一句话某些执行单元占着别人需要的资源同时又等待别人手中的资源并最终形成一个无法自行打破的等待环。因此真正有效的排障思路是抓线程栈 ↓ 找到等待点 ↓ 找到资源拥有者 ↓ 继续追踪拥有者正在等待什么 ↓ 构造依赖关系 ↓ 寻找闭环工具层面建议至少熟练掌握faulthandler sys._current_frames()threading.enumerate()threading.Lock threading.RLock threading.Condition threading.Barrier其中sys._current_frames()尤其值得记住——Python 官方文档直接把死锁调试列为它最有价值的用途之一。(Python documentation)而从架构层面更值得记住四条原则统一锁顺序 缩小临界区 减少共享状态 不要无限等待优秀的 Python 并发程序不是因为开发者特别擅长“在死锁发生后救火”而是因为从设计开始就让等待关系足够简单、透明、可观测。当有一天你的线上 Python 服务突然安静下来没有异常、没有报错、没有响应希望你不会第一时间只想到“重启试试。”而是会想到“先把所有线程栈留下来。”那一刻你已经从“会使用多线程”真正迈向了“能够驾驭并发系统”。延伸阅读继续深入 Python 并发编程可以重点阅读 Python 官方threading文档其中详细介绍了Lock、RLock、Condition、Semaphore、Event 与 Barrier 等同步原语。(Python documentation)排查程序卡死、崩溃和超时问题可以重点阅读官方faulthandler文档。(Python documentation)需要理解线程栈现场抓取时则建议阅读sys._current_frames()的官方说明。(Python documentation)**互动思考**你在实际 Python 项目中遇到过“程序不报错但就是不往下运行”的情况吗最后发现的是互斥锁死锁、线程池耗尽、Condition 等待还是网络 I/O 卡住欢迎把你的排查思路和典型案例记录下来——并发问题最宝贵的学习材料往往正来自这些真实的故障现场。