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 很容易漏掉,尤其是在异常发生时。

小技巧: std::lock 可以接受任意数量的互斥量,但不要传太多,一般两三个就够了。传太多反而影响性能。

5.4 std::scoped_lock:更优雅的写法

C++17 引入了 std::scoped_lock,它把 std::lockstd::lock_guard 合二为一。我个人非常喜欢这个,因为它让代码更简洁,也更安全。

std::mutex mtx1, mtx2;

void elegant_operation() {
    // 一行搞定:锁住两个互斥量,自动释放
    std::scoped_lock lock(mtx1, mtx2);
    
    // 操作共享数据
    // ...
    // 离开作用域时自动释放
}

你看,代码少写了好几行,而且绝对不会忘记释放锁。C++17 的 CTAD(类模板参数推导)还能自动推导互斥量的类型,连模板参数都不用写。

推荐: 如果你的项目支持 C++17,优先使用 std::scoped_lock。它比 std::lock + lock_guard 的组合更安全、更简洁。

5.5 避免死锁的最佳实践

光靠工具还不够,还得有好的编码习惯。我总结了几条经验,都是踩过坑之后才明白的。

  1. 固定锁的顺序:所有线程按照相同的顺序加锁。比如总是先锁 mtx1 再锁 mtx2,这样就不会形成循环等待。
  2. 使用层级锁:给每个锁分配一个层级编号,只允许按层级递增的顺序加锁。我在一个大型项目中用过这个方案,效果很好。
  3. 尽量缩小锁的范围:只锁必要的代码段,不要锁住整个函数。锁的范围越小,死锁的概率越低。
  4. 避免在锁内调用外部函数:你不知道外部函数会不会也去加锁,万一形成循环等待就麻烦了。
  5. 使用 std::scoped_lock 或 std::lock:需要锁多个互斥量时,不要手动逐个加锁。
避坑指南: 我曾经在锁内调用了第三方库的回调函数,结果那个回调函数又回来锁同一个互斥量。虽然用的是递归锁没死锁,但性能一塌糊涂。后来我重构了代码,把回调移到了锁外面。

5.6 死锁预防的知识体系

下面这张图总结了死锁预防的核心逻辑,你可以对照着梳理一下自己的思路。

死锁预防知识体系 互斥条件 持有并等待 不剥夺条件 循环等待 死锁发生 固定锁的顺序 使用 std::scoped_lock 缩小锁的范围 std::lock / std::scoped_lock

从这张图可以看得很清楚:四个必要条件同时满足才会死锁,而我们的预防策略就是破坏这些条件。工具层面,std::lockstd::scoped_lock 是我们最得力的助手。

5.7 总结

死锁这东西,说白了就是线程之间的“互相等待”。只要记住四个必要条件,再配合好的编码习惯和现代 C++ 的工具,完全可以避免。

我个人最推荐的做法:能用 std::scoped_lock 就别手动加锁,能固定锁顺序就别乱来。这些习惯养成了,死锁基本就离你远远的了。

最后提醒一句: 写多线程代码时,多想想“如果另一个线程也在做同样的事,会不会出问题?” 这个习惯救过我很多次。

公众号:蓝海资料掘金营,微信 deep3321