本文由一个简单的例子详细探讨下死锁是怎么发生的,以及如何避免死锁问题的发生。
死锁是怎么发生的?
死锁比较官方的说法是:是指两个或多个线程在执行过程中,由于争夺资源而造成的一种互相等待的现象,从而导致程序无法继续执行。
上述说可能比较抽象,个人理解当线程1在持有互斥锁A的同时请求互斥锁B,同时线程2持有互斥锁B的同时在请求互斥锁A。即线程1在等待线程2执行完成后将锁B释放,但是线程2在等待线程1执行完后将锁A释放,由此发生了互相等待的情况,即产生了死锁。
代码示例A:
假如我们有2个变量,想使用两个互斥锁分别同步这2个变量的访问。在两个线程中,分别对这两个变量都进行了读或写。如下面代码发生了死锁
#include <iostream>
#include <algorithm>
#include <thread>
#include <mutex>
using namespace std;
std::mutex mtx_a;
std::mutex mtx_b;
int a = 0, b = 0;
// 写入a
void set_a(int tmp)
{
cout << "set_a...1" << endl; //a资源锁定前
std::unique_lock<mutex> lock_a(mtx_a);
cout << "set_a...2" << endl; //a资源已锁定
a = tmp;
this_thread::sleep_for(chrono::seconds(1)); //为了增加触发死锁几率,增加等待,模拟工程中的函数处理需要时间
cout << "set_a...3" << endl; //a资源即将解锁
}
// 写入b
void set_b(int tmp)
{
cout << "set_b...1" << endl; //b资源锁定前
std::unique_lock<mutex> lock_b(mtx_b);
cout << "set_b...1" << endl; //b资源锁定前
b = tmp;
this_thread::sleep_for(chrono::seconds(1)); //为了增加触发死锁几率,增加等待,模拟工程中的函数处理需要时间
cout << "set_b...1" << endl; //b资源锁定前
}
void func_1()
{
cout << "func_1...1" << endl; //a资源锁定前
std::unique_lock<mutex> lock_a(mtx_a); // func_1持有mtx_a
cout << "a=" << a << endl; //读取a
cout << "func_1...2" << endl; //请求b资源前
set_b(a+1); //调用的函数会请求mtx_b
cout << "func_1...3" << endl;
}
void func_2()
{
cout << "func_2...1" << endl; //b资源锁定前
std::unique_lock<mutex> lock_b(mtx_b); // func_2持有mtx_b
cout << "b=" << b << endl; //读取b
cout << "func_2...2" << endl; //请求a资源前
set_a(b+1); //调用的函数会请求mtx_a
cout << "func_2...3" << endl;
}
int main(){
auto th_1 = thread(func_1);
auto th_2 = thread(func_2);
th_1.join();
th_2.join();
cout << "main finished" << endl;
return 0;
}
输出:
从结果可见,我们无法等待这段程序自动执行完成了,因为发生了死锁。
我们从输出这些打印信息,分析下都发生了什么:
首先func_1、func_2两个线程并发执行,所以在第一行中func_1...1、func_2...1分别输出,甚至由于输出不是原子操作,回车符的输出都被“打断”了;
然后线程1获取锁a,线程2获取锁b后,两个线程分别正常输出a和b的值;
同样的,两个线程在执行完fun_1(2)...2的输出后,分别进入set_b,set_a的函数调用;
进入set函数调用后,两个线程要分别获取锁b和锁a,但是由于锁a被线程1占用中,线程2则在等待,无法继续执行;同样的,锁b被线程2占用中,线程1也无法继续执行。两个线程都无法继续执行。
死锁怎么预防
发生死锁的根本原因还是对锁的使用不当。所以为了避免发生死锁,我们还是要从程序设计上入手,预防死锁的发生。
下面是一些有效的预防死锁方法:
1、资源排序法
在合适的场景下,对资源的访问进行一个全局的排序,各个线程按照同一顺序对资源进行请求。
在上面的例子中,假如我们统一规定,对a,b两个资源的访问顺序为a,b。对fun_2的资源访问调换顺序,如下,则就不会发生死锁。
但是要注意的是,这样可能改变了a或b输出的值,因为这两个值的设置互相依赖,所以我们要结合需求,解决死锁问题。
void func_2()
{
cout << "func_2...1" << endl;
set_a(b+1);
cout << "func_2...2" << endl;
std::unique_lock<mutex> lock_b(mtx_b);
cout << "b=" << b << endl;
cout << "func_2...3" << endl;
}
输出:
程序正常执行完成
2、使用定时锁
这里介绍定时锁std::timed_mutex,它听过try_lock_for函数可以设置尝试锁定时间。timed_mutex也可以结合unique_ptr管理使用。
如上述代码A,我们将set_a的函数改成如下形式,或者set_a、set_b都进行修改都可以解决死锁问题:
std::timed_mutex mtx_a; //修改为定时锁
void set_a(int tmp)
{
int max_retry = 3; //设置最大超时重试次数
while(max_retry--)
{
std::unique_lock<timed_mutex> lock_a(mtx_a, std::defer_lock); //设置初始为不锁定状态
if(lock_a.try_lock_for(chrono::seconds(2))) //尝试锁定锁a,超时时间2s
{
a = tmp; //设置a的值
this_thread::sleep_for(chrono::seconds(1));
return; //执行成功,返回调用
}
else //尝试锁定操作超时,则循环尝试再次锁定
{
this_thread::sleep_for(chrono::seconds(1)); //设置1s等待时间,避免忙等待
}
}
}
输出可见,程序成功退出死锁:
3、避免嵌套锁
什么是嵌套锁?其实在示例代码A中,我们就是使用了嵌套锁。即在锁定a的锁定中去尝试获取(锁定)锁b。
嵌套锁是一个比较常见的发生死锁的原因。例A中的代码比较简单很容易发现,但是在日常工作中,我们写的代码通常具有复杂的调用关系,如果代码习惯不好很容易造成嵌套锁。
避免嵌套锁,我们要注意对每个锁的作用有个清晰的认知,合理使用锁,及时释放锁。
比如在例A中,我们尝试用锁a同步对变量a的访问;锁b同步对变量b的锁定。但是在例A的代码设计中,我们在func_1中使用锁a管理了对a的读取,同时也锁住了对set_b(设置b)的调用。如果我们并不需要这种设计,我们可以在访问变量a结束后,及时将锁a释放,这样避免了锁的嵌套。
修改func_1、func_2如下
只是增加了 lock.unlock(); 一行代码
void func_1()
{
cout << "func_1...1" << endl;
std::unique_lock<mutex> lock_a(mtx_a);
cout << "a=" << a << endl;
lock_a.unlock(); //及时对锁a进行释放
cout << "func_1...2" << endl;
set_b(a+1);
cout << "func_1...3" << endl;
}
void func_2()
{
cout << "func_2...1" << endl;
std::unique_lock<mutex> lock_b(mtx_b);
cout << "b=" << b << endl;
lock_b.unlock(); //及时对锁b进行释放
cout << "func_2...2" << endl;
set_a(b+1);
cout << "func_2...3" << endl;
}
4、避免自引用
什么是自引用?
如果一个线程在持有锁的情况下调用某一个函数,同时该函数又尝试获取同一个锁,即发生了自引用。
例如如下代码,就是自引用的
void func_3()
{
std::unique_lock<mutex> lock_a(mtx_a);
cout << a << endl;
set_a(6); // set_a中也尝试获取了mtx_a,发生自引用
}
5、避免长时间持有锁,在锁的范围内只执行必要的操作
如标题,这是一个程序设计的原则,不需要过多解释,相信大家都能够理解。