一个实际的例子
假设小明和小红各有一个银行账户,余额分别为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;
}
编译代码并运行测试,看上去并没有任何问题:
但事实上,有经验的同学应该不难注意到,上面的代码是存在死锁问题的。我们考虑下面的情况:
- 线程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();
// ...
}
接下来再次编译和运行程序,应该可以看到这时候进程已经卡出不退出了:
我们也可以在另外一个终端中执行命令ps -elLf | grep "./main"
,可以看到如下输出:
注意到主线程(LWP=6382)和它的两个子线程(LWP=6383、6384)的状态都为S(可中断的睡眠状态),且睡眠等待的原因都为申请futex锁(我前面的文章中介绍过,Linux系统的互斥锁在底层依赖一种叫futex锁的机制)。这就证明了我们的程序遇到了死锁问题。
死锁产生的四大条件
针对死锁出现的条件,学界已经有如下的定论。我们只需要将这些结论记住,并且知道如何消除死锁就可以了。
当以下四个条件同时成立时,会引发死锁:
- 循环等待:
- 定义:存在一个「线程-资源」循环链,每个线程都在等待下一个线程占有的资源。
- 举例:前文中介绍的银行账户例子即是。
- 不能抢占:
- 定义:竞争资源不能被强制从持有者手中夺走,只能由其主动释放。
- 举例:当线程t1抢占a1.mutex后,线程t2无法强制让t1放弃这把锁。
- 持有并等待:
- 定义:线程已持有至少一个竞争资源,同时又在请求其他竞争资源,且不释放已占有的竞争资源。
- 举例:线程t1在抢占到互斥锁a1.mutex后,还需要抢占到a2.mutex,才能继续往下执行。
- 互斥条件:
- 定义:竞争资源同一时间只能被一个线程使用,也就是说在时间上具有排他性。
- 举例:小明或小红的账户同一时间显然只允许被一个线程读写。
下面我们从直观上简单地理解一下这四个条件。
条件2、4没啥好说的,这是多线程资源竞争类问题的基本特点——一般情况下我们也是无法消除的。而在这俩基本条件的基础上,倘若又出现了条件1,此外又有条件3(该条件意味着程序无法自行解开死锁)加持,那么死锁自然而然就来了。
消除死锁
消除"循环等待"
从前文的分析中我们已经知道,我们的银行账户程序中可能会产生如下的"循环等待":
要破开这种环,一种简单的方法是我们可以要求所有线程必须按相同的顺序申请锁。比如在本例中,我们可以统一要求两个线程都必须先获取到a1.mutex,然后再获取到a2.mutex。那么上面的依赖图就会变成这个样子:
此时"循环等待"条件就被破坏了,死锁应该就不会发生了。
最后我们修改代码来验证一下:
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;
}