多线程死锁

死锁,其实在我们生活中比较常见,比如在我们上下班高峰期,大家都希望快速到达公司或者目的地,只要有几辆车抢灯通行,就可能出现下面的场景,最终还得交警来解除死锁,一个方向的车辆往后退一点,再让另外一个与之垂直方向的车辆通行,逐步解除死锁状态。
拥堵

死锁条件

我们对上面的十字路口交通情况来做个简化:

死锁前

  • 每一辆车:线程,四个方向共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. 互斥:一个路口资源只能被一辆车占用。一旦有车辆占有路口资源后,其他车辆无法再占用。比如,1号车辆,占用路口资源a。
  2. 占有且等待:一辆车占用路口资源后,还需等待去向的下一个资源。比如,1号车辆占有路口资源a后,还需等待占有路口资源b。
  3. 不可抢占:一个路口资源被占用后,其他车辆不能抢占。比如,1号车辆占有路口资源a,4号车辆无法抢占路口资源a。
  4. 循环等待:存在一个资源占有/请求环。比如,死锁中的资源占用图中所示。

前3个条件是死锁的必要条件,第4个条件是前3个条件存在时的一个潜在结果。第4个条件的循环等待条件不可解,是因为前3个条件的存在。因此,这4个条件构成了死锁的充分必要条件。

死锁判断方法

现象: 程序中的线程卡住,没有再执行后续流程;无法停止线程任务,有时连最基本的析构都无法完成。

判断方法

  1. 不能假定多个线程的执行先后顺序,每个线程的调度都是由操作系统来完成的。
  2. 找出发生死锁的2个线程,梳理每个线程在请求/释放多个公共资源的先后顺序。
  3. 一旦两个线程对多个公共资源的请求和释放顺序不一致,都将有概率发生死锁。
  • 方法一:绘制二维图,下图的二维图中黄色交集区域就是死锁区域。
    多线程死锁二维交集图-死锁1
    多线程死锁二维交集图-死锁2
    多线程死锁二维交集图-死锁3
    多线程死锁二维交集图-死锁4
    多线程死锁二维交集图-无死锁1
    多线程死锁二维交集图-无死锁2
    多线程死锁二维交集图-无死锁3
    多线程死锁二维交集图-无死锁4
    多线程死锁二维交集图-无死锁5
    多线程死锁二维交集图-无死锁6

  • 方法二:绘制资源-线程占有/请求依赖图,如果一旦存在环路,必然会出现概率死锁:
    多线程单线程死锁

    上面的单线程死锁,看似很简单,很多人都不相信会出现这种情况。其实,在一个简单的程序中可能正确,一旦耦合了复杂的流程,可能导致大家都不能快速的判定此种情况。

    #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;
    }
    

    多线程死锁
    上面的多线程死锁,其实是非常常见,需要细心的处理依赖关系,才能识别出来。

避免死锁方法

避免死锁的方法,其实就是避免出现死锁的条件:

  1. 互斥:对公共资源互斥访问,特别是对资源要修改的场景,必然是一个互斥的。除非,给每个线程提供一个资源,避免各个线程竞争。因此,对于公共资源,必然需要保证互斥,该条件不能被打破。

  2. 占有且等待:方式一,一个线程从一开始就获取到所有资源的访问权,或者换句话说,线程执行功能入口就加一个大的互斥锁。那么,对于该功能,多个线程并行的意图就会变成串行执行,执行效率大打折扣。因此,一开始获取所有的资源,是低效的。方式二,如果获取不到资源,考虑暂时释放已经获取到的资源,等到下一次条件满足时,再获取资源。那么在程序中就需要增加额外的逻辑来处理,释放再获取资源。

  3. 不可抢占:如果线程执行所需的资源被其他线程持有,可以抢占其他线程的资源,但当前是不支持的。

  4. 循环等待:在软件设计实现时,可以小心谨慎的处理,打破资源占有/请求环。

因此,避免死锁的方法,主要就是避免出现占有/请求环:

a. 内聚资源管理:使用独立的一个类来管理,对外提供接口。并要求,每个对外提供的接口是多线程安全的,换句话说,多个线程调用类对外提供的接口,不会出现资源管理异常,或者是死锁。

b. 软件设计上,类间无依赖环,比如下面的案例,就会造成死锁。
设计依赖环
这个案例中,依赖环,其实隐藏得比较深。 我们绘制一个调用序列图来看看:
设计依赖环序列

B的FUN1函数加锁,调用A注册的回调函数,在A的函数中,又调用了B的Fun2函数,而在Fun2的函数中又加锁。这样就出现了一个调用环

c. 多锁加解锁原则:

  • 所有对外提供的接口,多锁加解锁顺序保持一致。比如,二维图中的最后一张图。
  • 至少有一个线程的加解锁之间未掺杂其他锁。举例:加锁a,解锁a,加锁b,解锁b,锁a的加解锁之间,无对锁b的任何操作。比如,二维图中无死锁风险的图。

d. 打破占有且等待的条件,可以先释放资源,再待时机成熟,重新获取资源,也可以打破资源占有/请求环,从而解决死锁问题。

总结

在多线程编程中,死锁是一个常见的问题,希望通过本节的学习,能帮助大家正确的判断死锁,避免死锁,从而编写出高效和稳定的程序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值