
前阵子有个线上服务半夜报警某个核心接口的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在等m1Worker2持有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_guardstd::mutex guard1(m1); std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::lock_guardstd::mutex guard2(m2); // 转账逻辑 } void transfer2() { std::lock_guardstd::mutex guard2(m2); std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::lock_guardstd::mutex guard1(m1); // 转账逻辑 }这个例子在并发测试里非常经典。两个线程一个按“m1 - m2”的顺序拿锁另一个按“m2 - m1”的顺序拿锁只要时机凑巧就会双双卡在第二个lock_guard的构造函数上。这里我特别想提醒一点代码里那两行sleep_for是故意的。很多人在测试环境复现不了死锁不是因为代码没风险而是竞争窗口太小两个线程根本没机会同时卡在中间。往临界区里加一个临时sleep把获得第一把锁之后、等待第二把锁之前的窗口拉大死锁就变得“可复现”了。这是压测阶段定位死锁的一个非常实用的技巧后面讲测试方法时还会再说。修复最省事的方式是用C17的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_guardstd::mutex guard(m); // 做一些处理 innerUpdate(); // 半路调用了自己人 } void innerUpdate() { std::lock_guardstd::mutex guard(m); // 同一个线程再次加锁同一个mutex // ... }std::mutex是不可重入的同一个线程对同一个mutex连续加锁两次结果是未定义行为——现实中绝大多数表现为直接卡死在这里。新手遇到这个问题第一反应是换成std::recursive_mutex让同一线程可以重复加锁。我的建议是不要急着这么干。递归锁本质上掩盖了一个设计问题临界区边界到底划在哪一层外层函数该不该持有锁去调用内层函数如果用了递归锁你只是让代码“能跑”但锁的粒度和职责变模糊了以后别人在锁内调用其他可能加锁的接口时死锁风险只会更高。更合理的做法是把锁拆开保证内层函数不再需要外层那部分锁保护或者把调用挪到锁外让职责边界干净起来。2.3 持锁调用外部代码锁通过回调“接上环”还有一种特别隐蔽的死锁问题出在“持锁调用外部代码”上。最常见的就是在锁内执行回调函数std::mutex mutex; std::unordered_mapint, std::functionvoid() handlers; void dispatch(int id) { std::lock_guardstd::mutex guard(mutex); auto it handlers.find(id); if (it ! handlers.end()) { it-second(); // 外部注册进来的回调里面可能再拿别的锁 } }这段代码表面上没问题锁也加了查找也做了。但危险不在dispatch函数本身而在回调是别人注册进来的——它内部完全可能再取一把别的锁甚至绕一圈又取回当前这把锁。一旦模块之间互相注册、互相调用锁依赖图就会在你看不见的地方悄悄闭合形成死锁环。重构思路很朴素锁内只拷贝回调对象出了锁再执行void dispatch(int id) { std::functionvoid() callback; { std::lock_guardstd::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_lockstd::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”语义让“持有并等待”的窗口尽可能小。如果你还在用C11没有scoped_lock可以用std::lock配合std::unique_lock的defer_lock手动实现同样效果std::unique_lockstd::mutex g1(m1, std::defer_lock); std::unique_lockstd::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_lockstd::timed_mutex才有try_lock_for和try_lock_until。try_lock的语义是“拿不到就算了”不再死等下去std::timed_mutex m1, m2; bool tryBothLocks() { std::unique_lockstd::timed_mutex g1(m1, std::defer_lock); if (!g1.try_lock_for(std::chrono::milliseconds(100))) { return false; } std::unique_lockstd::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 --toolhelgrind ./your_program会在程序运行到锁顺序逆反时输出告警并附带着持有锁的调用栈。缺点是慢通常用来跑单元测试和短时段的压力测试。ThreadSanitizer更常用编译时加上选项即可g -fsanitizethread -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却不高。监控可以盯三块关键线程的心跳状态、每把锁的平均等待时间、业务超时分布。指标出现异常通常是并发问题最早发来的信号。我的习惯是给锁等待时间单独设一个监控面板。正常情况下锁等待都在微秒到毫秒量级一旦某个锁的等待突破秒级说明有线程持锁时间异常或者已经形成了等待环。这时候不用等用户投诉运维就能先收到告警处理窗口会宽得多。最后说几句实际体会我自己这几年写并发代码最大的改变是不再自信地以为“我知道所有锁在哪里”。锁的顺序、粒度、是否持锁调用外部代码这些都已经当成公共契约写进代码注释里。每加一把新锁先问一句它会不会打破全局锁序会不会在某个中间环节和已有锁形成闭环这套习惯看起来很繁琐但比起半夜爬起来看线程栈、第二天黑着眼圈和同事复盘事故这点成本实在不算什么。死锁这东西一旦在线上发生哪怕只影响五分钟带来的身心折腾也远远超过在开发阶段多花半小时做一次并发走查。希望这篇文章能帮你把该避的坑提前避掉。