前阵子有个线上服务半夜报警,某个核心接口的P99从几十毫秒直接冲到几万毫秒,CPU占用却低得反常。进程还活着,请求全部堆在队列里不动了。我抓了一次线程栈,发现两个工作线程各抱着一把mutex,谁都不肯先松手——教科书里的死锁,就这么毫无预兆地出现在生产环境里。
这个事故让我决定把C++并发编程中死锁避免这件事彻底整理一遍。这篇文章不打算只复述“什么是死锁”和“四个必要条件”,我想从事故现场讲起,再把常见死锁代码形态、规避策略、定位工具、团队落地方案串成一条完整的线。不管你是刚开始接触多线程的新手,还是已经写过几年并发代码的老手,希望能有一点值得带走的经验。
1. 先从一个事故现场说起
1.1 卡死的接口与两张等待中的线程栈
当时的现象非常典型:接口超时率飙升,进程不退出,CPU不到10%,网络连接全都正常。最要命的是没有core dump,日志里也没有任何异常。我让值班同学先重启恢复服务,然后回头研究现场——唯一有价值的证据就是两张线程栈。
用gdb附到进程上,命令只有一行:
gdb -p <PID> (gdb) thread apply all bt输出的关键部分长这样(略去了地址和符号细节):
Thread 2: #0 __lll_lock_wait () #1 pthread_mutex_lock () #2 Worker2::process() (worker.cpp:88) // 线程2正在等 m1 Thread 1: #0 __lll_lock_wait () #1 pthread_mutex_lock () #2 Worker1::process() (worker.cpp:66) // 线程1正在等 m2两张栈放在一起,答案已经写在脸上了:Worker1持有m2在等m1,Worker2持有m1在等m2。这就是最典型的循环等待,两个线程谁都没法继续往下走,外部看起来就是请求全部卡住。
这里有个经验要分享:抓线程栈的时候别只gt了主线程就收工。并发程序里出问题的往往不是你认为最忙的那个线程,而是某个低频的定时器线程、后台任务线程。thread apply all bt会把所有线程栈一次打出来,目的就是让你看到完整的“锁依赖图”,而不是只看局部。
1.2 死锁的本质:资源被“互相等”卡死
很多人以为死锁就是“锁坏了”或者“某个线程卡住了”,其实死锁不是锁本身的问题,而是多个线程在资源获取上形成了环。拿生活类比,就像两个人过一座很窄的桥,你攥着左边的扶手不放,我攥着右边的扶手不放,都在等对方先松手——结果谁都过不去。
在C++里,这个“资源”不一定是std::mutex。条件变量、信号量、文件句柄、数据库连接、future的等待结果,都可能成为资源环上的一环。只要多个线程各自占有一部分资源、又都在等另一部分资源,死锁就可能发生。
由此也能解释一个反直觉的现象:死锁的进程往往CPU很低。因为线程已经不再消耗CPU去做运算了,它们全部阻塞在锁等待上,系统看起来“很平静”。这也是判断死锁和性能瓶颈的一个快速手段——高CPU通常是忙等或计算密集,低CPU加请求堆积则要优先怀疑是不是锁在搞鬼。
1.3 四个必要条件,逐一落到C++语法上
教科书上把死锁的成因总结成四个必要条件,我当年觉得这是纯理论,直到线上出事才意识到它们每一条都对应着具体的代码行为:
| 必要条件 | 在C++里的具体表现 |
|---|---|
| 互斥 | std::mutex同一时刻只能被一个线程持有,天然满足 |
| 持有并等待 | 一个线程已经lock了一把锁,还没解锁就再次lock另一把锁 |
| 不可剥夺 | mutex只能由持有它的那个线程解锁,其他线程不能强行夺走 |
| 循环等待 | 多个线程之间形成“你等我、我在等他、他又在等你”的环 |
这条规律最大的价值在于:所有死锁避免策略,本质上都是破坏上述四个条件中的一个。比如只允许代码里拿一把锁,是破坏“持有并等待”;全项目统一锁的获取顺序,是破坏“循环等待”;用try_lock加超时退避,相当于不让线程进入无限期的“不可剥夺”等待。
明白了这层对应关系,后面看到各种规避手段就不会觉得是一堆孤立规则,而是同一件事的不同切面。
2. C++并发代码里最常踩的三类死锁
2.1 多把锁顺序不一致:最经典的ABBA
先看一段能稳定复现死锁的代码:
std::mutex m1; std::mutex m2; void transfer1() { std::lock_guard<std::mutex> guard1(m1); std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::lock_guard<std::mutex> guard2(m2); // 转账逻辑 } void transfer2() { std::lock_guard<std::mutex> guard2(m2); std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::lock_guard<std::mutex> guard1(m1); // 转账逻辑 }这个例子在并发测试里非常经典。两个线程一个按“m1 -> m2”的顺序拿锁,另一个按“m2 -> m1”的顺序拿锁,只要时机凑巧,就会双双卡在第二个lock_guard的构造函数上。
这里我特别想提醒一点:代码里那两行sleep_for是故意的。很多人在测试环境复现不了死锁,不是因为代码没风险,而是竞争窗口太小,两个线程根本没机会同时卡在中间。往临界区里加一个临时sleep,把获得第一把锁之后、等待第二把锁之前的窗口拉大,死锁就变得“可复现”了。这是压测阶段定位死锁的一个非常实用的技巧,后面讲测试方法时还会再说。
修复最省事的方式是用C++17的std::scoped_lock一次拿齐所有锁:
void transfer1() { std::scoped_lock lock(m1, m2); // 转账逻辑 } void transfer2() { std::scoped_lock lock(m1, m2); // 转账逻辑 }scoped_lock的构造函数内部会调用std::lock算法,在拿不到全部锁时自动释放已经到手的锁再重试,从根上避免了“先拿一把再等第二把”的窗口。这个方案比单纯靠人肉统一顺序更可靠,因为它把“获取多把锁”这件事变成了原子操作。
2.2 同一线程重复加锁:比想象中的更常见
多线程死锁大家都很警惕,有一种单线程自我死锁却经常被忽略:
std::mutex m; void update() { std::lock_guard<std::mutex> guard(m); // 做一些处理 innerUpdate(); // 半路调用了自己人 } void innerUpdate() { std::lock_guard<std::mutex> guard(m); // 同一个线程再次加锁同一个mutex // ... }std::mutex是不可重入的,同一个线程对同一个mutex连续加锁两次,结果是未定义行为——现实中绝大多数表现为直接卡死在这里。新手遇到这个问题第一反应是换成std::recursive_mutex,让同一线程可以重复加锁。我的建议是:不要急着这么干。
递归锁本质上掩盖了一个设计问题:临界区边界到底划在哪一层?外层函数该不该持有锁去调用内层函数?如果用了递归锁,你只是让代码“能跑”,但锁的粒度和职责变模糊了,以后别人在锁内调用其他可能加锁的接口时,死锁风险只会更高。更合理的做法是把锁拆开,保证内层函数不再需要外层那部分锁保护,或者把调用挪到锁外,让职责边界干净起来。
2.3 持锁调用外部代码:锁通过回调“接上环”
还有一种特别隐蔽的死锁,问题出在“持锁调用外部代码”上。最常见的就是在锁内执行回调函数:
std::mutex mutex; std::unordered_map<int, std::function<void()>> handlers; void dispatch(int id) { std::lock_guard<std::mutex> guard(mutex); auto it = handlers.find(id); if (it != handlers.end()) { it->second(); // 外部注册进来的回调,里面可能再拿别的锁 } }这段代码表面上没问题,锁也加了,查找也做了。但危险不在dispatch函数本身,而在回调是别人注册进来的——它内部完全可能再取一把别的锁,甚至绕一圈又取回当前这把锁。一旦模块之间互相注册、互相调用,锁依赖图就会在你看不见的地方悄悄闭合,形成死锁环。
重构思路很朴素:锁内只拷贝回调对象,出了锁再执行:
void dispatch(int id) { std::function<void()> callback; { std::lock_guard<std::mutex> guard(mutex); auto it = handlers.find(id); if (it != handlers.end()) { callback = it->second; } } if (callback) { callback(); } }这个版本把“查表”和“执行回调”彻底分开,持锁时间极短,回调执行时手上不再有任何锁,自然也就不会因为回调内部加锁而进入循环等待。
我个人的编码习惯是:凡是锁内出现函数调用、虚函数调用、回调、invoke这类间接调用,都会单独标记出来过一遍。是不是真有必要在锁里调用它?能不能先把结果或函数对象拷出来?这两问能挡住一大半隐蔽死锁。
2.4 条件变量旁边隐藏的“假死锁”现场
条件变量不是锁,但它总是和mutex配套使用,用错了很容易出现和死锁症状几乎一样的“假死锁”。最常见的两种错误:
第一种是等待端在持锁状态下做了耗时操作。虽然cv.wait内部会释放锁,但如果在进入wait之前,线程持锁执行了IO、睡眠、远程调用,那么唤醒信号就会被硬生生堵在后面。别的线程想获取锁去notify,得等临界区结束,表现起来就是“卡死了”,线程栈却看不出典型的循环等待环。
第二种是lost wakeup。调用notify时还没有别的线程在wait,信号丢失,后面的线程永远等不到唤醒。
先说结论:条件变量必须配合“条件谓词”使用,不能只写一个裸的wait。标准的写法是:
std::unique_lock<std::mutex> lk(m); cv.wait(lk, [] { return ready; }); // 先检查谓词,不满足才进入等待同时,生产环境优先用notify_all而不是notify_one。虽然notify_all有轻微性能开销,但它能把“丢失唤醒”的概率降到最低,对绝大多数业务场景来说更安全。另外,等待端的临界区里不要放耗时逻辑,有条件就先把数据读出来,在锁外处理。
3. 避免死锁的正确姿势:从规范到语言特性
3.1 先把锁的顺序定死
如果代码里必然出现多把锁,最简单粗暴的规则是:全项目所有互斥量按同一个逻辑顺序获取。具体做法是给每把锁分配一个唯一的编号,规定任何代码都只能从编号小的锁往编号大的锁拿,反过来一律禁止。
这个规则一但定下来,ABBA死锁从结构上就消失了——因为不可能出现“拿了m2再回头拿m1”的情况。关键是落地。模块A按自己习惯拿锁,模块B按另一套习惯拿锁,两边一交汇就会出事。所以锁顺序必须是全局约定,要写进编码规范,代码评审时逐条检查,谁都不能例外。
3.2 用std::scoped_lock一次拿齐所有锁
前文已经提过std::scoped_lock,这里补充一点原理。它内部通过std::lock函数实现多锁原子获取:要么一次拿到全部锁,要么在拿不全时释放已经拿到的锁再重试。这是一种“all-or-nothing”语义,让“持有并等待”的窗口尽可能小。
如果你还在用C++11,没有scoped_lock,可以用std::lock配合std::unique_lock的defer_lock手动实现同样效果:
std::unique_lock<std::mutex> g1(m1, std::defer_lock); std::unique_lock<std::mutex> g2(m2, std::defer_lock); std::lock(g1, g2); // 内部处理避免死锁有一点要点明:scoped_lock只解决“获取多把锁”的实现问题,不解决“锁内再手工加锁”的问题。如果某人拿完scoped_lock,回头又在临界区里对别的锁调用lock,死锁依然可能回来。
3.3 缩小锁粒度,而不是拼命加锁
很多死锁不是功能复杂,而是一个函数把不相关的资源全圈进了同一把锁。一个对象同时管理配置、缓存、连接池,你用一个mutex把所有字段全盖住,那任何两段需要其中不同字段的代码都可能互相等待。拆锁之后,每把锁只管一类资源,锁和锁之间的交汇面就小了,形成死锁环的可能性直线下降。
还有一类非常常见的问题是持锁做IO或睡眠。日志写入、RPC调用、磁盘落盘动辄几十毫秒,锁一旦被拽住,等锁线程等得越久,越容易在等待过程中和其他线程已持有的锁形成环。我的经验法则是:临界区里只做内存操作和CPU计算,凡是可能阻塞的调用一律挪到锁外。
3.4 try_lock和超时:给死锁一个“截止时间”
std::mutex只有try_lock,std::timed_mutex才有try_lock_for和try_lock_until。try_lock的语义是“拿不到就算了”,不再死等下去:
std::timed_mutex m1, m2; bool tryBothLocks() { std::unique_lock<std::timed_mutex> g1(m1, std::defer_lock); if (!g1.try_lock_for(std::chrono::milliseconds(100))) { return false; } std::unique_lock<std::timed_mutex> g2(m2, std::defer_lock); if (!g2.try_lock_for(std::chrono::milliseconds(100))) { return false; } return true; }但要小心:try_lock只是把“永久死等”换成了“等一下就走”。如果两个线程都在执行“拿第一把锁、尝试第二把失败、释放、重试”的逻辑,就可能变成活锁——大家都没死锁,但谁也干不成事。给重试加随机退避能显著降低这种概率。
生产环境我通常只在“调用方有明确超时要求”的场景用这类方案,普通业务锁还是优先考虑std::scoped_lock和全局锁顺序。真到线上发生了死锁,try_lock更像是一个逃生门,而不是日常路径。
3.5 锁层级设计:给互斥量定“级别”
如果锁数量很多,可以按用途给锁分级别:比如全局配置锁是0层,业务模块锁是1层,对象内部锁是2层。规则只有一条:只能从高层向低层获取,禁止反向。
为什么要这样设计?因为循环等待要求的是“环”,分层之后锁的获取方向永远是单向的,环在结构上就不可能成立。内部系统把配置、业务数据、临时状态分成几个清晰的层级,很多互相等死的问题会自然消失。
分层锁不是所有系统都适用,尤其是数据流动复杂、需要频繁跨层访问的场景。但大多数业务系统,“配置是全局的、业务数据是模块的、临时状态是对象的”这个三层划分足够好用。
3.6 能不用锁就不必用锁:原子操作与无锁边界
有些共享数据本质上不是“多操作组成的复合临界区”,而只是某个计数器、某个标志位、某个指针。这种场景直接用std::atomic就好,根本不用锁。引用计数、开关状态、队列头尾指针,都是原子操作的典型应用。
但无锁不等于无脑。真正的无锁数据结构,比如并发队列、并发栈,对内存序、ABA问题的理解要求非常高。如果团队里没有愿意啃这三座大山的人,我建议慎用“真无锁”,优先考虑“锁粒度尽量小 + 能用原子就不加锁”。
访问共享数据之前先问自己三个问题:这个操作真的需要排它吗?如果只是读取一次快照,原子load就够了;如果是“检查再修改”,CAS循环往往比mutex好用;只有当多个操作必须连在一起、中间不能被其他线程插入,才需要考虑加锁。
4. 死锁已经发生了,怎么高效定位
4.1 gdb大法:线程栈会把凶手画出来
线上死锁最难的是现场难抓,它可能几个小时才出现一次。不过一旦进程还活着,gdb就能让现场冻结下来。核心命令还是那一行:
gdb -p <PID> (gdb) thread apply all bt每一帧看下来,哪个线程持有锁、哪个在等锁,通常在3到5帧内就能判断清楚。看到两个线程都停在pthread_mutex_lock上,再配合info threads确认线程编号对应的业务函数,基本就能锁定是哪个业务路径出了问题。
生产环境有时候没有ptrace权限,gdb attach不上去。备选方案是core dump:把ulimit -c unlimited设置好,卡死后用gcore直接抓现场,拿下来慢慢分析。线上恢复永远是第一优先级,但别忘了先把现场留好,否则下回只能盲查。
4.2 Helgrind与ThreadSanitizer:跑之前就先报出来
静态查死锁很难,但动态工具能在开发阶段就提醒你。valgrind --tool=helgrind ./your_program会在程序运行到锁顺序逆反时输出告警,并附带着持有锁的调用栈。缺点是慢,通常用来跑单元测试和短时段的压力测试。
ThreadSanitizer更常用,编译时加上选项即可:
g++ -fsanitize=thread -g -O1 -pthread test.cpp -o testTSan能检测数据竞争和锁顺序逆反(lock-order inversion),对数据竞争很敏感。要注意TSan会明显拖慢程序,适合在CI环境跑集成测试,不太适合直接上生产。Helgrind和TSan各有侧重,配合使用效果更好——前者对锁顺序更敏感,后者对数据竞争覆盖面更广。
我自己的流程是:本地开发用TSan跑功能测试,每次合入前在CI跑一轮helgrind的时间窗口测试,双保险之后才敢上压测环境。
4.3 从日志和锁状态里反推现场
很多死锁现场是事后用日志拼出来的。最简单的做法是在加锁解锁关键路径上打trace日志,带上锁的唯一标识和线程ID。日志时间线能显示谁在什么时刻持有哪把锁、又等了多久,比看代码猜线索高效得多。
再进一步,可以给锁做一个轻量包装,记录持锁线程id、加锁时间戳、锁的获取顺序链表。平时开销很低,一旦检测到“某线程持锁超过N秒”就主动报警,很多隐患会在变成事故之前被提前揪出来。这个包装锁不一定复杂,一个类加一个全局注册表就能实现,但收益非常直接。
4.4 死锁之后的恢复手段
死锁一旦真发生,恢复手段其实有限。线上优先级永远先恢复可用性,重启进程通常是最快的办法。重启前尽量用gdb或core dump把线程栈留下,否则下回又是盲查。
如果重启代价高,可以设计看门狗机制:监控关键线程的心跳,卡死超过阈值就自动重启对应服务。这个方法能兜底,但不解决根源——真正要做的还是靠评审和压测把死锁扼杀在发布之前。一句话总结:恢复手段是止损,不是治病。
5. 让“避免死锁”成为团队习惯
5.1 编码规范该写哪几条
我们组现在有几条硬性规则,都是拿事故换来的:
- 禁止在持锁状态下调用外部回调、网络IO、磁盘IO。
- 所有互斥量必须登记编号,拿锁只能按编号从小到大。
- 嵌套加锁必须走代码评审,且要有明确的锁顺序依据。
- 新模块引入新锁时,必须说明锁的归属层级和可重入策略。
这几条看起来简单,但每一条背后都有一次线上事故。规范的价值不在于写得漂亮,而在于评审时有据可依,新人培训时不至于靠口口相传。
5.2 代码评审的死锁检查清单
Review并发代码时,我会专门盯几个点,每次都能抓出问题:
- 每个
lock_guard的作用域在哪,什么时候释放锁。 - 有没有同时给两个mutex加锁的地方,如果有,顺序是否和全局约定一致。
- 锁内有没有调用回调、虚函数、其他模块接口。
- 有没有可能出现“锁内等待别人、锁外等待自己”的互相等待。
这份清单很短,但针对性很强。我建议把它写进团队的Pull Request模板里,让提交者自己先过一遍,评审人再逐项核对,能省下大量沟通成本。
5.3 压测和并发测试怎么设计才能发现问题
并发bug的可怕之处在于概率低。想提高复现机会,压测时让多个线程同时执行不同的业务路径,而不是一窝蜂跑同一个操作,竞争场景更丰富。另一个技巧是故意加大临界区窗口,比如在临界区里临时sleep几十毫秒,把死锁发生的窗口拉宽,让问题更容易暴露。
随机化操作序列也很重要。线程交替执行“加钱”“减钱”“查询余额”这类不同路径,比固定顺序更容易触发锁顺序的交叉点。一旦测试环境复现死锁,立刻保存core文件并贴上线程栈,这个步骤最好自动化,谁都能按按钮复现,而不是依赖某个老手加班蹲现场。
5.4 监控与兜底:死锁的第一信号往往不是崩溃
死锁不一定表现为服务崩溃,更多时候是某类请求的P99快速恶化、线程池队列堆积、CPU却不高。监控可以盯三块:关键线程的心跳状态、每把锁的平均等待时间、业务超时分布。指标出现异常,通常是并发问题最早发来的信号。
我的习惯是给锁等待时间单独设一个监控面板。正常情况下锁等待都在微秒到毫秒量级,一旦某个锁的等待突破秒级,说明有线程持锁时间异常,或者已经形成了等待环。这时候不用等用户投诉,运维就能先收到告警,处理窗口会宽得多。
最后说几句实际体会
我自己这几年写并发代码,最大的改变是不再自信地以为“我知道所有锁在哪里”。锁的顺序、粒度、是否持锁调用外部代码,这些都已经当成公共契约写进代码注释里。每加一把新锁,先问一句:它会不会打破全局锁序?会不会在某个中间环节和已有锁形成闭环?
这套习惯看起来很繁琐,但比起半夜爬起来看线程栈、第二天黑着眼圈和同事复盘事故,这点成本实在不算什么。死锁这东西,一旦在线上发生,哪怕只影响五分钟,带来的身心折腾也远远超过在开发阶段多花半小时做一次并发走查。希望这篇文章能帮你把该避的坑提前避掉。