ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++并发编程死锁全解析:成因、陷阱与排查实战

C++并发编程死锁全解析:成因、陷阱与排查实战 写这篇东西的起因是我前阵子帮一个朋友排查线上服务卡顿问题。现象很典型服务进程还在CPU占用率却低得不像话请求就像进了黑洞一个都出不来。用调试器挂上去一看两个线程各自握着一把锁正互相等着对方释放另一把锁。这是教科书级别的死锁场景我就那么水灵灵地撞上了。这种事情在并发编程里太常见了。C里一旦用了多线程、共享数据锁就是必需品。但锁这东西用好了是护身符用不好就是连环索。这篇博客我就把死锁这件事拆开揉碎讲清楚死锁是怎么产生的、真实项目里最常见的几种死锁陷阱、如何从设计层面彻底规避以及真出事之后怎么快速定位。不管你是刚接触C并发的新手还是已经写了几年多线程代码的老手只要你的代码里出现过“线程卡住、程序假死、偶尔抽风”这篇文章都值得你花十分钟看完。1. 死锁是什么为什么说它比崩溃更可怕1.1 程序崩溃了你反而省心很多年轻的开发者对崩溃深恶痛绝但说实话在并发世界里崩溃往往比死锁“仁慈”得多。程序崩溃了有core dump有调用栈定位问题只是时间问题。死锁不一样它不会给你留任何现场进程看起来活着线程却永久性阻塞日志戛然而止监控显示响应时间飙升但已经无力回天。死锁的本质用一句话概括多个线程彼此持有对方需要的资源同时又在等待对方释放自己需要的资源形成了一条循环依赖链。想象一下这样一个场景你和同事在同一间办公室办公室里只有一台打印机和一台扫描仪。你拿了打印机正准备扫描同事拿了扫描仪正准备打印你们俩谁也不肯先放下手里的东西又都急着用对方手里的设备。活人能被两台机器憋死这就是死锁。死锁之所以比崩溃可怕是因为它具备“隐蔽性”和“规模性”两个特点。隐蔽性在于产生死锁的时序条件不一定每次都会满足——可能跑几十小时甚至几天才触发一次日常测试根本测不出来。规模性在于一旦死锁发生往往不是卡住一个线程而是由连锁反应拖垮整个进程。我之前见过一个案例一个持锁线程卡住之后其他线程在等待同一把锁时也全部堆积最终服务的所有工作线程陷入瘫痪。1.2 死锁的四个必要条件计算机科学家早就把这套事总结成理论了。死锁产生需要同时满足四个必要条件互斥条件一个资源每次只能被一个线程占用。持有并等待一个线程已经持有了至少一个资源同时又去申请其他资源。不可剥夺线程持有的资源在自己主动释放之前不能被其他线程强行抢走。循环等待存在一个线程间的循环链链里的每个线程都在等待链中下一个线程持有的资源。这四个条件缺一不可。所以避免死锁的基本思路也就出来了只要打破其中任意一个条件死锁就成不了。但这四个条件里互斥是并发控制的根基没法去掉不可剥夺在大多数场景下也做不到——总不能把一个线程手里的锁强行扒下来持有并等待可以通过一次性获取所有资源来规避但这个约束在复杂系统里很难落地。所以环顾一圈最实用的突破口其实就剩一个消除循环等待。我们在写代码时能控制的最有效手段就是对锁的获取顺序进行全局统一约定。所有线程在需要多把锁时都严格按照从编号小到大或者逻辑层级从高到低的顺序加锁循环等待的结构就会被一刀切断。这个思路说起来简单但在真实项目里执行起来远比想象中困难得多因为它要求每一个写并发代码的同事都有这种全局意识。一旦有人在某个角落里偷偷违反了约定死锁就意味着随时会回来找你。2. 真实项目里最常见的四类死锁陷阱说完了理论咱们聊聊实战。这些年我看了不少死锁现场发现真正恶劣的死锁通常不是来自于宏大的设计错误而是藏在一些看似无害的小细节里。下面四个场景你大概率在代码里见过甚至踩过其中一两个。2.1 锁顺序不一致AB-BA 问题这是最教科书级别的死锁也是实际项目里出现频率最高的死锁形式。两个线程都持有一把锁然后去申请对方已经持有的另一把锁// 线程A std::lock_guardstd::mutex lock1(mtx_a); // 做一些操作... std::lock_guardstd::mutex lock2(mtx_b); // 等待B // 线程B std::lock_guardstd::mutex lock2(mtx_b); // 做一些操作... std::lock_guardstd::mutex lock1(mtx_a); // 等待A线程A先拿A锁再申请B锁线程B先拿B锁再申请A锁。如果A和B的获取动作交替穿插两个线程就会在对方那里卡住。为什么这个问题这么难防因为真实的项目代码里这两个加锁动作可能相隔几十行、散布在不同函数里甚至藏在不同的模块中。你很难只读一个函数的代码就发现全局顺序存在冲突。比如两个模块各自封装了内部状态一个模块需要先锁资源A再锁资源B另一个模块却习惯先锁资源B再锁资源A当它们通过回调或者接口互相调用时死锁就像地雷一样埋好了。2.2 持锁调用未知代码这个坑比锁顺序不一致更隐蔽。很多时候我们会写这样的代码std::lock_guardstd::mutex lock(mtx); // 更新自己模块的状态 update_state(); // 通知其他模块 notify_listeners();表面上看没什么问题。但notify_listeners是一个回调接口它内部可能会调用其他模块的代码。如果那个模块里恰好需要获取自己的另一把锁而那个被通知的模块同时又要调用回调接口的模块持有锁你就被迫在三层函数调用之外参与了一次环形持锁循环。处理这类问题有一条红线不要轻易在你持有锁的情况下调用外部回调接口。如果实在需要回调就应该把回调动作移出锁的保护范围。最稳妥的做法是在锁内只修改共享状态把需要通知的消息记录下来等锁释放之后再去执行真正的通知动作。这是实际工程中减少死锁概率的重要手段之一也是“锁外通知”模式的价值所在。2.3 与条件变量混用时的绕圈现象条件变量与互斥锁配合使用时如果使用者对“先释放锁还是先发通知”的顺序理解有偏差也会出现类似的循环等待。问题最常见于生产者-消费者模型中// 消费者 std::unique_lockstd::mutex lock(mtx); cv.wait(lock, []() { return !queue.empty(); }); auto item queue.front(); queue.pop();这是一个标准写法。但如果你在生产者那边写完cv.notify_one()之后又习惯性地顺手做大量耗时操作或者持有锁的时间过长消费线程的wait可能迟迟得不到锁重新执行的时机进而让其他等待同一把锁的线程堆积。虽然这种问题不一定形成严格的死锁但在多线程协作环境里它会造成“假死”的体验——任务就是拖延着不推进和死锁的表现形式极其相似。更值得警惕的是wait的虚假唤醒问题。用cv.wait(lock, predicate)这种带谓词的形式能规避大部分风险但如果你用的是cv.wait(lock)裸等待就一定要在唤醒后重新检查条件否则很容易在条件不成立的情况下继续执行逻辑导致后续根本不存在的资源申请甚至触发异常路径中锁不匹配的问题。2.4 同一个线程重复加锁这种情况通常出现在代码重构之后。一个人把原本不互斥的两个函数合并到了一个更大的逻辑里而这两个函数内部各自对同一个互斥量加了锁。同一线程重复对std::mutex加锁行为是未定义的在Linux的glibc实现下多数会导致程序直接std::terminate或者永久卡死。很多刚入门的同学会误以为“既然是同一个线程加两遍当然没问题”这是对非递归互斥量的典型误解。如果你预计某个函数“可能”会被已经持锁的同一线程再次调用那确实有两种选择。一种是使用std::recursive_mutex允许同一线程多次加锁每次加锁对应一次解锁另一种更推荐的做法是重构代码逻辑保证对外暴露的接口内部不会重复加锁——比如把真正需要加锁的核心逻辑放到一个私有函数里公开接口先加锁再调用内部函数不再加锁。递归互斥量虽然好用但它会让锁的状态变得难以追踪我见过不少案子就是因为递归锁加多了层次反而掩盖了锁顺序的设计缺陷。3. 避免死锁的实用策略与工具选型理论讲完了陷阱也列完了。这一节我集中说怎么在设计和编码阶段就把死锁拒之门外。3.1 最硬的一条规则全局锁顺序项目里凡是存在多把锁交叉使用的地方我要求团队必须建立一张锁层级表。比如锁A是第一层锁B是第二层锁C是第三层。任何线程在获取多把锁时都必须按照层级从低到高的顺序获取禁止反向加锁。这样所有线程的加锁方向保持一致循环等待的结构就不存在了。这张锁层级表必须写进团队文档里Review代码的时候要逐行检查。听上去很繁琐但这是成本最低的防死锁手段。我在实际项目中见过坚持这个规矩的团队基本上从未在新增代码里出现过一次死锁事故反而是一些看起来“可以灵活处理”的团队总是隔几个月就冒出一次线上卡顿。3.2 std::scoped_lock 与 try_lock 的正确用法C17 引入的std::scoped_lock是一个非常好用的东西它支持同时传入多把锁并保证在内部以不会造成死锁的顺序进行加锁std::scoped_lock lock_all(mtx_a, mtx_b);很多已经用上 C17 的开发者仍然习惯自己逐一加锁这其实是在放弃编译器/标准库帮你做安全排序的机会。std::scoped_lock内部对多锁的获取顺序做了优化虽然具体策略取决于实现但至少不会因为你的加锁顺序失误直接死锁。从代码风格看它还把“若干把锁需要同生共死”这个语义表达得一目了然。那如果硬件资源确实紧张你需要的不是同时加锁而是“尝试加锁”呢这时轮到std::try_lock了。它能依次尝试获取多把锁一旦获取失败会释放已经获取到的锁避免半路卡死。配合循环重试或者退避策略在低竞争场景下是可行的。我的建议是优先使用std::scoped_lock的多锁版本至于std::try_lock适合在无法确定全局锁顺序又想尽力规避死锁时的兜底方案但它会引入额外竞争不要让它在高频路径上频繁重试。3.3 作用域最小化把锁的粒度做小持有锁的时间越长发生死锁的概率就越大这个道理你应该听过无数次但真正写代码时还总忘。最典型的错误是取一个很粗的锁把整个业务流程包在里面连网络请求、文件IO、数据库查询都一把锁保护了。这不仅仅是性能问题更是死锁的重灾区——你永远不知道一个网络IO对端会持有什么样的锁。正确做法是把锁的作用域限制在真正保护共享数据的临界区内。在进入临界区之前完成数据的准备在离开临界区之后执行耗时操作。举例来说// 不好的写法锁内做IO std::lock_guardstd::mutex lock(mtx); auto data load_data_from_database(); cache_[key] data;// 好的写法先取数据再锁或先锁再放到本地再解锁再IO std::optionalData data; { std::lock_guardstd::mutex lock(mtx); auto it cache_.find(key); if (it ! cache_.end()) { data it-second; } } if (!data.has_value()) { data load_data_from_database(); std::lock_guardstd::mutex lock(mtx); cache_[key] *data; }这个例子里的第二段代码虽然多写了几行但它在锁内只做了哈希查找和指针复制数据库IO完全在锁外执行。这样做不仅显著降低了死锁概率还让锁的竞争压力下降了至少一个量级。在我的眼里锁粒度是最值得花时间打磨的并发优化点。3.4 C20 的 jthread 与协作式取消C20 带来的std::jthread不只是“joinable thread”这么简单它引入了std::stop_token这个协作式取消令牌。很多人可能觉得这和死锁没关系但其实关系重大。死锁经常会让你面临两难线程卡住了到底该不该强制杀掉如果不杀系统继续卡如果杀了线程持有的锁就永远没人释放了其他线程照样卡。std::jthread的取消机制让线程有机会在被请求停止时主动检查取消点清理资源、释放锁然后安全退出。比如std::jthread worker([this](std::stop_token st) { while (!st.stop_requested()) { std::unique_lockstd::mutex lock(mtx_); if (data_ready_.wait_for(lock, st, 100ms)) { process(); } } }); // 在别的地方请求停止 worker.request_stop();这种协作式取消设计让线程有了“优雅退出”的能力而不是被外部手段粗暴中断。即便真的出现锁顺序设计失误导致的持锁阻塞由于没有强制中断机制问题仍需要从源头解决但协作式取消至少能帮助你在正常的生命周期管理里安全地结束任务配合wait_for带stop_token的超时等待能够把长时间无响应的线程拉回来重新审视。这在复杂系统里是可观测性和可运维性的巨大提升。4. 死锁的排查与调试实录就算你把前面的策略都用了生产环境总还是可能出现幺蛾子。一旦怀疑死锁怎么快速定位4.1 gdb 抓现场的正确姿势在Linux环境下死锁现场最直接的定位方式是 gdb。我和团队经常这么操作gdb -p pid (gdb) thread apply all btthread apply all bt会打印所有线程的调用栈。在死锁场景下你通常能看到两个或以上的线程阻塞在__lll_lock_wait或std::mutex::lock这类系统调用上。把它们的调用栈合起来看就能还原出每一方持有什么锁、在等待什么锁。这里有一个细节经验如果进程卡了很久用thread apply all bt通常能直接看到问题。但如果进程只是“间歇性卡顿”抓线程堆栈时要多抓几次观察哪些线程的堆栈总在同一个位置。反复出现在锁等待位置的那几条堆栈就是最可疑的死锁源。还有如果能配置 core dump 尽早配置服务异常时保留现场排查效率会高很多。4.2 日志法给每一把锁编号gdb 适合事后发现场但有时候死锁发生的环境你访问不到尤其是线上环境没有足够的调试权限。这时候就要靠日志法。我常用的方案是给每一把锁分配一个唯一编号并在加锁和解锁的关键位置输出精简日志。遇到卡顿问题时看最后几条日志就能大致还原出锁的获取顺序轨迹。要做到这个效果我一般封装一个TrackedLock包裹std::mutex记录持有线程 ID 和锁编号class TrackedMutex { public: explicit TrackedMutex(int id) : id_(id) {} void lock() { auto tid std::this_thread::get_id(); spdlog::info(Try lock {} from thread {}, id_, tid); mutex_.lock(); holder_ tid; spdlog::info(Acquired lock {} by thread {}, id_, tid); } void unlock() { spdlog::info(Release lock {} by thread {}, id_, holder_); holder_ {}; mutex_.unlock(); } private: int id_; std::mutex mutex_; std::thread::id holder_; };这段代码只是示例生产环境里真正的实现要考虑日志性能消耗、是否要带时间戳、能否和 tracing 系统联动。用这个思路一旦出现卡顿日志里就能清楚看到哪个线程持有了哪把锁、正在等待哪把锁。我在实际项目中排查过一次线上间歇性卡顿日志法帮我在两个小时之内锁定了问题线程和锁对象而 gdb 完全进不了现场。4.3 避免活锁与饥饿的额外提示排查死锁的时候很多人喜欢把所有并发问题都往死锁上靠。实际上活锁和饥饿的行为表现和死锁很相似但处理方式完全不同。活锁指的是线程不断尝试但始终无法取得进展比如两个线程反复谦让互相让步结果谁也没干活饥饿则是一个线程长期得不到锁的访问权。我的经验是如果你的程序CPU占用很高但任务不推进优先怀疑活锁CPU占用很低但线程全部卡住优先怀疑死锁。这两种情况的排查路径不一样死锁重点看锁等待链活锁重点看循环逻辑和超时重试的退避策略。4.4 常见问题速查表我把平时在项目里积累的问题收敛成一份速查表排查并发问题时直接从表里找方向现象可能原因定位手段进程活着但请求超时、CPU占用低死锁线程阻塞在锁等待gdb 全部线程堆栈观察多条堆栈停在锁等待进程CPU占用高但任务不推进活锁或激烈锁竞争样本抓取统计热点函数偶尔卡死几十秒后恢复持锁线程做了慢IO或GC停顿日志追踪锁持有时间限流大IO程序直接崩溃coredump重复加非递归锁检查递归路径是否对同一mutex二次lock多线程性能严重下降锁粒度过大、竞争剧烈拆锁、读写锁、无锁化优化这张表不敢说覆盖所有情况但能帮你快速梳理排查思路。更重要的原则是遇到问题先保留现场再动手猜疑。很多人在排查死锁时一上来就改代码反而破坏了原有现场让问题更难复现。5. 再分享几个我在实战中积累的细节前面讲了方案和排查最后这几条是我个人在实际项目中沉淀下来的习惯不写进教科书但对日常工作特别有用。坚持锁与数据的不变式。定义每一把锁时明确它到底保护哪一份共享数据。代码评审里如果出现“用一把大锁保护了好几个不相干的数据结构”我通常会要求拆锁。锁与数据的对应关系模糊是死锁风险增长的前兆。持锁时间要像保护女神一样保护能短则短。我给自己定过一条线任何持锁临界区内不准出现动态内存分配、系统调用、网络IO、磁盘IO连std::cout都尽量别碰。这条红线执行之后代码稳定性和并发能力都有了明显提升。测试阶段用压力测试尽量提高并发概率。-fsanitizethread是C编译器提供的线程擦除检测工具它能帮你捕捉数据竞争和一部分加锁顺序问题。在我组织的压测里Stress 环境能让潜在的死锁问题在很小的概率下浮出水面这比上线后被用户骂回去强太多了。写代码的时候凡是涉及多把锁的地方在注释里写清楚加锁顺序。这句注释看起来没什么用但对后来维护的人而言可能就是救命绳。我见过太多因为“看不懂前人的锁顺序”而踩坑的新人一句注释能省下整个团队一天排查时间。死锁这个话题到底还是要在实战中摸爬滚打才记得住。希望这篇分享能帮你少走几步弯路。如果你在项目中碰到过更有意思的死锁案例也欢迎交流。搞并发编程的都不容易互相借个火路就好走多了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进