死锁是线程在std::mutex::lock()上永久阻塞且无法推进,由互斥、持有并等待、不可剥夺、循环等待四条件共同导致;根本原因是分步加锁顺序不一致,如线程a先锁mtx1再锁mtx2,而线程b相反,形成a→b→a闭环。

死锁不是“偶尔卡一下”,而是线程在 std::mutex::lock() 上永久阻塞,且没有任何线程能推进——只要满足四个条件(互斥、持有并等待、不可剥夺、循环等待),它就一定会发生,只是时序问题。
为什么std::lock_guard分步加锁会死锁
根本原因在于加锁顺序不一致,而不是锁本身有问题。两个线程分别按相反顺序调用 std::lock_guard,中间穿插 sleep_for 或其他耗时操作,极大提高竞争窗口。
- 线程 A 执行
lock_guard(mtx1)成功 → 持有 mtx1 - 线程 B 执行
lock_guard(mtx2)成功 → 持有 mtx2 - 线程 A 接着调用
lock_guard(mtx2)→ 阻塞,等待 mtx2 释放 - 线程 B 接着调用
lock_guard(mtx1)→ 阻塞,等待 mtx1 释放
此时已形成闭环:A→B→A。注意:std::lock_guard 是 RAII 封装,它不负责解决顺序问题,只保证单个锁的自动释放。
用std::lock一次性锁定多个互斥量
std::lock 是 C++11 提供的原子性多锁获取函数,内部使用试探+回退策略(如先尝试低地址锁),避免了用户手动控制顺序的错误可能。
- 必须配合
std::adopt_lock使用,否则lock_guard会再次调用lock()导致未定义行为 - 不能直接传入
std::lock_guard构造函数;要先std::lock(mtx1, mtx2),再用std::lock_guard的adopt_lock构造 - 若任意一个锁不可得,
std::lock会释放已获取的锁并重试,不会留下半锁状态
void safe_transfer() {
std::lock(mtx1, mtx2); // 原子性获取两个锁
std::lock_guard<:mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<:mutex> lock2(mtx2, std::adopt_lock);
// 临界区操作
}</:mutex></:mutex>
排查死锁:别等线上出事才看 pstack
死锁发生后,所有相关线程都停在 pthread_mutex_lock 或类似系统调用上,pstack <pid></pid> 能立刻暴露等待链,但前提是进程还没被 kill -9。
- 运行中进程:执行
pstack <pid></pid>,搜索pthread_mutex_lock和__lll_lock_wait,确认多个线程是否卡在不同锁上且互相指向 - Core dump 分析:用
gdb ./a.out core后执行thread apply all bt,比pstack更完整 - 开发阶段就启用检测:编译时加
-fsanitize=thread(TSan),它会在首次出现锁顺序冲突时直接报错,附带调用栈
注意:Valgrind + Helgrind 在生产环境慎用,性能损耗超 10 倍;而 TSan 只适合测试环境,不支持 inline asm 和部分系统调用。
容易被忽略的递归死锁场景
同一个线程对 std::mutex 重复加锁,不是“等别人”,而是“等自己”——这也会立即死锁,且不依赖多线程协作。
- 典型诱因:
lock_guard在函数内作用域嵌套,或递归调用中未检查锁状态 -
std::recursive_mutex能解决该问题,但它掩盖了设计缺陷:本不该在同一线程内多次进入同一临界区 - 更安全的做法是重构逻辑,或用
std::unique_lock+try_lock()显式判断,避免隐式阻塞
真正难缠的死锁,往往藏在看似无害的“先查后改”模式里:比如先读账户余额(只读不锁),再根据结果决定是否扣款(加锁),中间窗口期被其他线程修改,导致逻辑错乱——这虽不是死锁,但常和死锁共存于同一模块,排查时容易误判。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











