死锁 (Deadlock) 深度解析

目录

[1. 什么是死锁?一个生活中的比喻](#1. 什么是死锁?一个生活中的比喻)

[2. 死锁的四个必要条件(科夫曼条件)](#2. 死锁的四个必要条件(科夫曼条件))

[a. 互斥 (Mutual Exclusion)](#a. 互斥 (Mutual Exclusion))

[b. 持有并等待 (Hold and Wait)](#b. 持有并等待 (Hold and Wait))

[c. 不可抢占 (No Preemption)](#c. 不可抢占 (No Preemption))

[d. 循环等待 (Circular Wait)](#d. 循环等待 (Circular Wait))

[3. 如何处理死锁?](#3. 如何处理死锁?)

死锁预防:破坏四个必要条件之一

[4. C++ 标准库提供的"死锁安全带"](#4. C++ 标准库提供的“死锁安全带”)

[std::lock 和 std::scoped_lock (C++17)](#std::lock 和 std::scoped_lock (C++17))


1. 什么是死锁?一个生活中的比喻

想象一个场景:在一个狭窄的单行道上,两辆车迎面相遇。

  • 车A 想要前进,但被 车B 挡住了。

  • 车B 也想前进,但被 车A 挡住了。

两辆车都占着自己当前的路(持有资源 ),同时又在等待对方让出道路(请求资源 )。如果两位司机都互不相让,他们就会永远卡在这里,谁也动不了。这种情况,就是死锁

在并发编程中,"车"就是线程 ,"道路"就是资源 (最常见的是互斥锁 std::mutex)。

死锁的正式定义:指两个或多个并发线程,在执行过程中因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。

2. 死锁的四个必要条件(科夫曼条件)

一个死锁的发生,必须同时满足以下全部四个条件。只要破坏其中任意一个,死锁就不会发生。

a. 互斥 (Mutual Exclusion)
  • 定义:一个资源在同一时刻只能被一个线程持有。如果其他线程想使用该资源,必须等待直到资源被释放。

  • 比喻:打印机在任何时候只能打印一个人的文件。在你打印完成之前,其他人必须等待。

  • 编程体现std::mutex 本身就是互斥的。lock() 操作确保了只有一个线程能进入临界区。这个条件在并发编程中通常是无法避免的,因为我们就是需要用锁来保护共享资源。

b. 持有并等待 (Hold and Wait)
  • 定义:一个线程至少持有一个资源,并且正在请求另一个被其他线程持有的资源。在等待新资源的同时,它并不会释放自己已经持有的资源。

  • 比喻:你手里拿着一支笔(持有资源1),现在你需要一张纸来写字,但纸在另一个人手里。你在等待他给你纸的同时,并没有放下你手中的笔。

  • 编程体现

    复制代码
    std::lock_guard<std::mutex> lock1(mtx1); // 持有 mtx1
    // ...做一些事...
    std::lock_guard<std::mutex> lock2(mtx2); // 等待 mtx2
c. 不可抢占 (No Preemption)
  • 定义:资源不能被强制地从持有它的线程中"抢"走。只能由持有者在完成任务后自愿释放。

  • 比喻:别人不能从你手里强行夺走你正在使用的笔。你必须自己决定什么时候用完并把它放下。

  • 编程体现 :当一个线程通过 lock() 获得了互斥锁,操作系统或其他线程无法强制它 unlock()。它必须自己执行到作用域结束(对于 lock_guard)或手动调用 unlock()

d. 循环等待 (Circular Wait)
  • 定义:存在一个线程的等待链,形成一个闭环。

    • 线程 T1 正在等待线程 T2 持有的资源。

    • 线程 T2 正在等待线程 T3 持有的资源。

    • ...

    • 线程 Tn 最终等待线程 T1 持有的资源。

  • 比喻

    • 想借小明 的钢笔,但小明 说必须先借到小红的笔记本才行。

    • 小红 说,她必须先借到的橡皮擦,才肯借出笔记本。

    • 于是,你等小明,小明等小红,小红又在等你。三个人陷入了无限的循环等待。

  • 最经典的编程场景

    复制代码
    // 线程 1
    void func1() {
        std::lock_guard<std::mutex> lock1(mtx1); // 1. 锁住 mtx1
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
        std::lock_guard<std::mutex> lock2(mtx2); // 2. 尝试锁住 mtx2 (等待线程2释放)
    }
    
    // 线程 2
    void func2() {
        std::lock_guard<std::mutex> lock2(mtx2); // 1. 锁住 mtx2
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
        std::lock_guard<std::mutex> lock1(mtx1); // 2. 尝试锁住 mtx1 (等待线程1释放)
    }

    如果 func1func2 并发执行,func1 可能锁住 mtx1 等待 mtx2,而 func2 同时锁住了 mtx2 等待 mtx1,这就形成了循环等待,导致死锁。

3. 如何处理死锁?

主要有三种策略:预防、避免、检测与恢复。在应用级编程中,我们最关心的是死锁预防

死锁预防:破坏四个必要条件之一

预防死锁的核心思想,就是通过编码规范和设计,来破坏四个必要条件中的至少一个。

  • 破坏"互斥"

    • 方法:允许多个线程同时访问资源。

    • 可行性 :很低。对于打印机、共享计数器这类资源,我们就是为了互斥才加锁的。但对于只读数据,可以使用无锁数据结构或原子操作 std::atomic 来代替互斥锁。

  • 破坏"持有并等待"

    • 方法:规定线程在请求资源前,不能持有任何其他资源。要么一次性申请所有需要的资源,要么在申请新资源前,先释放已持有的所有资源。

    • 可行性:较低。很难预知一个任务到底需要哪些资源,且"先释放再申请"可能导致线程活锁(不断尝试但总失败)。

  • 破坏"不可抢占"

    • 方法:允许系统或优先级更高的线程抢占资源。

    • 可行性:极低。在应用层面几乎无法实现,且会使程序逻辑变得异常复杂。

  • 破坏"循环等待" (最实用、最常用的策略)

    • 方法对所有资源(互斥锁)进行全局排序,并强制所有线程都必须按照这个统一的顺序来获取锁。

    • 可行性 :非常高,是预防死锁的黄金法则

    • 比喻:规定所有司机在十字路口都必须先让右边的车。这样就不会出现所有车都想抢先而堵死的局面。

    • 编程实现

      复制代码
      std::mutex mtx1, mtx2;
      
      // 假设我们规定,必须先锁地址较小的互斥量
      // 这是一个全局统一的顺序
      
      // 线程 1
      void func1_fixed() {
          // 总是按 mtx1 -> mtx2 的顺序加锁
          std::lock_guard<std::mutex> lock1(mtx1);
          std::lock_guard<std::mutex> lock2(mtx2);
      }
      
      // 线程 2
      void func2_fixed() {
          // 同样遵守 mtx1 -> mtx2 的顺序
          std::lock_guard<std::mutex> lock1(mtx1);
          std::lock_guard<std::mutex> lock2(mtx2);
      }

      通过强制所有线程都遵循 mtx1 -> mtx2 的加锁顺序,就打破了循环等待的可能性,从根本上消除了死锁。

4. C++ 标准库提供的"死锁安全带"

手动管理锁的顺序很麻烦且容易出错。为此,C++ 标准库提供了更高级的工具来自动处理这个问题。

std::lockstd::scoped_lock (C++17)

当你需要同时锁定多个互斥锁时,应该使用它们。

  • std::lock(mtx1, mtx2, ...): 这是一个函数,可以一次性、无死锁地锁定传入的所有互斥锁。它内部实现了一套避免死锁的算法(例如,它会尝试锁定,如果某个锁失败了,它会解开所有已锁定的锁,然后重试)。

  • std::scoped_lock(mtx1, mtx2, ...) (C++17, 最佳选择) : 这是一个 RAII 风格的类,是 std::lock 的现代化封装。它在构造时自动调用 std::lock 来安全地锁定所有互斥锁,在析构时(离开作用域时)自动将它们全部解锁。

    #include <mutex>
    #include <thread>

    std::mutex mtx_A, mtx_B;

    void safe_operation() {
    // 只需要一行代码,就能安全、无死锁地锁定两个互斥锁
    // 无需关心 mtx_A 和 mtx_B 的顺序
    std::scoped_lock lock(mtx_A, mtx_B);

    复制代码
      // ... 在此作用域内,mtx_A 和 mtx_B 都被安全锁定 ...
      // ... 执行你的操作 ...

    } // lock 析构时,会自动解锁 mtx_A 和 mtx_B

结论 :在需要同时锁定多个互斥锁的场景下,永远优先使用 std::scoped_lock。这是现代 C++ 中预防死锁的最佳实践。

相关推荐
西阳未落3 小时前
欧拉路径与欧拉回路
算法·深度优先
Swift社区3 小时前
LeetCode 390 消除游戏
算法·leetcode·游戏
橘颂TA4 小时前
【剑斩OFFER】优雅的解法——三数之和
算法
我爱工作&工作love我4 小时前
2024-CSP-J T3 小木棍
算法·动态规划
DatGuy4 小时前
Week 18: 深度学习补遗:Stacking和量子运算Deutsch算法
人工智能·深度学习·算法
Nie_Xun6 小时前
ROS1 go2 vlp16 局部避障--3 篇
算法
Da Da 泓9 小时前
LinkedList模拟实现
java·开发语言·数据结构·学习·算法
未知陨落9 小时前
LeetCode:68.寻找两个正序数组的中位数
算法·leetcode
努力学习的小廉11 小时前
我爱学算法之—— 模拟(下)
c++·算法