死锁定位:从 dump 里还原锁的持有链
关键鉴别点:先区分“死锁”和“慢”。CPU 空闲 + 进度为零 + 两次 dump 栈不变 = 死锁。CPU 跑满 = 死循环或活锁,是另一类问题 。
2. 最小复现(Minimal Repro)
cpp
// deadlock_repro.cpp —— 经典 AB-BA 死锁,跨平台通用 #include <mutex> #include <thread> #include <chrono> std::mutex mtx_a, mtx_b; void thread_func_1() { std::lock_guard<std::mutex> lock1(mtx_a); // 先拿 A std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 给另一线程拿 B 的机会 std::lock_guard<std::mutex> lock2(mtx_b); // 再请求 B —— 但 B 可能已被 thread_func_2 持有 } void thread_func_2() { std::lock_guard<std::mutex> lock1(mtx_b); // 先拿 B std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::lock_guard<std::mutex> lock2(mtx_a); // 再请求 A —— 但 A 已被 thread_func_1 持有 } int main() { std::thread t1(thread_func_1); std::thread t2(thread_func_2); t1.join(); // 永远等不到 t2.join(); return 0; }编译(保留调试信息,否则 dump 里只有地址没有符号):
bash
# Linux g++ -g -O0 -pthread deadlock_repro.cpp -o deadlock_repro # Windows (MSVC) cl /Zi /Od /EHsc deadlock_repro.cpp
预期表现:程序启动后挂起,t1.join()永远不返回。两个线程分别卡在mtx_b和mtx_a的获取上,形成循环等待。
3. 定位工具与方法(Diagnose)
3.1 Linux 路径:gdb + core dump
获取 dump(两种方式):
bash
# 方式 A:程序已挂起,attach 上去 gdb -p <pid> # 方式 B:主动让进程 core dump(需 ulimit -c unlimited) kill -6 <pid> gdb ./deadlock_repro core
第一步:看所有线程的栈,找“等待锁”的线程
gdb
(gdb) thread apply all bt
输出中,找到最后一帧(或倒数几帧)停在__lll_lock_wait/pthread_mutex_lock的线程。这些是正在等锁的线程 。
第二步:对每个等待线程,提取两件事:它在等哪把锁、谁持有那把锁
以 glibc 的pthread_mutex_t为例(x86_64),关键字段是__data.__owner(持有者 LWP ID)和__data.__lock(锁状态):
gdb
# 切换到等待线程的栈帧,找到 pthread_mutex_lock 的调用帧 (gdb) frame <N> # N 是 pthread_mutex_lock 所在的帧 (gdb) p mutex_x # 或者 p *(pthread_mutex_t*)<地址>
输出中:
第三步:对另一个等待线程重复,直到画出闭环
假设分析结果:
闭环成立:12346 等 12347 持有的锁,12347 等 12346 持有的锁 → 死锁 。
快速命令(不想手动翻帧时):
gdb
(gdb) thread apply all bt 3 # 只看栈顶 3 帧,快速筛出锁等待线程 [citation:13]
3.2 Windows 路径:WinDbg + dump
获取 dump:任务管理器右键进程 → “创建转储文件”,或使用 procdump。
第一步:打开 dump 并自动分析
windbg
!analyze -v
对于 hang/死锁场景,!analyze -v会指出可疑线程和等待的资源 。
第二步:看所有线程栈,找锁等待
windbg
~*kb
在输出中搜索Wait、Lock、CriticalSection、NtWaitForSingleObject等关键词 。
第三步:分析 CriticalSection 的持有关系
如果程序使用CRITICAL_SECTION(MSVCstd::mutex在 Windows 上底层通常也映射到它):
windbg
!cs -l
这个命令列出所有被锁定的关键段,以及每个关键段的持有线程和等待线程。输出中可以直接看到:谁持有 CS_A,谁在等 CS_A,谁持有 CS_B,谁在等 CS_B。如果形成闭环,死锁确认。
第四步:切换到可疑线程,看它在等什么
windbg
~~[<TID>]s # 切换到指定线程 kv # 查看该线程的调用栈
如果线程栈显示它在RtlEnterCriticalSection/NtWaitForSingleObject,结合!cs -l给出的持有关系,闭环就还原出来了 。
3.3 跨平台通用方法:等待图(Wait-for Graph)
无论 Linux 还是 Windows,核心分析方法相同:画一张有向图。
用手动或脚本方式整理成表格:
| 线程 | 等待的锁 | 该锁的持有者 |
|---|---|---|
| T1 | mtx_b | T2 |
| T2 | mtx_a | T1 |
闭环:T1 → T2 → T1。死锁成立。
这个方法不依赖具体调试器,任何能拿到“谁等谁”信息的 dump 都可以套用。
4. 根因分析(Root Cause)
死锁的四个必要条件(Coffman 条件)
四个条件同时满足,死锁必然发生。修复的思路就是打破其中至少一个——最常用的是打破循环等待(锁排序)。
为什么“崩溃点”和“死锁点”不同
死锁不会崩溃。进程活着,线程在等待,操作系统不认为有任何异常。所以:
这也是为什么死锁定位的第一步永远是“先拿到挂起瞬间的线程快照”。
为什么日志停在“某一行”但不一定是那一行的问题
死锁发生时,各线程的进度不同。可能线程 A 已经执行到第 100 行并拿到了锁 1,线程 B 执行到第 50 行并拿到了锁 2,然后互相等待。日志最后写的是“线程 A 进入操作 X”或“线程 B 进入操作 Y”,但两行日志之间没有因果关系——它们只是各自执行到了那里。不要因为日志最后一行是 X 就盯着 X 的代码看,要看锁的持有关系。
5. 修复方案(Fix)
首选:全局锁顺序(Lock Ordering)
给所有锁定义一个全序(按地址、按名称、按固定编号),所有代码路径都按这个顺序获取锁。
cpp
// 修复:统一按 A → B 的顺序获取 void thread_func_1() { std::lock_guard<std::mutex> lock1(mtx_a); std::lock_guard<std::mutex> lock2(mtx_b); // 始终 A 后 B } void thread_func_2() { std::lock_guard<std::mutex> lock1(mtx_a); // 改为先拿 A std::lock_guard<std::mutex> lock2(mtx_b); // 再拿 B,顺序一致 }C++17 的std::scoped_lock可以自动处理多锁获取,它内部使用死锁避免算法(try-and-backoff),不需要你手动排序:
cpp
void thread_func_1() { std::scoped_lock lock(mtx_a, mtx_b); // 同时获取两把锁,内部避免死锁 // ... } void thread_func_2() { std::scoped_lock lock(mtx_a, mtx_b); // 同样同时获取,不会 AB-BA // ... }备选方案
不要做的“修复”
6. 预防措施(Prevent)
编码规范
运行时检测
代码审查关注点
CI 门禁
7. 延伸阅读(站内交叉引用)
摘要:程序不崩不报错,就是不动了。CPU 占用不高,日志停在某一行,等多久都没反应——这是死锁最典型的面孔。死锁的难点不在修复(锁顺序排一下就好),而在从一堆线程栈里找出谁持有谁等待的闭环。本文以 Linux(gdb + core dump)和 Windows(WinDbg + dump)两条路径,完整走一遍从 dump 还原锁持有链的流程,并给出可复用的“等待图”分析法。
1. 症状(Symptom)
现象:程序挂起(hang),无响应、无输出、无进度。与崩溃不同,进程仍然存在,只是所有相关线程都卡住了。
典型特征:
CPU 占用率很低或为零(区别于死循环:死循环会让 CPU 跑满)
日志停在某个操作之前,之后再无输出
多个线程的调用栈都停在锁获取函数上(Linux:
__lll_lock_wait/pthread_mutex_lock;Windows:NtWaitForSingleObject/RtlEnterCriticalSection)取两次线程 dump(间隔几秒),栈完全一样,没有任何推进
复现条件:两个或更多线程以不同的顺序获取同一组锁。例如线程 A 先拿锁 1 再拿锁 2,线程 B 先拿锁 2 再拿锁 1,各自拿到第一把锁后,再去请求对方持有的锁。
环境:Linux(pthread mutex / std::mutex)、Windows(CriticalSection / SRWLock / std::mutex)均可能出现,机制相似但 dump 分析命令不同。
__lock = 2:已被锁定,且有线程在等__owner = <LWP ID>:当前持有这把锁的线程线程 LWP 12346 等待
mtx_a,mtx_a被 LWP 12347 持有线程 LWP 12347 等待
mtx_b,mtx_b被 LWP 12346 持有节点= 线程
边 A → B= 线程 A 正在等待线程 B 持有的锁
图中出现环= 死锁
互斥:锁一次只能被一个线程持有
持有并等待:线程持有至少一把锁,同时等待另一把锁
不可抢占:锁不能被强制夺走,只能由持有者释放
循环等待:存在一个线程的闭环,每个线程都在等下一个线程持有的资源
没有 core dump 自动生成(除非你主动
kill -6或配置了 watchdog)没有异常日志
唯一的线索是线程栈——而线程栈需要 dump 才能看到
try_lock+ 超时/退避:std::try_lock获取失败时释放已持有的锁,稍后重试❌ 调大 sleep / 加日志“观察”:不改变锁顺序,死锁窗口依然存在
❌ 只修复一条路径的锁顺序:其他路径仍可能反向获取
❌ 用
recursive_mutex掩盖问题:AB-BA 死锁是两个线程之间的问题,递归锁解决的是同一线程重复加锁,完全不适用项目级约定:所有多锁场景必须使用
std::scoped_lock(C++17),禁止手动lock()嵌套如果必须手动排序,在头文件中用注释或常量定义全局锁顺序表,所有开发者遵循
禁止在持锁期间调用未知的外部回调/虚函数(可能间接获取其他锁,形成隐藏的锁顺序)
Linux:编译时加
-fsanitize=thread(TSan),在测试阶段捕获锁顺序反转(TSan 会报告“lock-order-inversion”)Windows:Application Verifier 的Locks检查可以检测锁使用错误和潜在死锁
生产环境:配置 watchdog 线程,定期检查关键操作的进度;超时后自动触发 dump 并告警
每个函数中获取多把锁的地方,获取顺序是否与全局约定一致
是否存在“持锁调用外部函数”的路径,外部函数是否可能获取其他锁
锁的持有时间是否过长(增加死锁窗口)
TSan 跑全部并发相关单元测试,
lock-order-inversion告警视为失败对核心并发路径做压力测试(多线程、高并发、随机调度),提高死锁复现概率
《C++疑难杂症急救手册 #1:Release必崩、Debug正常:/GS堆栈检查失败的全链路定位与修复》——同系列,栈破坏类问题
《C++疑难杂症急救手册 #2:内存泄漏三件套:CRT调试堆 + Valgrind + ASan 对照实战》——同系列,泄漏类问题
《C++疑难杂症急救手册 #3:堆缓冲区溢出:为什么崩溃点总在无辜的 free()?》——同系列,堆破坏类问题
《C++疑难杂症急救手册 #4:栈缓冲区溢出:GCC / Clang / MSVC 编译选项全对照》——同系列,栈保护机制
《并发编程之管道通讯,共享内存和套接字》——并发基础,理解线程间协作的底层机制
《Windows/Linux/MacOS下使用消息队列进行进程间通讯》——跨平台 IPC,与线程锁形成对照
单一粗粒度锁:用一把大锁保护整个临界区,消除嵌套锁(牺牲并发度)
锁层级(Lock Hierarchy):在运行时检查获取顺序,违反层级时断言失败(调试期发现死锁隐患)