【C/C++】一文搞透死锁(deadlock)

51 阅读7分钟

p601652935.webp

一个实际的例子

假设小明和小红各有一个银行账户,余额分别为1000和100,现在同时执行下面两件事:

  • 小明向小红转账900
  • 小红向小明转账100

我们可以用两个线程来模拟这两个操作的同时进行。此外,显而易见的是,我们肯定不能允许两个线程同时修改任何一个人的账户余额,因此需要分别为两个人的账户设置一把细粒度锁。

代码如下:

#include <iostream>
#include <string>
#include <mutex>
#include <thread>

class Account {
 public:
    std::string name_;
    int balance_;
    std::mutex mutex_;

    Account(const std::string& name, int balance) : name_(name), balance_(balance) {}
};

int Transfer(Account& from, Account& to, int money) {
    from.mutex_.lock();
    to.mutex_.lock();

    if (from.balance_ < money) {
        from.mutex_.unlock();
        to.mutex_.unlock();
        return 0;
    }

    from.balance_ -= money;
    to.balance_ += money;

    from.mutex_.unlock();
    to.mutex_.unlock();

    return money;
}

int main() {
    Account a1("xiaoming", 1000);
    Account a2("xiaohong", 100);

    std::thread t1(&Transfer, std::ref(a1), std::ref(a2), 900);
    std::thread t2(&Transfer, std::ref(a2), std::ref(a1), 100);

    t1.join();
    t2.join();

    printf("The balance of xiaoming: %d\n", a1.balance_);
    printf("The balance of xiaohong: %d\n", a2.balance_);

    return 0;
}

编译代码并运行测试,看上去并没有任何问题:

image.png

但事实上,有经验的同学应该不难注意到,上面的代码是存在死锁问题的。我们考虑下面的情况:

  • 线程t1获取到互斥锁a1.mutex
  • 这时操作系统发生调度,转到线程t2执行
  • 线程t2获取到互斥锁a2.mutex
  • 这时操作系统再次发生调度,重新转回线程t1执行
  • 线程t1尝试获取互斥锁a2.mutex,发现该锁已被抢占,进而休眠等待。
  • 线程t1休眠后,操作系统调度被触发,转而继续执行t2
  • 线程t2尝试获取互斥锁a1.mutex,发现该锁已被抢占,进而休眠等待。

显然,死锁的出现是有概率的,所以我们刚才在测试时候没卡出来也是非常正常的事情。但是在实际项目中,一旦出现死锁,其造成的危害将是非常严重的。

如果我们想要在本地测试时卡出死锁,可以尝试在Transfer函数中申请两把锁前后,手工执行一次sleep系统调用,强制触发操作系统调度,以增加卡出死锁的概率:

#include <unistd.h>

int Transfer(Account& from, Account& to, int money) {
    from.mutex_.lock();
    ::sleep(1);
    to.mutex_.lock();
    // ...
}

接下来再次编译和运行程序,应该可以看到这时候进程已经卡出不退出了:

image.png

我们也可以在另外一个终端中执行命令ps -elLf | grep "./main",可以看到如下输出:

image.png

注意到主线程(LWP=6382)和它的两个子线程(LWP=6383、6384)的状态都为S(可中断的睡眠状态),且睡眠等待的原因都为申请futex锁(我前面的文章中介绍过,Linux系统的互斥锁在底层依赖一种叫futex锁的机制)。这就证明了我们的程序遇到了死锁问题。

死锁产生的四大条件

针对死锁出现的条件,学界已经有如下的定论。我们只需要将这些结论记住,并且知道如何消除死锁就可以了。

当以下四个条件同时成立时,会引发死锁:

  1. 循环等待
    • 定义:存在一个「线程-资源」循环链,每个线程都在等待下一个线程占有的资源。
    • 举例:前文中介绍的银行账户例子即是。
  2. 不能抢占
    • 定义:竞争资源不能被强制从持有者手中夺走,只能由其主动释放。
    • 举例:当线程t1抢占a1.mutex后,线程t2无法强制让t1放弃这把锁。
  3. 持有并等待
    • 定义:线程已持有至少一个竞争资源,同时又在请求其他竞争资源,且不释放已占有的竞争资源。
    • 举例:线程t1在抢占到互斥锁a1.mutex后,还需要抢占到a2.mutex,才能继续往下执行。
  4. 互斥条件
    • 定义:竞争资源同一时间只能被一个线程使用,也就是说在时间上具有排他性。
    • 举例:小明或小红的账户同一时间显然只允许被一个线程读写。

