死锁,其实在我们生活中比较常见,比如在我们上下班高峰期,大家都希望快速到达公司或者目的地,只要有几辆车抢灯通行,就可能出现下面的场景,最终还得交警来解除死锁,一个方向的车辆往后退一点,再让另外一个与之垂直方向的车辆通行,逐步解除死锁状态。
死锁条件
我们对上面的十字路口交通情况来做个简化:
- 每一辆车:线程,四个方向共4个线程,1~4。
- 路口:公共竞态资源,四个方向共4个资源,a~d。
四个方向的车辆都不遵守规则,不礼让,就会出现下面的情况:
- 1号车:占有路口资源a,想要获取路口资源b;
- 2号车:占有路口资源b,想要获取路口资源c;
- 3号车:占有路口资源c,想要获取路口资源d;
- 4号车:占有路口资源d,想要获取路口资源a。
我们把资源占用图绘制一下:
当今是一个法治国家,并且素有礼仪之邦的民族,大家都文明行车:
- 1号车:占有路口资源a,想要获取路口资源b;
- 3号车:占有路口资源c,想要获取路口资源d;
- 2/4号车:都未占有任何路口资源。
我们把资源占用图绘制一下:
从上面的两种场景可以总结出死锁的条件:
- 互斥:一个路口资源只能被一辆车占用。一旦有车辆占有路口资源后,其他车辆无法再占用。比如,1号车辆,占用路口资源a。
- 占有且等待:一辆车占用路口资源后,还需等待去向的下一个资源。比如,1号车辆占有路口资源a后,还需等待占有路口资源b。
- 不可抢占:一个路口资源被占用后,其他车辆不能抢占。比如,1号车辆占有路口资源a,4号车辆无法抢占路口资源a。
- 循环等待:存在一个资源占有/请求环。比如,死锁中的资源占用图中所示。
前3个条件是死锁的必要条件,第4个条件是前3个条件存在时的一个潜在结果。第4个条件的循环等待条件不可解,是因为前3个条件的存在。因此,这4个条件构成了死锁的充分必要条件。
死锁判断方法
现象: 程序中的线程卡住,没有再执行后续流程;无法停止线程任务,有时连最基本的析构都无法完成。
判断方法
- 不能假定多个线程的执行先后顺序,每个线程的调度都是由操作系统来完成的。
- 找出发生死锁的2个线程,梳理每个线程在请求/释放多个公共资源的先后顺序。
- 一旦两个线程对多个公共资源的请求和释放顺序不一致,都将有概率发生死锁。
-
方法一:绘制二维图,下图的二维图中黄色交集区域就是死锁区域。
-
方法二:绘制资源-线程占有/请求依赖图,如果一旦存在环路,必然会出现概率死锁:
上面的单线程死锁,看似很简单,很多人都不相信会出现这种情况。其实,在一个简单的程序中可能正确,一旦耦合了复杂的流程,可能导致大家都不能快速的判定此种情况。
#include <iostream> #include <thread> #include <mutex> using namespace std; class Demo { public: bool Init() { // ... 一顿初始化 lock_guard<mutex> guard(resMtx); isInit = true; std::cout << "init success" << std::endl; return true; } void Run() { lock_guard<mutex> guard(resMtx); res++; } void Deinit() { lock_guard<mutex> guard(resMtx); if (isInit) { std::cout << "start deinit" << std::endl; ClearRes(); std::cout << "end deinit" << std::endl; } } private: void ClearRes() { lock_guard<mutex> guard(resMtx); res = 0; isInit = false; } mutex resMtx; bool isInit = false; uint32_t res = 0; }; int main() { Demo demo; demo.Init(); demo.Run(); demo.Deinit(); return 0; }
上面的多线程死锁,其实是非常常见,需要细心的处理依赖关系,才能识别出来。
避免死锁方法
避免死锁的方法,其实就是避免出现死锁的条件:
-
互斥:对公共资源互斥访问,特别是对资源要修改的场景,必然是一个互斥的。除非,给每个线程提供一个资源,避免各个线程竞争。因此,对于公共资源,必然需要保证互斥,该条件不能被打破。
-
占有且等待:方式一,一个线程从一开始就获取到所有资源的访问权,或者换句话说,线程执行功能入口就加一个大的互斥锁。那么,对于该功能,多个线程并行的意图就会变成串行执行,执行效率大打折扣。因此,一开始获取所有的资源,是低效的。方式二,如果获取不到资源,考虑暂时释放已经获取到的资源,等到下一次条件满足时,再获取资源。那么在程序中就需要增加额外的逻辑来处理,释放再获取资源。
-
不可抢占:如果线程执行所需的资源被其他线程持有,可以抢占其他线程的资源,但当前是不支持的。
-
循环等待:在软件设计实现时,可以小心谨慎的处理,打破资源占有/请求环。
因此,避免死锁的方法,主要就是避免出现占有/请求环:
a. 内聚资源管理:使用独立的一个类来管理,对外提供接口。并要求,每个对外提供的接口是多线程安全的,换句话说,多个线程调用类对外提供的接口,不会出现资源管理异常,或者是死锁。
b. 软件设计上,类间无依赖环,比如下面的案例,就会造成死锁。
这个案例中,依赖环,其实隐藏得比较深。 我们绘制一个调用序列图来看看:
B的FUN1函数加锁,调用A注册的回调函数,在A的函数中,又调用了B的Fun2函数,而在Fun2的函数中又加锁。这样就出现了一个调用环。
c. 多锁加解锁原则:
- 所有对外提供的接口,多锁加解锁顺序保持一致。比如,二维图中的最后一张图。
- 至少有一个线程的加解锁之间未掺杂其他锁。举例:加锁a,解锁a,加锁b,解锁b,锁a的加解锁之间,无对锁b的任何操作。比如,二维图中无死锁风险的图。
d. 打破占有且等待的条件,可以先释放资源,再待时机成熟,重新获取资源,也可以打破资源占有/请求环,从而解决死锁问题。
总结
在多线程编程中,死锁是一个常见的问题,希望通过本节的学习,能帮助大家正确的判断死锁,避免死锁,从而编写出高效和稳定的程序。