跳到内容

第 3 卷 / 第 12 章 / 第 2 课

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>
#include <string.h>

static pthread_mutex_t first = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t second = PTHREAD_MUTEX_INITIALIZER;
static pthread_barrier_t gate;

static void check_pthread(int error, const char *operation) {
    if (error != 0) {
        fprintf(stderr, "%s: %s\n", operation, strerror(error));
        exit(EXIT_FAILURE);
    }
}

static void meet_after_first_lock(void) {
    int error = pthread_barrier_wait(&gate);
    if (error != PTHREAD_BARRIER_SERIAL_THREAD) {
        check_pthread(error, "barrier wait");
    }
}

static void *lock_first_then_second(void *unused) {
    (void)unused;
    check_pthread(pthread_mutex_lock(&first), "lock first");
    puts("A holds first");
    meet_after_first_lock();
    check_pthread(pthread_mutex_lock(&second), "lock second");
    return NULL;
}

static void *lock_second_then_first(void *unused) {
    (void)unused;
    check_pthread(pthread_mutex_lock(&second), "lock second");
    puts("B holds second");
    meet_after_first_lock();
    check_pthread(pthread_mutex_lock(&first), "lock first");
    return NULL;
}

int main(void) {
    pthread_t a;
    pthread_t b;
    if (setvbuf(stdout, NULL, _IONBF, 0) != 0) return EXIT_FAILURE;

    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;
    }

    check_pthread(pthread_join(a, NULL), "join a");
    check_pthread(pthread_join(b, NULL), "join b");
    return EXIT_SUCCESS;
}

保存为 deadlock.c,在支持 POSIX barrier 的环境中编译,并用 GNU timeout 给它一个外部截止时间:

bash
cc -std=c17 -g -O0 -Wall -Wextra -Werror -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 都无法完成。

修复时,两次 pthread_create 都改用下面的 ordered_worker,删除 gate、meet_after_first_lock 及 barrier 初始化。尤其不能保留“持有 first 后等另一线程到达”的 barrier,否则另一线程根本拿不到 first,还是会卡住。主线程 join 后再销毁两把 mutex。这里只修复这条复现路径;实际应用还要检查其他获取路径。

c
static void *ordered_worker(void *unused) {
    (void)unused;
    check_pthread(pthread_mutex_lock(&first), "lock first");
    check_pthread(pthread_mutex_lock(&second), "lock second");
    check_pthread(pthread_mutex_unlock(&second), "unlock second");
    check_pthread(pthread_mutex_unlock(&first), "unlock first");
    return NULL;
}

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 环,工具可能直接报告参与死锁的线程。

下面的 DeadlockProbe.java 可用 javac 编译,并在待诊断的 JVM 内调用 dumpDeadlocks。另起一个 JVM 调用它只会检查那个新进程,不会自动连接上面的 PID。

java
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;

public final class DeadlockProbe {
    public static int dumpDeadlocks() {
        ThreadMXBean bean = ManagementFactory.getThreadMXBean();
        boolean monitors = bean.isObjectMonitorUsageSupported();
        boolean synchronizers = bean.isSynchronizerUsageSupported();
        long[] ids = synchronizers
                ? bean.findDeadlockedThreads()
                : bean.findMonitorDeadlockedThreads();
        if (ids == null) return 0;
        ThreadInfo[] infos = bean.getThreadInfo(ids, monitors, synchronizers);
        for (ThreadInfo info : infos) {
            if (info != null) System.err.println(info);
        }
        return ids.length;
    }
}

Java 21 的 ThreadMXBean 面向 platform thread,不监控 virtual thread;findDeadlockedThreads 检查 monitor 与 ownable synchronizer 环,支持范围还取决于 VM。ThreadInfo.toString 可能截断深层栈,完整分析需遍历 getStackTrace 与已持有锁信息。没有报告也不能证明整个系统没有死锁。例如 Java 线程若持锁等待本地代码、文件锁、数据库事务或网络服务,本地 JVM 的锁图就不完整。

4. Python:线程栈不是锁所有权图 ​

Python 可以用 faulthandler 在收到信号或超时后打印线程栈:

python
import faulthandler
import sys

faulthandler.dump_traceback_later(
    timeout=10,
    repeat=False,
    file=sys.stderr,
)

这个片段只安排一次十秒后的诊断,不会等待十秒、制造死锁或终止程序。嵌入持续运行的服务才有机会触发;不再需要时调用 cancel_dump_traceback_later,并保持输出文件打开。

这能回答“各线程停在哪”,却不会自动给出 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