12.2 死锁现场诊断
地心控制台突然安静下来:没有报错,也没有新结果。两名维修线程各自握着一把锁,又都在等对方松手。此时最诱人的动作是重启;它也恰好会抹掉“谁在等谁”这条最值钱的线索。
12.1 死锁为何形成给出了等待环与 Coffman 条件。这一课把那张等待图还原到真实现场:哪些线程在等待、它们从哪里进入等待、谁持有相关资源。先构造一个可重复的两锁死锁,再分别说明 C、Java 与 Python 能拿到什么证据。实验必须在隔离环境中运行,并用 timeout 结束。
1. 构造一个确定的等待环
仅靠 sleep 调整调度时序并不可靠。下面使用 barrier,确保两个线程都拿到第一把锁后,才开始申请第二把锁:
#define _XOPEN_SOURCE 700
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
static pthread_mutex_t first = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t second = PTHREAD_MUTEX_INITIALIZER;
static pthread_barrier_t gate;
static void *lock_first_then_second(void *unused) {
(void)unused;
pthread_mutex_lock(&first);
puts("A holds first");
pthread_barrier_wait(&gate);
pthread_mutex_lock(&second);
return NULL;
}
static void *lock_second_then_first(void *unused) {
(void)unused;
pthread_mutex_lock(&second);
puts("B holds second");
pthread_barrier_wait(&gate);
pthread_mutex_lock(&first);
return NULL;
}
int main(void) {
pthread_t a;
pthread_t b;
setvbuf(stdout, NULL, _IONBF, 0);
if (pthread_barrier_init(&gate, NULL, 2) != 0) {
return EXIT_FAILURE;
}
if (pthread_create(&a, NULL, lock_first_then_second, NULL) != 0
|| pthread_create(&b, NULL, lock_second_then_first, NULL) != 0) {
return EXIT_FAILURE;
}
pthread_join(a, NULL);
pthread_join(b, NULL);
return EXIT_SUCCESS;
}在支持 POSIX barrier 的环境中编译,并给它一个外部截止时间:
cc -std=c17 -g -O0 -Wall -Wextra -pthread deadlock.c
timeout 2 ./a.out
# A holds first
# B holds second
# timeout 结束进程,退出码为 124barrier 只负责把时序固定下来。通过 barrier 后,A 持有 first 等待 second,B 持有 second 等待 first,所以两个 pthread_join 都无法完成。
修复不是“多睡一会儿”,而是让两个函数都按 first → second 获取锁。若这只是最小复现,还要回到真实代码检查同一组锁是否存在第三条、第四条获取路径。
2. C/C++:先冻结现场,再拿完整线程栈
两名维修线程已经停住。此时需要的是每条 thread 的 stack,而不是只看“CPU 使用率为零”。栈帧能告诉你它卡在哪次 lock 调用,结合锁地址和其他线程位置才能还原等待边。
保留调试符号后,可以让 GDB 附加到仍在运行的进程:
gdb -p PID进入 GDB 后,先抓全部线程:
(gdb) set pagination off
(gdb) info threads
(gdb) thread apply all bt常见现象是多个线程停在 mutex 的底层等待函数里,调用栈更上层显示各自调用 pthread_mutex_lock 的业务位置。主线程可能只是卡在 pthread_join、任务队列或请求等待上,它通常不是环的根。
不要依赖 pthread_mutex_t 的私有字段来写诊断逻辑。它是实现定义的内部结构,字段与含义可能随 libc、架构和 mutex 类型变化。更可靠的证据来自:
- 每个线程的完整调用栈;
- 源码中的锁获取路径;
- 应用自己记录的锁名、持有者与等待开始时间;
- 同一时刻的调度、请求与资源日志。
若不能长时间暂停线上进程,可以在批准的运维流程中生成 core 或一次性线程转储,再离线分析。附加调试器本身会暂停线程,也可能改变时间敏感问题,因此先评估服务影响。
从栈还原等待图
对每个阻塞线程记录一行:
| 线程 | 已持有 | 正在等待 | 获取位置 |
|---|---|---|---|
| A | first | second | lock_first_then_second |
| B | second | first | lock_second_then_first |
再把“等待的锁”指向“持有该锁的线程”。只看两条栈经常还不够:持有者可能正在等条件变量、I/O、另一个进程或远端响应,等待链需要继续展开,直到找到能前进的端点或闭环。
3. Java:线程转储能提供更多锁信息
HotSpot 进程通常可以通过 JDK 工具取得线程转储,例如:
jcmd PID Thread.print -l
# 或
jstack -l PID转储会列出 Java 线程状态、调用栈,以及 monitor 或 java.util.concurrent synchronizer 的相关信息。若检测到 Java 层 monitor 环,工具可能直接报告参与死锁的线程。
程序内部也可以使用管理接口:
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] ids = bean.findDeadlockedThreads();
if (ids != null) {
ThreadInfo[] infos = bean.getThreadInfo(ids, true, true);
for (ThreadInfo info : infos) {
System.err.println(info);
}
}findDeadlockedThreads 能覆盖一部分 Java monitor 与 ownable synchronizer 死锁,但不能证明整个系统没有死锁。例如 Java 线程若持锁等待本地代码、文件锁、数据库事务或网络服务,本地 JVM 的锁图就不完整。
4. Python:线程栈不是锁所有权图
Python 可以用 faulthandler 在收到信号或超时后打印线程栈:
import faulthandler
import sys
faulthandler.dump_traceback_later(
timeout=10,
repeat=False,
file=sys.stderr,
)这能回答“各线程停在哪”,却不会自动给出 threading.Lock 的完整持有者关系。CPython 的 GIL 也不能消除应用死锁:线程可能持有应用锁后等待另一个锁、条件、队列或 I/O;阻塞操作还可能释放 GIL。
因此 Python 服务也需要在高层记录资源语义,例如任务 ID、锁名、队列、请求截止时间和当前阶段。不要把周期性打印堆栈的第三方工具等同于死锁证明器。
5. 条件变量也会制造“看起来像死锁”的停滞
条件变量等待会原子地释放关联 mutex,并在返回前重新获得它。因此“线程在 pthread_cond_wait 中”本身不表示它持有该 mutex 睡眠。
真正的问题可能是:
- 修改谓词后忘记 signal/broadcast;
- 等待者与通知者保护的不是同一个谓词;
- 通知发生了,但等待者没有在循环中重新检查条件;
- 生产者已退出,消费者仍相信未来会有数据;
- 关闭路径没有广播终止状态。
诊断时同时记录谓词值和生产者生命周期。等待图只画 mutex,而忽略业务条件,会把协议错误误判为经典锁环。
6. lockdep:在测试期发现潜在反向顺序
Linux 内核的 lockdep 会记录运行中观察到的锁类别与获取依赖。当新依赖与已有依赖组合成循环时,它可以报告“可能的循环锁依赖”。这不要求系统此刻已经有两条线程互相卡死;它是在告诉开发者,已经观察到足以组成危险环的顺序。
它的价值依赖覆盖率:从未运行到的路径就不会产生相应依赖。锁类别标注错误、关闭相关内核调试配置或只在低覆盖环境运行,也会削弱结果。
用户态项目可以借鉴同一思路:
- 给锁定义稳定的类别或等级;
- 在线程本地记录当前已持有的锁;
- 每次加锁时验证等级只会上升,或记录依赖边;
- 在测试中把违反规则变成明确失败。
动态检查能发现实际走到的反向顺序,静态分析和代码评审则能覆盖尚未运行的路径,两者互补。
7. 数据库死锁:失败的是事务,不一定是进程
数据库会维护自己的锁与等待关系。两个应用线程在操作系统看来都只是等待数据库响应,真正的环存在于数据库的行锁、范围锁或其他事务资源中。
许多数据库会检测这种环,选择一个事务作为受害者并回滚,使其他事务继续。应用收到死锁错误后,应当:
- 回滚并重试整个事务边界,而不是从中间一条语句继续;
- 使用有上限、带抖动的退避;
- 保证外部副作用不会因重试重复发生;
- 缩短事务,按一致顺序访问表和对象;
- 保存数据库给出的死锁报告,用真实锁对象还原环。
具体错误码、受害者选择和诊断命令由数据库产品及版本决定,应以当前官方文档为准。
8. 远端等待会把环藏到多台机器
服务 A 持有本地资源调用 B,B 又持有另一资源回调 A,这同样可以形成等待环。单机线程转储只能看到“等待网络”,看不到另一端正在等待谁。
这类问题需要跨服务请求 ID、分布式追踪与统一截止时间来拼接因果链。超时能释放部分资源,但如果旧请求在超时后仍可能写入共享存储,还需要租约或 fencing token 等机制拒绝过期持有者。不能把单机 mutex 的“强行解锁”类比到分布式系统;资源所有权与失败模型已经不同。
9. 一份可执行的现场流程
发现无进展时,可以按以下顺序缩小范围:
- 确认现象:是吞吐归零、单个请求卡住,还是尾延迟上升?
- 保留现场:记录时间、版本、PID、请求 ID、负载与最近变更。
- 一次抓全:取得所有线程栈,而不是逐个查看后让时刻漂移。
- 标注资源:为阻塞点写出“持有什么、等什么、谁可能推进”。
- 追到端点:沿本地锁、条件、I/O、数据库和远端调用继续展开。
- 找环或缺失事件:区分锁环、遗漏通知、持锁者阻塞和单纯拥塞。
- 恢复服务:按既定策略回滚、终止或重启,并保留诊断材料。
- 修复结构:统一顺序、缩小持有范围或补上检测与超时。
- 写回归测试:用 barrier、故障注入或锁顺序检查放大原竞态。
10. 小结
死锁诊断的核心是从瞬时堆栈恢复资源依赖,而不是在等待函数名里停下:
- C/C++ 线程栈需要结合源码与锁语义判断持有者;
- Java 工具能显示更多 synchronizer 信息,但看不到系统外部的完整环;
- Python 栈转储能定位停顿位置,标准锁却不会自动生成所有权图;
- lockdep 类检查适合在测试期发现危险顺序;
- 数据库和远端服务需要各自的等待图、事务报告与追踪上下文。
下一章进入文件系统与崩溃恢复。并发控制解决“谁先改”,持久化协议还要回答“改到一半断电怎么办”。