第一部分:什么是死锁?
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力干涉,这些线程都将无法向前推进。
一个经典的死锁场景被称为 "哲学家就餐问题" :五位哲学家围坐一桌,每两人之间有一支筷子。哲学家要么思考,要么就餐。就餐时需要同时拿起左右两边的筷子。如果所有哲学家同时拿起左边的筷子,那么他们都会永远等待右边的筷子被释放,从而陷入死锁。
第二部分:死锁产生的四个必要条件(Coffman条件)
这四个条件必须同时满足 ,死锁才会发生。因此,我们的所有策略都围绕着破坏其中至少一个条件来展开。
- 互斥:一个资源每次只能被一个线程占用。
- 占有并等待:一个线程在持有至少一个资源的同时,又在等待获取其他线程持有的资源。
- 不可剥夺:线程已获得的资源在未使用完之前,不能被其他线程强行抢占。
- 循环等待:存在一个线程-资源的循环链,链中的每一个线程都在等待下一个线程占有的资源。
第三部分:死锁预防
死锁预防是一种静态策略,它在程序设计阶段就通过破坏死锁的四个必要条件之一来确保死锁不会发生。
1. 破坏"占有并等待"
-
思路:要求线程一次性申请它所需要的所有资源。如果无法满足,则该线程进入等待状态,直到所有资源都可用。
-
C++实现 :通常使用
std::lock或std::scoped_lock来一次性锁定多个互斥量。cpp#include <mutex> #include <thread> std::mutex mutex1, mutex2; void safe_function() { // 不好的方式:分别加锁,可能产生占有并等待 // mutex1.lock(); // mutex2.lock(); // 危险点! // 好的方式:使用std::lock一次性锁定多个互斥量,避免死锁 std::lock(mutex1, mutex2); // 使用lock_guard/adopt_lock来管理所有权,避免忘记解锁 std::lock_guard<std::mutex> lk1(mutex1, std::adopt_lock); std::lock_guard<std::mutex> lk2(mutex2, std::adopt_lock); // C++17 最佳方式:使用std::scoped_lock,它等价于上面的组合,但更简洁安全。 // std::scoped_lock lock(mutex1, mutex2); // ... 临界区操作 }
2. 破坏"不可剥夺"
-
思路:如果一个线程已经持有了一些资源,但在申请新资源时无法立即得到,它必须释放所有已占有的资源,以后需要时再重新申请。
-
实现 :这通常难以直接实现,因为强行释放一个线程持有的锁(如互斥量)可能会导致数据处于不一致的状态。但在某些高级并发模式(如使用
std::unique_lock和try_lock)中可以实现类似逻辑。cppstd::mutex mutex1, mutex2; void no_hold_and_wait() { std::unique_lock<std::mutex> lk1(mutex1, std::try_to_lock); std::unique_lock<std::mutex> lk2(mutex2, std::try_to_lock); while (!(lk1.owns_lock() && lk2.owns_lock())) { // 如果没能同时获得两个锁,就释放已经持有的锁,让出CPU,再重试 if (lk1.owns_lock()) lk1.unlock(); if (lk2.owns_lock()) lk2.unlock(); std::this_thread::yield(); // 让出时间片,避免忙等待 std::lock(lk1, lk2); // 重新尝试锁定 } // ... 临界区操作 }注意:这种方式可能导致活锁(Livelock),但通过随机退避可以缓解。
3. 破坏"循环等待"
-
思路:给所有资源定义一个严格的线性顺序。每个线程都必须按照这个顺序来申请资源。
-
C++实现:为互斥量分配一个全局的锁定顺序。
cppclass CriticalData { std::mutex mutex; }; CriticalData data1, data2; void thread_func_1() { // 总是先锁data1的mutex,再锁data2的mutex std::scoped_lock lock(data1.mutex, data2.mutex); // ... } void thread_func_2() { // 同样遵守顺序:先data1,后data2。即使它只想访问data2和data1。 // 如果这里写成 std::lock(data2.mutex, data1.mutex),就可能与thread_func_1形成循环等待。 std::scoped_lock lock(data1.mutex, data2.mutex); // ... }这是最常用且最有效的预防策略之一。
第四部分:死锁避免
死锁避免是一种动态策略 ,系统在资源分配时通过算法(如银行家算法)判断此次分配是否会导致系统进入不安全状态,从而决定是否分配。
-
核心思想:允许"占有并等待",但系统会谨慎地评估每个资源请求,确保不会导致死锁。
-
C++中的实践:在应用层面,完整的银行家算法并不常用,因为它的开销较大。但我们可以借鉴其思想:
- 使用
std::try_lock来尝试获取锁,如果失败则采取回退行动,而不是一直等待。 - 使用带超时的锁,例如
std::timed_mutex。
cppstd::timed_mutex mutex1, mutex2; bool try_lock_for_both(std::chrono::milliseconds timeout) { auto start = std::chrono::steady_clock::now(); do { if (mutex1.try_lock()) { // 成功获得第一个锁,尝试在剩余时间内获得第二个锁 if (mutex2.try_lock_for(timeout)) { return true; // 成功获得两个锁 } else { mutex1.unlock(); // 获取第二个锁失败,释放第一个锁 } } // 等待一小段时间再重试,避免忙等待 std::this_thread::sleep_for(std::chrono::milliseconds(10)); } while ((std::chrono::steady_clock::now() - start) < timeout); return false; // 超时,未能获得锁 } - 使用
第五部分:实践中的高级技巧与工具
1. 使用RAII管理锁 这是C++中管理资源的黄金法则。std::lock_guard, std::unique_lock, std::scoped_lock都是RAII的典范,它们能确保在作用域结束时自动释放锁,极大地避免了因异常抛出而导致锁无法释放的问题。
2. 避免嵌套锁 尽量缩小锁的粒度,并避免在一个锁的保护区域内去调用另一个可能获取锁的函数。如果无法避免,务必使用固定的锁顺序。
3. 使用工具检测死锁
- Clang ThreadSanitizer (TSan):一个强大的动态分析工具,可以检测数据竞争和死锁。
- Visual Studio / WinDbg:调试器可以在死锁发生时暂停程序,查看各个线程的调用栈和锁的持有情况。
- gdb :在Linux下,可以使用
thread apply all bt命令查看所有线程的堆栈,分析阻塞在哪个锁上。
第六部分:总结与对比
| 特性 | 死锁预防 | 死锁避免 |
|---|---|---|
| 理念 | 设计时静态地破坏必要条件 | 运行时动态地检查资源分配 |
| 核心方法 | 一次性分配、资源排序、剥夺 | 银行家算法、尝试锁、超时锁 |
| 资源利用率 | 可能较低(如一次性分配) | 相对较高 |
| 实现复杂度 | 相对简单,易于理解 | 复杂,需要系统支持 |
| C++常用手段 | std::lock, std::scoped_lock, 固定锁顺序 |
std::try_lock, std::timed_mutex |
给C++开发者的最终建议:
- 首选预防 :在代码设计阶段就考虑锁的顺序,并优先使用
std::scoped_lock来一次性锁定多个互斥量。 - 善用RAII :永远不要让裸的
mutex暴露在外,总是用lock_guard等RAII包装器来管理。 - 保持简单:锁的粒度要小,锁定的时间要短,锁的嵌套层次要浅。
- 工具辅助:在测试阶段积极使用ThreadSanitizer等工具来发现潜在的死锁和数据竞争。
死锁问题虽然复杂,但通过系统性地理解和应用上述策略,你完全可以写出健壮、高效的无死锁并发C++代码。