5. 死锁与预防:别让你的线程互相掐架
死锁这东西,说白了就是两个线程互相等着对方释放资源,结果谁也动不了。我在刚学多线程那会儿,第一次遇到死锁,程序卡得死死的,连日志都打不出来。当时我还以为是电脑死机了,后来才发现是线程们自己把自己锁死了。
嗯,这一章我们就来聊聊死锁是怎么产生的,以及怎么避免它。
5.1 死锁产生的四个必要条件
死锁不是凭空出现的,它需要同时满足四个条件。缺一个,死锁就形成不了。我个人习惯把这四个条件记成“四个字”:互斥、持有、不剥夺、循环等待。
| 条件 | 说明 | 通俗理解 |
|---|---|---|
| 互斥条件 | 资源一次只能被一个线程占用 | 一把锁只能一个人拿着 |
| 持有并等待 | 线程拿着一个资源,又在等另一个资源 | 手里抓着A,眼睛盯着B |
| 不剥夺条件 | 资源不能被强行抢走,只能主动释放 | 除非你放手,否则别人拿不走 |
| 循环等待 | 线程之间形成等待环路 | 你等我,我等他,他等你 |
你想想看,如果这四个条件同时成立,那死锁就板上钉钉了。我在项目中遇到过最典型的场景:两个线程分别锁了两个不同的互斥量,然后互相等着对方释放。程序直接卡死,连个错误提示都没有。
5.2 死锁的经典场景
先看一个反面教材。这段代码我当年也写过类似的,现在想想真是后怕。
std::mutex mtx1, mtx2;
void thread1() {
std::lock_guard<std::mutex> lock1(mtx1);
// 做一些操作...
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lock2(mtx2);
// 这里可能死锁
}
void thread2() {
std::lock_guard<std::mutex> lock2(mtx2);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lock1(mtx1);
// 这里也可能死锁
}
为什么会这样?因为 thread1 锁了 mtx1 等着 mtx2,而 thread2 锁了 mtx2 等着 mtx1。两个线程互相掐着对方的脖子,谁也动不了。
5.3 std::lock:一次性锁多个互斥量
C++11 提供了 std::lock,它可以一次性锁住多个互斥量,而且保证不会死锁。它的内部实现用了某种“试探-回退”的策略,说白了就是:如果一次锁不全,就释放已经锁住的,重新尝试。
std::mutex mtx1, mtx2;
void safe_operation() {
// 一次性锁住两个互斥量
std::lock(mtx1, mtx2);
// 手动管理锁的释放
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
// 安全地操作共享数据
// ...
}
注意这里的 std::adopt_lock,它告诉 lock_guard:锁已经被锁住了,你只需要在析构时释放就行。我建议你养成这个习惯,因为手动 unlock 很容易漏掉,尤其是在异常发生时。
5.4 std::scoped_lock:更优雅的写法
C++17 引入了 std::scoped_lock,它把 std::lock 和 std::lock_guard 合二为一。我个人非常喜欢这个,因为它让代码更简洁,也更安全。
std::mutex mtx1, mtx2;
void elegant_operation() {
// 一行搞定:锁住两个互斥量,自动释放
std::scoped_lock lock(mtx1, mtx2);
// 操作共享数据
// ...
// 离开作用域时自动释放
}
你看,代码少写了好几行,而且绝对不会忘记释放锁。C++17 的 CTAD(类模板参数推导)还能自动推导互斥量的类型,连模板参数都不用写。
5.5 避免死锁的最佳实践
光靠工具还不够,还得有好的编码习惯。我总结了几条经验,都是踩过坑之后才明白的。
- 固定锁的顺序:所有线程按照相同的顺序加锁。比如总是先锁 mtx1 再锁 mtx2,这样就不会形成循环等待。
- 使用层级锁:给每个锁分配一个层级编号,只允许按层级递增的顺序加锁。我在一个大型项目中用过这个方案,效果很好。
- 尽量缩小锁的范围:只锁必要的代码段,不要锁住整个函数。锁的范围越小,死锁的概率越低。
- 避免在锁内调用外部函数:你不知道外部函数会不会也去加锁,万一形成循环等待就麻烦了。
- 使用 std::scoped_lock 或 std::lock:需要锁多个互斥量时,不要手动逐个加锁。
5.6 死锁预防的知识体系
下面这张图总结了死锁预防的核心逻辑,你可以对照着梳理一下自己的思路。
从这张图可以看得很清楚:四个必要条件同时满足才会死锁,而我们的预防策略就是破坏这些条件。工具层面,std::lock 和 std::scoped_lock 是我们最得力的助手。
5.7 总结
死锁这东西,说白了就是线程之间的“互相等待”。只要记住四个必要条件,再配合好的编码习惯和现代 C++ 的工具,完全可以避免。
我个人最推荐的做法:能用 std::scoped_lock 就别手动加锁,能固定锁顺序就别乱来。这些习惯养成了,死锁基本就离你远远的了。
公众号:蓝海资料掘金营,微信 deep3321