下面我们从直观上简单地理解一下这四个条件。

条件2、4没啥好说的,这是多线程资源竞争类问题的基本特点——一般情况下我们也是无法消除的。而在这俩基本条件的基础上,倘若又出现了条件1,此外又有条件3(该条件意味着程序无法自行解开死锁)加持,那么死锁自然而然就来了。

消除死锁

消除"循环等待"

从前文的分析中我们已经知道,我们的银行账户程序中可能会产生如下的"循环等待":

image.png

要破开这种环,一种简单的方法是我们可以要求所有线程必须按相同的顺序申请锁。比如在本例中,我们可以统一要求两个线程都必须先获取到a1.mutex,然后再获取到a2.mutex。那么上面的依赖图就会变成这个样子:

image.png

此时"循环等待"条件就被破坏了,死锁应该就不会发生了。

最后我们修改代码来验证一下:

class Account {
 private:
    static int id_count_;
 public:
    int id_;  // 通过给竞争资源设置不同ID号的方式,来确定抢占锁的顺序
    std::string name_;
    int balance_;
    std::mutex mutex_;

    Account(const std::string& name, int balance)
        : name_(name), balance_(balance), id_(id_count_++) {}
};

int Account::id_count_ = 0;

int Transfer(Account& from, Account& to, int money) {
    if (from.id_ < to.id_) {
        from.mutex_.lock();
        ::sleep(1);
        to.mutex_.lock();
    } else {
        to.mutex_.lock();
        ::sleep(1);
        from.mutex_.lock();
    }
    // ...

测试一下,现在程序应该能正常输出了。

消除"持有并等待"

方法一

要消除这个条件,一种方法是让某个线程在抢占到某一把锁后,若发现另一把锁现在无法被抢占,就立马放弃掉现在获取到的这把锁,随机等待一段时间后再次重复这个过程,直到两把锁都成功被抢到。

通过这种手段,我们就破坏掉了"持有并等待"中的"且不释放已占有的竞争资源"的这一子条件,从而避免了死锁。

#include <cstdlib>

int Transfer(Account& from, Account& to, int money) {
    while (true) {
        from.mutex_.lock();
        // 如果to.mutex_被成功抢到,则直接退出循环
        if (to.mutex_.try_lock()) {
            break;
        }
        // 否则需要放弃刚才抢到的from.mutex_
        from.mutex_.unlock();
        // 等待0~3秒后再次尝试抢锁
        ::sleep(::rand() % 4);
    }
    // ...
}

int main() {
    ::srand(::time(0));  // 初始化随机数种子
    // ...
}

方法二

另外一种方法是消除"同时又在请求其他竞争资源"这个子条件。

我们还可以再引入一把大的保护锁,使得"抢占两把细粒度锁"这整个过程原子化,这样在单个线程中只要抢占到了大锁,从逻辑上讲自然就不需要再抢占其他竞争资源了——因为抢到大锁,在逻辑上讲等效于抢到了两把细粒度锁!

std::mutex protecter;

int Transfer(Account& from, Account& to, int money) {
    protecter.lock();
    from.mutex_.lock();
    ::sleep(1);
    to.mutex_.lock();
    protecter.unlock();
    
    // ...
}

注意区分这种方法与"直接使用整把大锁保护整个Transfer函数"这种暴力方法中,全局大锁粒度范围的不同!

消除"互斥条件"

实际上这里的"消除"要打个引号,为什么呢?

在现代C++中引入了原子操作的概念。通过原子操作,我们可以直接在硬件层面保证多个线程对某些简单的共享资源(比如整型变量)的操作是互斥的。

这意味着,在应用层的程序员看来,互斥条件似乎在代码层面被消除了——虽然事实上,它只不过是被下放到硬件层面来保障罢了。

代码如下:

int Transfer(Account& from, Account& to, int money) {
    int from_balance;

    do {
        from_balance = from.balance_.load();
        if (from_balance < money) {
            return 0;
        }        
    } while (!from.balance_.compare_exchange_strong(from_balance, from_balance - money));

    to.balance_.fetch_add(money);
    return money;
}