跳到内容

12.2 死锁现场诊断

地心控制台突然安静下来:没有报错,也没有新结果。两名维修线程各自握着一把锁,又都在等对方松手。此时最诱人的动作是重启;它也恰好会抹掉“谁在等谁”这条最值钱的线索。

12.1 死锁为何形成给出了等待环与 Coffman 条件。这一课把那张等待图还原到真实现场:哪些线程在等待、它们从哪里进入等待、谁持有相关资源。先构造一个可重复的两锁死锁,再分别说明 C、Java 与 Python 能拿到什么证据。实验必须在隔离环境中运行,并用 timeout 结束。

1. 构造一个确定的等待环

仅靠 sleep 调整调度时序并不可靠。下面使用 barrier,确保两个线程都拿到第一把锁后,才开始申请第二把锁:

c
#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 的环境中编译,并给它一个外部截止时间:

bash
cc -std=c17 -g -O0 -Wall -Wextra -pthread deadlock.c
timeout 2 ./a.out
# A holds first
# B holds second
# timeout 结束进程,退出码为 124

barrier 只负责把时序固定下来。通过 barrier 后,A 持有 first 等待 second,B 持有 second 等待 first,所以两个 pthread_join 都无法完成。

修复不是“多睡一会儿”,而是让两个函数都按 first → second 获取锁。若这只是最小复现,还要回到真实代码检查同一组锁是否存在第三条、第四条获取路径。

2. C/C++:先冻结现场,再拿完整线程栈

两名维修线程已经停住。此时需要的是每条 thread 的 stack,而不是只看“CPU 使用率为零”。栈帧能告诉你它卡在哪次 lock 调用,结合锁地址和其他线程位置才能还原等待边。

保留调试符号后,可以让 GDB 附加到仍在运行的进程:

bash
gdb -p PID

进入 GDB 后,先抓全部线程:

text
(gdb) set pagination off
(gdb) info threads
(gdb) thread apply all bt

常见现象是多个线程停在 mutex 的底层等待函数里,调用栈更上层显示各自调用 pthread_mutex_lock 的业务位置。主线程可能只是卡在 pthread_join、任务队列或请求等待上,它通常不是环的根。

不要依赖 pthread_mutex_t 的私有字段来写诊断逻辑。它是实现定义的内部结构,字段与含义可能随 libc、架构和 mutex 类型变化。更可靠的证据来自:

  • 每个线程的完整调用栈;
  • 源码中的锁获取路径;
  • 应用自己记录的锁名、持有者与等待开始时间;
  • 同一时刻的调度、请求与资源日志。

若不能长时间暂停线上进程,可以在批准的运维流程中生成 core 或一次性线程转储,再离线分析。附加调试器本身会暂停线程,也可能改变时间敏感问题,因此先评估服务影响。

从栈还原等待图

对每个阻塞线程记录一行:

线程已持有正在等待获取位置
Afirstsecondlock_first_then_second
Bsecondfirstlock_second_then_first

再把“等待的锁”指向“持有该锁的线程”。只看两条栈经常还不够:持有者可能正在等条件变量、I/O、另一个进程或远端响应,等待链需要继续展开,直到找到能前进的端点或闭环。

3. Java:线程转储能提供更多锁信息

HotSpot 进程通常可以通过 JDK 工具取得线程转储,例如:

bash
jcmd PID Thread.print -l
# 或
jstack -l PID

转储会列出 Java 线程状态、调用栈,以及 monitor 或 java.util.concurrent synchronizer 的相关信息。若检测到 Java 层 monitor 环,工具可能直接报告参与死锁的线程。

程序内部也可以使用管理接口:

java
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 在收到信号或超时后打印线程栈:

python
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 会记录运行中观察到的锁类别与获取依赖。当新依赖与已有依赖组合成循环时,它可以报告“可能的循环锁依赖”。这不要求系统此刻已经有两条线程互相卡死;它是在告诉开发者,已经观察到足以组成危险环的顺序。

它的价值依赖覆盖率:从未运行到的路径就不会产生相应依赖。锁类别标注错误、关闭相关内核调试配置或只在低覆盖环境运行,也会削弱结果。

用户态项目可以借鉴同一思路:

  1. 给锁定义稳定的类别或等级;
  2. 在线程本地记录当前已持有的锁;
  3. 每次加锁时验证等级只会上升,或记录依赖边;
  4. 在测试中把违反规则变成明确失败。

动态检查能发现实际走到的反向顺序,静态分析和代码评审则能覆盖尚未运行的路径,两者互补。

7. 数据库死锁:失败的是事务,不一定是进程

数据库会维护自己的锁与等待关系。两个应用线程在操作系统看来都只是等待数据库响应,真正的环存在于数据库的行锁、范围锁或其他事务资源中。

许多数据库会检测这种环,选择一个事务作为受害者并回滚,使其他事务继续。应用收到死锁错误后,应当:

  • 回滚并重试整个事务边界,而不是从中间一条语句继续;
  • 使用有上限、带抖动的退避;
  • 保证外部副作用不会因重试重复发生;
  • 缩短事务,按一致顺序访问表和对象;
  • 保存数据库给出的死锁报告,用真实锁对象还原环。

具体错误码、受害者选择和诊断命令由数据库产品及版本决定,应以当前官方文档为准。

8. 远端等待会把环藏到多台机器

服务 A 持有本地资源调用 B,B 又持有另一资源回调 A,这同样可以形成等待环。单机线程转储只能看到“等待网络”,看不到另一端正在等待谁。

这类问题需要跨服务请求 ID、分布式追踪与统一截止时间来拼接因果链。超时能释放部分资源,但如果旧请求在超时后仍可能写入共享存储,还需要租约或 fencing token 等机制拒绝过期持有者。不能把单机 mutex 的“强行解锁”类比到分布式系统;资源所有权与失败模型已经不同。

9. 一份可执行的现场流程

发现无进展时,可以按以下顺序缩小范围:

  1. 确认现象:是吞吐归零、单个请求卡住,还是尾延迟上升?
  2. 保留现场:记录时间、版本、PID、请求 ID、负载与最近变更。
  3. 一次抓全:取得所有线程栈,而不是逐个查看后让时刻漂移。
  4. 标注资源:为阻塞点写出“持有什么、等什么、谁可能推进”。
  5. 追到端点:沿本地锁、条件、I/O、数据库和远端调用继续展开。
  6. 找环或缺失事件:区分锁环、遗漏通知、持锁者阻塞和单纯拥塞。
  7. 恢复服务:按既定策略回滚、终止或重启,并保留诊断材料。
  8. 修复结构:统一顺序、缩小持有范围或补上检测与超时。
  9. 写回归测试:用 barrier、故障注入或锁顺序检查放大原竞态。

10. 小结

死锁诊断的核心是从瞬时堆栈恢复资源依赖,而不是在等待函数名里停下:

  • C/C++ 线程栈需要结合源码与锁语义判断持有者;
  • Java 工具能显示更多 synchronizer 信息,但看不到系统外部的完整环;
  • Python 栈转储能定位停顿位置,标准锁却不会自动生成所有权图;
  • lockdep 类检查适合在测试期发现危险顺序;
  • 数据库和远端服务需要各自的等待图、事务报告与追踪上下文。

下一章进入文件系统与崩溃恢复。并发控制解决“谁先改”,持久化协议还要回答“改到一半断电怎么办”。

Built with VitePress | Software Systems Atlas