并发与死锁现场调试:从线程栈、锁拥有者到等待图
一个接口突然全部超时,CPU 很低,进程也没有崩溃。单次线程转储里,大量工作线程都停在加锁函数,于是团队准备把锁替换成并发容器;第二次采样却显示锁拥有者正在等待数据库响应。真正拖住系统的是慢 I/O 持锁,不是锁之间形成了环。
另一次现场更像死锁:线程 A 持有 account 等 ledger,线程 B 持有 ledger 等 account,三次采样都没有进展。只有把线程身份、锁拥有者、等待对象和时间顺序连成等待图,才能证明这是闭环。线程栈提供观察点,不自动提供因果结论;attach、断点和 stop-the-world 还可能制造一张“所有线程都没动”的假现场。采样动作也必须进入时间线。
先把线程、运行时任务和锁身份对齐
操作系统看到的是进程与原生线程;语言运行时可能再在其上调度 goroutine、虚拟线程、async task、worker 或 event loop callback。一次线程现场至少要保留采样时间、主机/容器、PID、原生 TID、运行时线程或任务 ID、线程名、当前状态、顶层栈帧、正在等待的对象和已经持有的对象。只记录线程名不够,因为线程池会复用名称;只记录运行时 ID 也不够,因为跨层取证时还要与 /proc、调试器和系统调用状态关联。
锁同样需要稳定身份。JVM thread dump 常能打印 monitor 地址与拥有者,native mutex 的内部字段则受 libc 和 ABI 影响,不能把 pthread_mutex_t 某个私有字段写成跨平台合同。Go 的 goroutine dump 能显示阻塞位置,却通常不能直接列出 sync.Mutex 的拥有者。可靠做法是组合三类证据:调试器/运行时提供的锁信息,源码中加锁顺序,以及低开销应用遥测记录的 lock_id、owner、waiter、request_id 和等待时长。
“线程死锁”和“协程死锁”描述的是不同调度层上的同一种活性失败。原生线程阻塞在 mutex、条件变量或内核等待时,线程转储通常能直接看到等待入口;协程、Future 或虚拟线程则可能已经把承载线程还给调度器,原生线程仍在正常跑,只有任务图停止推进。此时图中的节点要从 TID 换成 task/goroutine/continuation,资源也不只是一把锁,还可能是 channel、Future、队列容量或必须由另一个任务触发的事件。例如任务 A 持有异步锁等待 Future B,而完成 B 的任务又等待 A 释放该锁,仍然是闭环;一批任务都在等尚未到达的外部消息,则只有等待,没有足够证据证明死锁。诊断协程故障必须同时保存任务转储、调度器或 event loop 活性、Future/channel 状态和业务完成计数,不能因为承载线程处于 RUNNABLE 就排除死锁,也不能因为大量任务处于 await 就宣布死锁。
动手前先确认工具与目标匹配。Linux native 现场需要目标发行版提供的 GDB、匹配 libc 的线程调试支持和 ptrace 权限;JVM 使用目标 JDK 自带的 jcmd/jstack;Go 优先保留 SIGQUIT goroutine dump、受限 pprof 或匹配版本的 Delve;Python 可预先启用 faulthandler,需要原生栈时再用带 Python 扩展的 GDB;Node.js 依赖 diagnostic report、Inspector 或 Worker 诊断;Windows 则用 ProcDump 采样并在 WinDbg/CDB 中查看全部线程。工具版本、进程架构和构建身份都要随现场保存。
gdb --version
uname -srmo
cat /proc/sys/kernel/yama/ptrace_scope 2>/dev/null || true
jcmd -l 2>/dev/null || true
java -version 2>&1 | head -n 3
go version 2>/dev/null || true
python3 --version 2>/dev/null || true
node --version 2>/dev/null || true命令不存在时,从目标发行版或语言官方批准渠道安装对应调试组件;企业内网应从签名镜像仓库或离线工具包取得。安装新 JDK、libc debug 包或 GDB 可能改变分析环境,却不应改变故障进程本身。不要在故障机上升级运行时来“获得更好的线程转储”;先复制匹配工具或在隔离分析环境重建版本链。
以 Linux 分析机为例,先启用组织批准的软件源,再安装与目标架构匹配的最小组件。Debian/Ubuntu 常用 gdb、binutils、procps 和目标 libc 的调试符号,RHEL 系常用 gdb、binutils、procps-ng 以及对应 debuginfo;JVM 工具来自与目标大版本匹配的 JDK,而不是任意下载一套最新 JDK。安装后立即记录包版本与来源,调试结束再按分析机基线决定保留或卸载:
# Debian/Ubuntu 分析机;调试符号包名称随发行版仓库而异
mkdir -p evidence
sudo apt-get update
sudo apt-get install --no-install-recommends gdb binutils procps
apt-cache policy gdb binutils procps | tee evidence/debug-tools-debian.txt
# RHEL/Fedora 分析机;生产环境从批准仓库执行
sudo dnf install gdb binutils procps-ng
dnf info installed gdb binutils procps-ng | tee evidence/debug-tools-rhel.txt包管理命令成功只证明工具已落盘。还要运行 file "$(command -v gdb)"、gdb --configuration,并核对目标 ELF 架构、libc、Build ID 与单独调试信息;分析机无匹配符号时可以采集原始地址,但不能把错误符号化的函数名写成锁拥有关系。
用双锁程序制造一个可证明的环
下面的 Linux/pthreads 程序有两种构建。BROKEN_ORDER 模式让两个线程先各持一把锁,再通过 barrier 同步后请求另一把,稳定形成环;正常模式统一按 A、B 顺序取锁并完成。owner_a 与 owner_b 是实验用的最小拥有者遥测,不依赖 glibc 私有 mutex 布局。
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>
static pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t lock_b = PTHREAD_MUTEX_INITIALIZER;
static pthread_barrier_t ready;
volatile long owner_a = 0;
volatile long owner_b = 0;
static long tid(void) {
return syscall(SYS_gettid);
}
static void lock_with_owner(pthread_mutex_t *lock, volatile long *owner) {
pthread_mutex_lock(lock);
*owner = tid();
}
static void unlock_with_owner(pthread_mutex_t *lock, volatile long *owner) {
*owner = 0;
pthread_mutex_unlock(lock);
}
static void *worker_ab(void *unused) {
(void)unused;
lock_with_owner(&lock_a, &owner_a);
#ifdef BROKEN_ORDER
pthread_barrier_wait(&ready);
#endif
lock_with_owner(&lock_b, &owner_b);
puts("worker_ab completed");
unlock_with_owner(&lock_b, &owner_b);
unlock_with_owner(&lock_a, &owner_a);
return NULL;
}
static void *worker_ba(void *unused) {
(void)unused;
#ifdef BROKEN_ORDER
lock_with_owner(&lock_b, &owner_b);
pthread_barrier_wait(&ready);
lock_with_owner(&lock_a, &owner_a);
puts("worker_ba completed");
unlock_with_owner(&lock_a, &owner_a);
unlock_with_owner(&lock_b, &owner_b);
#else
lock_with_owner(&lock_a, &owner_a);
lock_with_owner(&lock_b, &owner_b);
puts("worker_ba completed");
unlock_with_owner(&lock_b, &owner_b);
unlock_with_owner(&lock_a, &owner_a);
#endif
return NULL;
}
int main(void) {
pthread_t first, second;
pthread_barrier_init(&ready, NULL, 2);
pthread_create(&first, NULL, worker_ab, NULL);
pthread_create(&second, NULL, worker_ba, NULL);
pthread_join(first, NULL);
pthread_join(second, NULL);
pthread_barrier_destroy(&ready);
return 0;
}编译故障版本并在隔离 shell 中启动:
mkdir -p build evidence/deadlock-samples
cc -g3 -O0 -pthread -DBROKEN_ORDER deadlock-demo.c \
-o build/deadlock-broken
sha256sum build/deadlock-broken | tee evidence/deadlock-broken.sha256
./build/deadlock-broken \
>evidence/deadlock-broken.stdout \
2>evidence/deadlock-broken.stderr &
demo_pid=$!
printf '%s\n' "$demo_pid" | tee evidence/deadlock.pid
sleep 1
ps -L -p "$demo_pid" -o pid,tid,stat,pcpu,wchan:28,comm
kill -0 "$demo_pid"
test ! -s evidence/deadlock-broken.stderr
if grep -q '^worker_.* completed$' evidence/deadlock-broken.stdout; then
printf 'broken build unexpectedly made progress\n' >&2
exit 1
fi预期 kill -0 成功、标准错误为空、两个 worker 都没有打印 completed,线程常在 futex 相关等待中。若进程已经退出或标准错误非空,应先排除编译、运行时和 barrier 初始化失败;若任一 worker 完成,则故障构建没有稳定复现,不能继续拿它证明死锁。wchan 只是内核等待位置,不能告诉你是哪一把业务锁,更不能单独证明环。接着用 GDB 暂停现场,并明确打印全部栈与实验 owner:
gdb -q -nx -batch -p "$demo_pid" \
-ex 'set pagination off' \
-ex 'info threads' \
-ex 'thread apply all bt' \
-ex 'print owner_a' \
-ex 'print owner_b' \
-ex 'detach' \
>evidence/deadlock-samples/sample-1.txt 2>&1预期一条 worker 栈经过 worker_ab 并阻塞在第二次 pthread_mutex_lock,另一条经过 worker_ba 阻塞在相反锁;owner_a 和 owner_b 是两个不同的 Linux TID。GDB thread ID、LWP/TID 和程序打印的 owner 要分别识别,不能把 GDB 的 2、3 直接当操作系统 TID。若 attach 报 Operation not permitted,检查同用户、dumpable、Yama、user/PID namespace、capability、LSM 和既有 tracer;不要把全局 ptrace_scope 改为宽松模式作为默认修复。
从栈与 owner 画出等待图
等待图只有两类边:线程持有资源,线程等待资源。对上面的现场,可以写成 T1 -> lock_b -> T2 -> lock_a -> T1。闭环是死锁的核心证据;大量线程都等待同一把锁,只说明争用热点,并不必然存在环。
构图时每条边都要能回到证据:等待边来自阻塞栈、运行时锁记录或应用事件;拥有边来自 thread dump、调试器对象、明确的 owner 遥测或锁实现可证明字段。若只知道线程在 pthread_mutex_lock,却不知道参数指向哪一把锁,就先在匹配符号下打印函数参数或在加锁包装层记录稳定 lock_id,不要凭函数名补出环。
一条可复核的证据链应当从构建身份走到结论,而不是从结论反挑截图。先用二进制哈希、模块 build ID/PDB 身份和运行时版本固定“分析的是谁”,再把运行时 ID 对齐到原生 TID;随后为每条等待边标注样本文件、线程与栈帧,为每条拥有边标注锁地址、owner 字段或应用事件;最后用多个采样点的完成计数和 CPU 时间判断这些边是否持续存在。若其中一条边只能靠源码推测,就把结论降级为“疑似”,继续补 owner 遥测或受控复现。这样,同事无需相信分析者的直觉,也能从原始样本重画同一张图。
JVM 的 intrinsic monitor、java.util.concurrent synchronizer、读写锁和虚拟线程拥有不同展示方式。jcmd <PID> Thread.print -l 通常会列线程状态、栈、锁地址、已锁 monitor/synchronizer,并可能在结尾报告检测到的 Java-level deadlock。这个自动检测结论很有价值,但仍要核对 JNI/native 锁、数据库连接和跨进程资源,因为 JVM 只能对它看得见的等待关系构图。
Go 的 goroutine dump 会显示 [semacquire]、[chan receive]、[IO wait] 等原因,runtime 在所有 goroutine 都无法前进且没有外部事件可唤醒时可能报 all goroutines are asleep - deadlock!。服务仍有网络 poller、timer 或后台 goroutine 时,局部业务死锁未必触发该报错;互斥锁拥有者通常也不直接出现在 dump 中。此时要把 goroutine 栈、mutex/block profile、源码锁顺序和请求进展指标组合起来。
多次采样证明“没有进展”
单次快照无法区分长临界区与永久等待。对实验进程间隔采集三次,并记录每次 attach 的开始、结束时间:
for sample in 1 2 3; do
{
date -u '+sample-start=%Y-%m-%dT%H:%M:%SZ'
gdb -q -nx -batch -p "$demo_pid" \
-ex 'set pagination off' \
-ex 'info threads' \
-ex 'thread apply all bt 8' \
-ex 'print owner_a' \
-ex 'print owner_b' \
-ex 'detach'
date -u '+sample-end=%Y-%m-%dT%H:%M:%SZ'
} >"evidence/deadlock-samples/sample-${sample}.txt" 2>&1
sleep 2
done三份样本都出现相同两条等待边、相同 owner 且业务完成计数不增长,才支持“稳定闭环且无进展”。采样间隔要大于正常临界区的高分位耗时;否则一个合法的慢操作也可能连续命中相同栈。真实服务还应同步保存请求完成数、锁等待直方图、CPU 时间增量、目标 fd 状态和外部依赖延迟,线程栈只占证据包的一部分。
GDB attach 默认会停止目标,thread apply all bt 的持续时间取决于线程数、符号和远程文件访问。生产中连续三次 attach 可能超过暂停预算,因此优先使用运行时自带的低扰动 dump、已预置的诊断信号或离线转储;必须 attach 时,先摘除单个副本流量,设置最长暂停、采样次数与停止条件。记录调试器暂停窗口,后续才能排除“样本不动是因为采样时一直被停住”。
取证顺序还受故障半径约束。若已有健康副本,先停止向故障实例分配新请求并确认会话、租约和领导权已经转移,再在该实例上采样;若它是唯一副本或持有不可转移状态,优先采集一次低扰动转储和进展指标,达到暂停预算就停止,不能为了凑齐三份样本把事故扩大。重启可以恢复服务,却会销毁内存中的 owner、等待边和原生栈,因此至少先保全一份与构建身份绑定的现场;是否继续深挖,由恢复时间目标、数据一致性风险和替代实例能力共同决定。
同一套证据模型如何跨语言落地
不同运行时的命令不能互换,但都要回答“谁在等谁、是否进展、采样造成什么影响”。
JVM:先用 jcmd,再判断是否需要 dump
pid=<java-pid>
mkdir -p evidence/jvm
for sample in 1 2 3; do
jcmd "$pid" Thread.print -l >"evidence/jvm/threads-${sample}.txt"
sleep 5
done预期每份文件包含 JVM 线程 ID、nid、状态与栈;发生 Java monitor 闭环时,输出通常给出等待对象、锁拥有者及 deadlock 摘要。若三次样本中 owner 或栈持续变化,说明系统仍有进展,应转查长临界区、锁竞争或慢依赖。jcmd 连接也需要同用户/attach 权限,并可能触发 VM 操作或 safepoint;线程很多时输出本身会产生开销。JDK 工具与目标 JVM 版本不匹配、容器 PID 视图错误或 attach 被禁用时,不能把空输出解释成“没有死锁”。
Go:把 goroutine、阻塞画像和请求进展放在一起
Go 进程默认收到 SIGQUIT 会把 goroutine 栈写到标准错误,随后退出;它是破坏性取证,不是在线服务的普通采样信号。只有实例已经摘流、替代副本已接管并且允许该进程终止时,才使用这条路径,同时确认日志通道有容量且权限受控。仍需保活时,优先读取应用预先安全启用的 pprof;从回环或受认证隧道采集 goroutine、mutex 和 block profile,禁止把诊断端口暴露到公共网络。mutex/block profile 还必须在应用中按评估过的采样率启用,否则空 profile 不能证明没有争用。
# 破坏性:默认会在输出 goroutine 栈后终止目标进程
kill -QUIT <go-pid>
# 仅示意受控回环入口,端点必须已经由应用安全启用
curl --fail --silent --show-error \
http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 \
>evidence/go-goroutines.txt
curl --fail --silent --show-error \
http://127.0.0.1:6060/debug/pprof/mutex \
>evidence/go-mutex.pb.gzgoroutine dump 中相同调用点持续存在只证明阻塞集合稳定;mutex profile 反映争用热点,也不等于拥有者等待图。要证明业务级死锁,仍要恢复 channel/锁/条件变量之间的闭环和无进展证据。
Python:预置 faulthandler 比临时注入更稳
Python 标准库 faulthandler 可以在批准的用户信号上转储所有线程栈。应用启动时把输出定向到受限文件,并保留文件句柄;不同平台支持的信号不同,Windows 不应照搬 Unix 信号配置。
import faulthandler
import signal
dump_file = open("/var/tmp/example-python-threads.log", "a", buffering=1)
faulthandler.register(signal.SIGUSR2, file=dump_file, all_threads=True)kill -USR2 <python-pid>输出能显示 Python 帧,但 C 扩展内部锁、GIL 状态和原生线程等待可能需要匹配 Python 构建的 GDB 扩展或 Core。异步任务在 await 上等待并不等于原生线程死锁;还要检查 event loop 是否在运行、任务是否有可完成的 Future,以及底层 fd 是否有事件。
Node.js:先区分事件循环停滞与 Worker 锁
单个 Node.js event loop 没有“两个 JS 线程互持 mutex”的经典形态,但同步原生扩展、Worker Threads、Atomics.wait、Promise 永不完成和 libuv I/O 都能表现为不响应。类 Unix 环境中,预先启用 diagnostic report 的服务可以按受控信号生成报告;信号触发在 Windows 上不可用。报告包含 JS/native 栈、libuv handles、环境与系统信息,必须按敏感转储保护。
node --report-on-signal --report-signal=SIGUSR2 app.js
kill -USR2 <node-pid>若主线程栈停在同步原生调用,Worker 又等待主线程消息,才可能形成跨层闭环;若 libuv handle 指向未完成 socket,优先核对慢 I/O;若事件循环延迟增长且 CPU 饱和,转向 CPU profiling。只看一份 JS 栈无法区分这些路径。
Windows:多份用户态 dump 比一张截图可靠
在受控目录用 ProcDump 对挂起进程采集多个 full dump,并用匹配架构的 WinDbg/CDB 查看所有线程。full dump 可能包含完整堆和凭证,目录 ACL、空间和保留必须先设定。
$targetPid = <process-id>
$dumpDir = 'C:\DebugEvidence\Deadlock'
New-Item -ItemType Directory -Force $dumpDir | Out-Null
icacls $dumpDir /inheritance:r /grant:r "${env:USERNAME}:(OI)(CI)F"
1..3 | ForEach-Object {
.\procdump64.exe -accepteula -ma $targetPid "$dumpDir\sample-$_.dmp"
Start-Sleep -Seconds 5
}在 CDB/WinDbg 中用 ~* kb 获取全部线程栈,再按具体同步原语加载对应扩展和符号。线程都停在 ntdll 等待入口很常见,真正的等待对象要从上层帧、handle/synchronization object、应用遥测和源码恢复。错误 PDB 仍可能打印合理函数名,先校验模块与 PDB 身份再构图。
四种相似现场必须分型
死锁要求等待图存在闭环,并且多次采样没有任何能打破环的外部进展。线程通常处于 waiting/blocked,锁 owner 固定。超时可能最终打破资源等待,此时属于活性故障但未必是永久死锁;要记录超时是否真实执行。
饥饿没有稳定闭环。某个线程长期拿不到 CPU、锁、队列配额或连接,但资源拥有者持续变化,系统总体仍完成工作。证据是受害线程等待时间不断增长、其他线程完成计数增加、调度或公平策略偏斜。修复方向是缩短临界区、调整公平性/优先级/池容量或消除热点,而不是只改变锁顺序。
慢 I/O 持锁常呈现“很多 waiter 指向一个 owner”,owner 的栈却在 read、poll、数据库驱动、DNS、文件系统或远程 RPC。等待图没有资源环,外部依赖恢复后会推进。核对 fd、socket 端点、请求 deadline、依赖延迟与持锁时长;修复通常是把 I/O 移出临界区、增加超时/取消和限制并发,而不是扩大线程池掩盖等待。
调试暂停由 debugger、safepoint、dump 采集、SIGSTOP 或断点造成。其特征是停止窗口与调试审计时间一致,所有线程的 CPU 与业务计数同时停住,detach/continue 后恢复;条件断点或表达式还可能执行目标代码并取得锁。任何多样本结论都要扣除采样工具造成的暂停,不能把仪器效应写成原始故障。
数据竞争又是另一类问题:两个线程在缺少同步时访问同一内存且至少一个写入,单次栈通常只看到结果,不保存先后顺序。优先使用 ThreadSanitizer、Go race detector、Java 并发事件/应用遥测、rr(仅在其支持边界且竞态能在单核记录下触发)或专用 tracing。看到值损坏后补画一个锁环,并不能证明数据竞争原因。
修复要用正反版本验证,而不是只看“不再卡住”
对双锁实验,修复是建立全局锁顺序:所有路径都先 A 后 B。重新编译不带 BROKEN_ORDER 的版本,并多次运行:
cc -g3 -O0 -pthread deadlock-demo.c -o build/deadlock-fixed
sha256sum build/deadlock-fixed | tee evidence/deadlock-fixed.sha256
for run in $(seq 1 100); do
timeout 2s ./build/deadlock-fixed \
>"evidence/fixed-${run}.stdout" \
2>"evidence/fixed-${run}.stderr" || exit 1
test "$(grep -c '^worker_.* completed$' "evidence/fixed-${run}.stdout")" -eq 2 \
|| exit 1
done每次预期两个 worker 都打印 completed 并在超时前退出。验证不能止于“100 次没挂”:代码评审还要证明所有锁路径遵守相同偏序,动态测试要覆盖取消、异常、超时和早退释放,运行指标要观察锁等待高分位与请求吞吐。若改用 trylock/timeout,必须验证失败路径释放已持锁并返回可处理错误,否则只是把永久等待改成重试风暴。
全局顺序必须成为代码契约,而不是只写在事故复盘里。可以给锁定义稳定 rank,并规定线程只能从低 rank 获取高 rank;同 rank 的动态资源(例如两个账户分片)按不可变 ID 排序后再取锁,不能按请求到达顺序决定。封装层在测试或预发记录每个执行上下文的持锁栈,发现逆序立即失败;代码评审重点检查回调、析构、错误处理和重试路径,因为隐藏的二次加锁最容易绕过主流程。条件变量等待会原子释放并在返回前重新取得关联锁,必须把“唤醒后重新持锁”纳入顺序;可重入锁只允许同一执行上下文重复进入,并不能消除与另一把锁形成的环。对协程代码还要禁止持有线程阻塞锁跨越 await,异步锁则要把取消与超时后的释放写成结构化清理。
锁图可由静态规则与运行遥测共同守住:静态检查覆盖已声明的 rank 和明显逆序,压力测试或预发遥测补足动态分派、插件回调和条件分支。生产不宜记录每次加锁的高基数明细,可以按 lock_class 聚合等待时长和超时数,仅在受控诊断窗口采样 owner/waiter 关系。治理目标不是“系统永不等待”,而是任何新增锁都能说明 rank、持有边界、是否允许跨 I/O/await、超时与取消语义,以及用什么测试证明不会引入新环。
上线采用可回滚的小批次:保留旧构建、配置与锁指标基线;先在单副本或小流量观察等待时间、完成数、超时和错误率;出现新数据竞争、吞吐退化或重试放大时恢复旧构建并撤销配置。回滚旧代码会重新引入原死锁风险,因此同时保留流量隔离、故障实例替换和触发阈值,不能把代码回滚当作无代价恢复。
退出现场时清掉进程、转储和调试权限
GDB 脚本已经执行 detach,目标仍处于死锁,需要显式终止实验进程并确认没有残留:
demo_pid="$(cat evidence/deadlock.pid)"
kill "$demo_pid" 2>/dev/null || true
wait "$demo_pid" 2>/dev/null || true
if kill -0 "$demo_pid" 2>/dev/null; then
printf 'process still alive: %s\n' "$demo_pid" >&2
exit 1
fi
find evidence/deadlock-samples -type f -printf '%m %u %g %s %p\n'实验目录确认无保留需要后再删除;生产证据则按事件 ID 核销线程 dump、Core/full dump、diagnostic report、pprof 文件、日志副本和分析机缓存。它们可能包含栈中参数、对象字段、请求体、令牌、文件路径和客户数据,不能因为后缀是 .txt 就降级保护。
容器或远程调试还要撤销 SYS_PTRACE、临时调试容器、端口转发、短时 RBAC、调试组和符号访问凭证。Windows 复查 ProcDump 监控进程与 dump 目录;JVM、Go、Python 和 Node 复查临时诊断端点、信号处理与输出轮转。最后确认目标已恢复运行或由受控替代实例接管,调试器没有把线程留在 stopped 状态。
成熟的并发取证不会看到 mutex_lock 就下结论。它会先绑定构建与线程身份,再恢复锁拥有者和等待边,用多次采样证明是否进展,排除慢 I/O、饥饿与调试暂停,最后让修复版本在失败实验、正常实验、流量指标和回滚路径上同时成立。只有闭环证据也闭环,死锁修复才不是一次碰巧有效的重启。
