尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++多线程死锁:成因、避免策略与调试技巧

C++多线程死锁:成因、避免策略与调试技巧 1. 死锁多线程编程中的“交通瘫痪”搞过多线程开发的兄弟估计没几个没被死锁折腾过。这东西就像十字路口四个方向的车都堵在路口中间谁也不让谁结果就是整个交通系统彻底瘫痪程序卡死在那里CPU可能还在空转但逻辑已经不再推进。在C里尤其是用std::thread、std::mutex、std::lock_guard这些标准库工具构建并发系统时死锁是一个必须正面硬刚的敌人。它不像数据竞争那样可能时隐时现死锁一旦发生程序往往就“定”在那里了不重启或外部干预很难恢复。今天我们就来彻底扒一扒死锁的底裤看看它到底是怎么形成的以及我们手里有哪些趁手的兵器可以避免它。简单说死锁就是两个或更多的线程在执行过程中因争夺资源而造成的一种互相等待的现象。每个线程都持有对方下一步需要的资源同时又等待对方释放自己当前需要的资源结果就是所有相关线程都无限期地阻塞下去。理解死锁不仅是应付面试八股文更是写出健壮、可靠并发代码的基本功。接下来我们从它的成因开始一步步拆解。1.1 死锁产生的四个必要条件死锁不是凭空出现的它的发生必须同时满足四个条件这四个条件由Coffman等人提出已经成为分析死锁的经典理论。理解它们是预防和解决死锁问题的钥匙。1. 互斥条件资源在一段时间内只能被一个线程或进程占用。比如一个std::mutex对象在某个时刻只能被一个线程锁住。如果资源可以同时共享就不会有等待自然也就没有死锁。2. 请求与保持条件一个线程在持有至少一个资源的同时又提出了新的资源请求而该新资源恰好被其他线程持有。这个线程会阻塞在等待新资源的地方但不会释放自己已经持有的资源。这是死锁形成的关键一步。3. 不剥夺条件线程已获得的资源在未使用完之前不能被其他线程强行剥夺只能由持有者主动释放。在C中我们无法强行解锁另一个线程持有的互斥锁std::mutex::unlock必须由锁的持有者调用这正符合不剥夺条件。4. 循环等待条件存在一个线程-资源的循环等待链。比如线程A持有资源R1等待资源R2而线程B持有资源R2等待资源R1。这就形成了一个A-B-A的循环等待。这是死锁的最终表现形式。这四个条件必须同时成立死锁才会发生。因此我们避免死锁的思路就是想办法破坏其中至少一个条件。在C多线程编程的实践中我们主要从破坏“请求与保持条件”和“循环等待条件”入手。2. C中典型的死锁场景与代码还原光讲理论有点干我们直接看代码。下面我通过几个经典的例子还原死锁是如何在C代码中悄然发生的。你可以把这些代码复制到你的IDE里跑一下亲眼看看死锁的“风采”当然可能你需要手动终止进程。2.1 场景一锁顺序不一致导致的经典死锁这是最常见的一种死锁。当多个线程需要获取同一组锁或多个互斥资源时如果它们获取锁的顺序不一致就极有可能导致循环等待。#include iostream #include thread #include mutex std::mutex mutex1; std::mutex mutex2; void thread_a_work() { std::cout Thread A: 尝试获取 mutex1... std::endl; std::lock_guardstd::mutex lock1(mutex1); // 先锁 mutex1 std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟一些工作增加死锁概率 std::cout Thread A: 已获得 mutex1 尝试获取 mutex2... std::endl; std::lock_guardstd::mutex lock2(mutex2); // 再请求 mutex2 std::cout Thread A: 工作完成 std::endl; } void thread_b_work() { std::cout Thread B: 尝试获取 mutex2... std::endl; std::lock_guardstd::mutex lock2(mutex2); // 先锁 mutex2 std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟一些工作 std::cout Thread B: 已获得 mutex2 尝试获取 mutex1... std::endl; std::lock_guardstd::mutex lock1(mutex1); // 再请求 mutex1 std::cout Thread B: 工作完成 std::endl; } int main() { std::thread t1(thread_a_work); std::thread t2(thread_b_work); t1.join(); t2.join(); std::cout 主线程结束。 std::endl; return 0; }运行这段代码你很可能会看到程序打印出前几行信息后就永远停在那里了。我们来分析一下时间线t1启动锁定了mutex1然后睡眠。t2启动锁定了mutex2然后睡眠。t1醒来试图锁定mutex2但发现mutex2已被t2持有于是t1阻塞等待mutex2。t2醒来试图锁定mutex1但发现mutex1已被t1持有于是t2阻塞等待mutex1。死锁形成t1持有mutex1等待mutex2t2持有mutex2等待mutex1完美的循环等待。注意由于线程调度的不确定性这个死锁不是100%发生。如果t1在t2开始之前就完成了所有工作获取并释放了两个锁那么程序可能正常结束。但并发程序的设计绝不能依赖这种“幸运”我们必须假设最坏情况会发生。2.2 场景二在持有锁时调用未知函数这种死锁更隐蔽危害也更大。当你持有一个锁然后去调用一个函数而这个函数内部可能又会去获取另一个锁或者甚至是同一个锁如果是递归锁且未正确使用的话。#include iostream #include thread #include mutex std::mutex global_mutex; int shared_data 0; // 一个“看似无害”的公共函数 void public_api_function() { std::lock_guardstd::mutex lock(global_mutex); // 内部也使用了同一个全局锁 shared_data; std::cout Public API: shared_data shared_data std::endl; } void my_function() { std::lock_guardstd::mutex lock(global_mutex); // 先获取锁 std::cout My Function: 做了一些工作... std::endl; // 危险操作在持有锁的情况下调用一个可能也要锁的函数 public_api_function(); // 这里会尝试再次获取 global_mutex std::cout My Function: 结束。 std::endl; } int main() { // 如果 global_mutex 是普通的 std::mutex非递归锁这里直接就会死锁 // 因为同一个线程试图重复锁定一个非递归互斥量是未定义行为通常会导致阻塞。 my_function(); return 0; }对于普通的std::mutex在同一个线程中重复加锁会导致未定义行为通常是永久阻塞即死锁。如果你需要一个线程可以多次获取同一把锁应该使用std::recursive_mutex。但更重要的教训是尽量避免在持有锁的情况下调用外部或复杂的函数因为你无法完全掌控其内部实现。如果必须调用需要非常清楚其内部同步机制。2.3 场景三单线程内的“自死锁”听起来有点矛盾但确实会发生。主要出现在错误地使用锁的时候。#include iostream #include mutex int main() { std::mutex mtx; { std::lock_guardstd::mutex lock(mtx); std::cout 第一次加锁成功 std::endl; // 错误尝试在同一个作用域内试图再次“管理”这个锁 // std::lock_guardstd::mutex another_lock(mtx); // 如果取消注释将导致死锁 // lock_guard 的构造函数会调用 mtx.lock()而 mtx 已被当前线程锁定。 } // 第一个 lock_guard 析构释放锁 std::cout 程序结束 std::endl; return 0; }这种错误在新手使用std::lock_guard或std::unique_lock时容易出现。记住一个std::mutex对象在某一时刻只能被一个lock_guard或unique_lock对象管理其锁定状态。试图创建第二个管理对象去锁定同一个未被释放的互斥量就会导致阻塞。3. 实战策略如何系统性地避免死锁知道了死锁怎么来的我们就能有针对性地防御。下面这些策略不是孤立的在实际项目中往往会组合使用。3.1 核心策略固定锁顺序这是解决“锁顺序不一致”死锁最直接有效的方法。原理很简单如果所有线程都按照一个全局约定的、固定的顺序去获取锁那么循环等待的条件就不可能成立。如何定义顺序可以给每个需要加锁的资源比如互斥量分配一个唯一的ID或层级然后规定所有线程都必须按照ID从小到大的顺序或层级从低到高的顺序来申请锁。// 假设我们有三个需要保护的数据结构每个都有自己的互斥量 std::mutex mutex_for_resource_a; // 我们定义它为顺序1 std::mutex mutex_for_resource_b; // 顺序2 std::mutex mutex_for_resource_c; // 顺序3 void safe_operation_1() { // 正确的顺序A - B - C std::lock_guardstd::mutex lock_a(mutex_for_resource_a); std::lock_guardstd::mutex lock_b(mutex_for_resource_b); std::lock_guardstd::mutex lock_c(mutex_for_resource_c); // ... 操作资源A, B, C } void safe_operation_2() { // 即使这个函数只需要资源B和C也必须按顺序获取 // 先获取B顺序2再获取C顺序3。不能跳过A但A不需要就不获取。 // 但更常见的做法是即使不需要A也按顺序获取但这里只获取B和C也是安全的因为顺序一致。 // 然而为了绝对安全且规则简单最好约定获取任何锁都从顺序1开始检查。 // 但实际上只要保证B和C的获取顺序是B在前、C在后且这个顺序在所有线程中一致就破坏了循环等待。 std::lock_guardstd::mutex lock_b(mutex_for_resource_b); std::lock_guardstd::mutex lock_c(mutex_for_resource_c); // ... 操作资源B, C }实操心得在大型项目中维护一个全局的锁顺序文档或注释非常重要。当新增资源时必须为其分配合适的顺序位置。这个方法的缺点是降低了并发度可能过早地持有一些并不急需的锁并且对开发者的纪律性要求很高。3.2 利用标准库工具std::lock 和 std::scoped_lock手动维护锁顺序容易出错。C标准库提供了更安全的工具来一次性获取多个锁并且能避免死锁。其内部通常实现了某种死锁避免算法比如“尝试-回退”策略。C17之前使用std::lockstd::lock是一个函数模板可以一次性锁定两个或更多的互斥量且保证不会死锁。#include mutex #include thread std::mutex mtx1, mtx2; void safe_work_old_way() { // 使用 std::lock 同时锁定多个互斥量 std::lock(mtx1, mtx2); // 1. 一次性锁定无死锁风险 // 2. 但 std::lock 锁定后还需要用 lock_guard 管理所有权和释放 // 使用 std::adopt_lock 参数告诉 lock_guard互斥量已经锁定了你只管析构时解锁。 std::lock_guardstd::mutex lock1(mtx1, std::adopt_lock); std::lock_guardstd::mutex lock2(mtx2, std::adopt_lock); // ... 安全地操作受保护资源 } // lock1 和 lock2 析构自动解锁C17及之后首选std::scoped_lockstd::scoped_lock是std::lock_guard的增强版可以接收多个互斥量它在构造时自动调用std::lock来一次性获取所有锁析构时按相反顺序释放。代码更简洁安全。void safe_work_modern_way() { // 一行代码搞定构造时锁定所有互斥量析构时自动释放。 std::scoped_lock lock(mtx1, mtx2); // C17 // 或者对于两个锁也可以使用推导指南C17 // std::scoped_lock lock(mtx1, mtx2); // ... 安全地操作受保护资源 } // 自动解锁强烈建议如果你的项目支持C17或更高标准在处理多个互斥量时应毫不犹豫地使用std::scoped_lock。它极大地减少了因手动管理锁顺序而引入死锁的可能性。3.3 策略使用层次锁层次锁是固定锁顺序策略的一种面向对象实现。它为每一把锁分配一个数字层级并强制规定线程在持有高层级锁时不能去获取低层级的锁。这相当于在运行时动态检查锁顺序。#include stdexcept #include mutex #include thread #include iostream class hierarchical_mutex { std::mutex internal_mutex; unsigned long const hierarchy_value; // 当前锁的层级 unsigned long previous_hierarchy; // 之前线程的层级 static thread_local unsigned long this_thread_hierarchy; // 线程本地存储记录当前线程持有的最高层级 void check_for_hierarchy_violation() { // 试图获取的锁的层级必须低于当前线程的层级 if (hierarchy_value this_thread_hierarchy) { throw std::logic_error(mutex hierarchy violated); } } void update_hierarchy_value() { previous_hierarchy this_thread_hierarchy; this_thread_hierarchy hierarchy_value; } public: explicit hierarchical_mutex(unsigned long value) : hierarchy_value(value), previous_hierarchy(0) {} void lock() { check_for_hierarchy_violation(); internal_mutex.lock(); update_hierarchy_value(); } void unlock() { this_thread_hierarchy previous_hierarchy; // 恢复之前的层级 internal_mutex.unlock(); } bool try_lock() { check_for_hierarchy_violation(); if (!internal_mutex.try_lock()) { return false; } update_hierarchy_value(); return true; } }; // 初始化线程本地变量 thread_local unsigned long hierarchical_mutex::this_thread_hierarchy(ULONG_MAX); // 初始化为最大值 // 使用示例 hierarchical_mutex high_level_mutex(10000); // 高层级 hierarchical_mutex low_level_mutex(5000); // 低层级 void high_level_func() { std::lock_guardhierarchical_mutex hl_lock(high_level_mutex); // 先拿高层级锁 // 从高层级去拿低层级锁是允许的 std::lock_guardhierarchical_mutex ll_lock(low_level_mutex); std::cout High level function done. std::endl; } void low_level_func() { std::lock_guardhierarchical_mutex ll_lock(low_level_mutex); // 试图从低层级去拿高层级锁将会在 lock() 时抛出 std::logic_error // std::lock_guardhierarchical_mutex hl_lock(high_level_mutex); // 错误 std::cout Low level function done. std::endl; } int main() { std::thread t1(high_level_func); // 正常执行 std::thread t2(low_level_func); // 正常执行如果不尝试获取高层级锁 t1.join(); t2.join(); return 0; }层次锁将锁顺序的检查从开发者的“记忆”和“文档”转移到了运行时通过抛出异常来提前暴露违反层级规则的操作从而在测试阶段就能发现潜在的死锁风险。这是一种非常工程化的防御手段。3.4 策略避免嵌套锁与缩短锁作用域这是一个设计哲学层面的建议。避免嵌套锁尽量避免函数A持有锁Lock1然后调用函数B而函数B内部又试图获取Lock2。这种嵌套调用链是死锁的温床。如果逻辑必须如此请使用上面提到的std::scoped_lock一次性获取所有需要的锁或者在设计上重新审视看能否将资源访问拆分成更独立、锁粒度更小的操作。缩短锁作用域锁住互斥量的时间越短越好。只在对共享数据进行读写的那段关键代码上加锁。锁作用域外不要进行可能阻塞的操作比如文件I/O、网络请求、用户输入等待或者调用那些你不知道会不会阻塞的函数。// 不好的做法锁作用域太大 void process_data_slow() { std::lock_guardstd::mutex lock(data_mutex); std::string data read_from_shared_buffer(); // 快速操作 std::this_thread::sleep_for(std::chrono::seconds(5)); // 模拟一个耗时操作 result heavy_computation(data); // 另一个耗时操作 write_to_shared_buffer(result); } // 好的做法只锁住必须共享的部分 void process_data_fast() { std::string data; { std::lock_guardstd::mutex lock(data_mutex); data read_from_shared_buffer(); // 仅复制数据 } // 锁在这里就释放了 // 在锁外进行耗时的计算 auto result heavy_computation(data); { std::lock_guardstd::mutex lock(data_mutex); write_to_shared_buffer(result); // 仅写回结果 } }4. 死锁的调试、检测与排查技巧即使我们遵循了所有最佳实践在复杂的系统中死锁仍可能发生。当程序“卡住”时如何判断是死锁又如何定位问题所在4.1 观察与初步判断程序无响应但CPU占用率可能很低线程因为等待锁而阻塞不消耗CPU周期。使用调试器挂起程序在IDE如Visual Studio、CLion或使用GDB挂起程序查看所有线程的调用栈。如果发现多个线程的调用栈都停在pthread_mutex_lock、WaitForSingleObjectWindows或std::mutex::lock相关的内部函数上那死锁的嫌疑就很大。打印日志在加锁和解锁的地方添加详细的日志注意日志输出本身也可能成为性能瓶颈和同步点需谨慎。通过日志可以观察锁的获取顺序和持有时间。4.2 使用工具进行检测Helgrind 和 DRD这是Valgrind工具套件中的两个工具专门用于检测多线程程序中的数据竞争和死锁。它们通过模拟CPU来工作能非常有效地发现潜在的锁顺序问题。使用方法valgrind --toolhelgrind ./your_program。Clang ThreadSanitizer (TSan)一个在运行时检测数据竞争、死锁等并发错误的工具。在编译时添加-fsanitizethread标志即可启用。它比Valgrind更快但对系统调用有更多限制。操作系统和IDE内置工具例如Linux下的pstack、gdb的thread apply all bt命令Windows下Visual Studio的并行堆栈窗口、并发分析工具等都可以用来查看线程状态辅助分析。4.3 设计时加入超时机制对于某些锁如果怀疑可能发生死锁可以考虑使用带超时的锁获取方式。C提供了std::timed_mutex、std::recursive_timed_mutex以及std::unique_lock的try_lock_for/try_lock_until方法。std::timed_mutex tmtx; void thread_with_timeout() { std::unique_lockstd::timed_mutex lock(tmtx, std::defer_lock); // 尝试在100毫秒内获取锁 if (lock.try_lock_for(std::chrono::milliseconds(100))) { // 成功获取锁 std::cout Lock acquired successfully. std::endl; // ... 工作 } else { // 获取锁超时这可能意味着发生了死锁或严重的锁竞争。 std::cout Failed to acquire lock within timeout. Possible deadlock! std::endl; // 在这里可以记录错误、进行恢复操作如释放已持有的其他资源、或优雅降级。 } }超时机制不能防止死锁但它可以为系统提供一个“逃生舱口”避免整个系统无限期挂起。在超时后线程可以选择放弃操作、记录错误日志、触发告警或者进行一些恢复清理工作。这是一种提高系统韧性的手段。4.4 死锁排查清单当怀疑死锁时可以按以下清单进行排查确认所有相关线程的堆栈看它们是否都在等待某个锁。画出资源依赖图列出每个线程当前持有的锁和正在等待的锁检查是否存在循环。检查锁的获取顺序对照代码看不同线程对同一组锁的获取顺序是否一致。检查是否存在嵌套锁查看在持有锁的代码段中是否调用了其他可能获取锁的函数。检查单线程重复加锁确认是否错误地对非递归锁进行了重入锁定。简化与复现尝试构造一个最小的、可复现的测试用例。这往往能帮你更清晰地看到问题本质。5. 进阶话题与设计模式5.1 锁粒度与性能权衡避免死锁的许多策略如固定顺序、一次性获取所有锁可能会迫使你扩大锁的粒度或提前持有锁这可能会降低程序的并发性能。这就引出了锁粒度的问题粗粒度锁一个锁保护一大块数据或整个子系统。管理简单不易死锁但并发度低容易成为性能瓶颈。细粒度锁用多个锁分别保护不同的数据片段。并发度高但设计复杂极易引入死锁和数据竞争。如何选择没有银弹。通常的建议是先从粗粒度锁开始确保正确性。当性能分析Profiling表明锁竞争成为瓶颈时再考虑引入更精细的锁机制并辅以上述的死锁避免策略。正确性永远优先于性能。5.2 无锁编程与死锁彻底避免死锁的一个终极思路是不用锁。这就是无锁编程。通过使用原子操作std::atomic、内存顺序等机制来实现线程安全的数据结构。无锁数据结构天生免疫死锁。#include atomic #include iostream #include thread std::atomicint counter{0}; void increment_atomic() { for (int i 0; i 100000; i) { counter.fetch_add(1, std::memory_order_relaxed); } } int main() { std::thread t1(increment_atomic); std::thread t2(increment_atomic); t1.join(); t2.join(); std::cout Counter value: counter.load() std::endl; // 预期是200000 return 0; }但是无锁编程的难度极高。设计一个正确的无锁数据结构非常复杂容易出错比如ABA问题。除非你对性能有极端要求并且是并发编程专家否则建议优先使用基于锁的、更直观的设计并配合良好的死锁避免实践。5.3 使用RAII管理锁这一点在C中至关重要也是我们一直在用的std::lock_guard,std::unique_lock,std::scoped_lock。RAII确保在异常发生时锁也能被正确释放避免了因异常导致锁未被释放而引发的死锁。void risky_operation() { std::lock_guardstd::mutex lock(some_mutex); // 即使后面抛出异常锁也会在栈展开时释放 some_operation_that_might_throw(); // 不需要手动调用 unlock() }6. 总结与个人体会死锁是多线程编程中最令人头疼的问题之一因为它涉及多个线程和资源的交互问题现象程序挂起往往在特定时序下才出现调试复现困难。通过这次梳理我们可以看到应对死锁是一场“防御战”理解根源牢牢掌握死锁的四个必要条件这是所有防御策略的理论基础。使用工具善用C标准库提供的std::lock和std::scoped_lock它们是你避免多重锁死锁的第一道防线。建立规范在团队中确立并严格遵守锁顺序约定或者使用层次锁这样的机制将规范代码化。优化设计从软件设计层面减少锁的嵌套、缩短锁的持有时间、考虑锁的粒度。准备后手在关键部位考虑使用带超时的锁并为系统设计死锁发生时的检测、日志和恢复机制哪怕是优雅失败重启。在我自己的项目经验里最有效的办法其实是代码审查和设计评审。在写代码的时候每当看到lock()或者lock_guard的构造心里就要拉响警报这里会不会和另一个地方的锁产生顺序冲突我持有的锁会不会在调用某个函数时导致嵌套养成这种条件反射能避免大多数死锁问题。另外对于复杂的并发模块编写专门的多线程单元测试和压力测试非常重要。有些死锁问题在低并发下很难暴露需要通过高并发测试来触发。工具如ThreadSanitizer应该在CI/CD流水线中集成作为代码合并前的强制检查项。最后记住一句老话如果可能尽量避免共享数据。通过任务队列、Actor模型、线程局部存储等方式减少共享是从根本上杜绝数据竞争和死锁的高明手段。当共享不可避免时再拿起我们今天讨论的这些锁和策略谨慎地管理它们。多线程编程如履薄冰但理解了原理并掌握了工具你也能写出既高效又稳固的并发程序。
返回列表