4. 死锁与预防:四个必要条件、常见策略、std::lock 与 std::scoped_lock
死锁这东西,说白了就是两个线程互相等着对方手里的资源,谁也不肯撒手。我在刚入行那会儿,第一次遇到死锁,程序跑着跑着就卡死了,CPU 占用率直接掉到零。当时我还以为是硬件坏了,折腾了半天才发现是锁的问题。嗯,从那以后,我对死锁就特别敏感。
4.1 死锁产生的四个必要条件
要发生死锁,必须同时满足四个条件。缺一个都不行。你想想看,这就像四个开关串联在一起,必须全部闭合,灯才会亮。
| 条件 | 说明 | 我的理解 |
|---|---|---|
| 互斥 | 资源一次只能被一个线程占用 | 说白了就是锁的特性,不能共享 |
| 持有并等待 | 线程拿着一个锁,同时去等另一个锁 | 这是最常见的死锁场景 |
| 不可剥夺 | 资源只能由持有者主动释放 | 你不能强行从别人手里抢锁 |
| 循环等待 | 存在一条线程间的等待环路 | 你等我,我等你,形成闭环 |
核心要点:只要破坏掉上面任意一个条件,死锁就不会发生。这是所有预防策略的理论基础。
我记得有一次在项目中,两个线程分别锁定了 A 和 B,然后互相等待对方释放。这就是典型的循环等待。当时我盯着日志看了半天,才意识到问题出在锁的顺序上。
4.2 常见的死锁预防策略
预防死锁,说白了就是破坏那四个条件。我个人的习惯是优先考虑「破坏循环等待」,因为实现起来最直接,也最容易检查。
策略一:破坏互斥条件
这个策略在实际中很少用。因为互斥是锁的本质,你把它破坏了,那还要锁干嘛?不过有一种特殊情况——使用无锁数据结构(比如原子操作),可以绕过互斥。但这属于另一个话题了。
策略二:破坏持有并等待
让线程一次性申请所有需要的锁。要么全拿到,要么一个都不拿。这样做的好处是简单,坏处是降低了并发度。我在项目中用过一次,后来发现性能下降明显,就放弃了。
// 一次性申请所有锁
void transfer(BankAccount& from, BankAccount& to, int amount) {
std::lock(from.mutex, to.mutex);
std::lock_guard<std::mutex> lock1(from.mutex, std::adopt_lock);
std::lock_guard<std::mutex> lock2(to.mutex, std::adopt_lock);
// 执行转账操作
}
策略三:破坏不可剥夺条件
这个策略在 C++ 标准库中不太好实现。因为 std::mutex 不支持「尝试获取锁,如果失败就释放已有锁」的操作。不过你可以用 std::timed_mutex 配合 try_lock_for 来模拟。我曾经在一个高并发服务器中用过这个方案,效果还行,但代码会变得复杂。
策略四:破坏循环等待(最常用)
规定所有线程按照相同的顺序加锁。比如,总是先锁 A 再锁 B。这样就不会出现 A 等 B、B 等 A 的情况。我个人强烈推荐这个策略,因为它简单、高效、容易审查。
我的建议:在团队中约定一个全局的锁顺序,并在代码评审时重点检查。我曾经因为一个同事不小心把锁顺序写反了,导致线上服务死锁了半小时。从那以后,我们就在代码里加了一个注释:// 注意:必须先锁 A,再锁 B。
4.3 std::lock 与 std::scoped_lock
C++11 引入了 std::lock,它可以同时锁住多个互斥量,而且不会死锁。它的原理是使用一种「尝试-回退」算法:先尝试锁住所有互斥量,如果某个锁失败了,就释放已经锁住的,然后重试。这其实就是破坏了「持有并等待」条件。
std::mutex m1, m2;
// 使用 std::lock 同时锁住两个互斥量
std::lock(m1, m2);
// 手动管理锁的释放
std::lock_guard<std::mutex> lock1(m1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(m2, std::adopt_lock);
嗯,这里要注意:std::lock 本身不会管理锁的生命周期,你需要配合 std::lock_guard 使用,并传入 std::adopt_lock 来告诉它「锁已经拿到了,你只管释放就行」。
C++17 引入了 std::scoped_lock,它把 std::lock 和 std::lock_guard 的功能合二为一。说白了,就是更简洁了。
std::mutex m1, m2;
// 使用 std::scoped_lock 同时锁住两个互斥量
{
std::scoped_lock lock(m1, m2);
// 临界区代码
// 自动释放锁
}
推荐:如果你用的是 C++17 或更高版本,我建议你直接用 std::scoped_lock。它既安全又简洁,不容易出错。我在新项目中已经全面改用 scoped_lock 了。
你可能会问:std::scoped_lock 真的不会死锁吗?其实它内部调用了 std::lock,而 std::lock 通过「尝试-回退」算法避免了死锁。但要注意,它只能避免「多个锁之间的死锁」,如果你在临界区里又调用了其他可能死锁的函数,那还是会有问题。
避坑指南:我曾经在代码里用了 std::scoped_lock,但没注意临界区里调了一个回调函数,那个回调函数又去申请了另一个锁。结果还是死锁了。所以,使用 scoped_lock 时,要确保临界区里的代码不会间接申请其他锁。
4.4 知识体系图
下面这张图总结了死锁的四个必要条件、预防策略以及 C++ 提供的工具。你可以把它当作一个快速参考。
4.5 总结
死锁是并发编程中比较头疼的问题,但只要你理解了那四个必要条件,预防起来并不难。我个人最常用的方法是「固定锁顺序」和「使用 std::scoped_lock」。前者从设计上杜绝循环等待,后者从工具层面避免加锁顺序问题。
嗯,最后提醒一句:工具再好,也架不住人犯错。代码评审时多留个心眼,看看有没有隐藏的锁依赖。我曾经就因为一个回调函数里的锁,差点把整个服务搞崩了。从那以后,我对临界区里的代码格外小心。
一句话总结:死锁不可怕,可怕的是你不知道它怎么来的。记住四个条件,用好 scoped_lock,你的代码就能远离死锁。