死锁产生的原因及如何避免死锁

什么是死锁?
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
当线程互相持有对方所需要的资源时,会互相等待对方释放资源,如果线程都不主动释放所占有的资源,将产生死锁。
  例如,在某个计算机系统中只有一台打印机和一台输入设备,进程P1正占用输入设备,同时又提出使用打印机的请求,但此时打印机正被进程P2 所占用,而P2在未释放打印机之前,又提出请求使用正被P1占用着的输入设备。这样两个进程相互无休止地等待下去,均无法继续执行,此时两个进程陷入死锁状态。
本质原因:
1)、系统资源有限。
2)、进程推进顺序不合理。
3)、资源分配不当。
如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁;其次,进程运行推进顺序与速度不同,也可能产生死锁。
死锁的形成场景:
1)忘记释放锁:在申请锁和释放锁之间直接return
2)单线程重复申请锁:一个线程,刚出临界区,又去申请资源。
3)多线程多锁申请:两个线程,两个锁,他们都已经申请了一个锁了,都想申请对方的锁
4)环形锁的申请:多个线程申请锁的顺序形成相互依赖的环形
产生死锁的四个必要条件:
  ● 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用完后释放。
  ● 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  ● 不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放)。
  ● 循环等待条件:指在发生死锁时,必然存在一个进程——资源的环形链。即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。
  这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
死锁预防 ——确保系统永远不会进入死锁状态
产生死锁需要四个条件,那么,只要这四个条件中至少有一个条件得不到满足,就不可能发生死锁了。由于互斥条件是非共享资源所必须的,没法破坏,是资源本身的性质所引起的。所以,主要是破坏产生死锁的其他三个条件。
a、破坏“请求且保持”条件
方法1:所有的进程在开始运行之前,必须一次性地申请其在整个运行过程中所需要的全部资源。
优点:简单易实施且安全。
缺点:因为某项资源不满足,进程无法启动,而其他已经满足了的资源也不会得到利用,严重降低了资源的利用率,造成资源浪费。使进程经常发生饥饿现象。
方法2:该方法是对第一种方法的改进,允许进程只获得运行初期需要的资源,便开始运行,在运行过程中逐步释放掉分配到的已经使用完毕的资源,然后再去请求新的资源。这样的话,资源的利用率会得到提高,也会减少进程的饥饿问题。
b、破坏“不可剥夺”条件
当一个已经持有了一些资源的进程在提出新的资源请求没有得到满足时,它必须释放已经保持的所有资源,待以后需要使用的时候再重新申请。这就意味着进程已占有的资源会被短暂地释放或者说是被抢占了。
该种方法实现起来比较复杂,且代价也比较大。释放已经保持的资源很有可能会导致进程之前的工作失效等,反复的申请和释放资源会导致进程的执行被无限的推迟,这不仅会延长进程的周转周期,还会影响系统的吞吐量。
c、破坏“循环等待”条件
可以通过定义资源类型的线性顺序来预防,可将每个资源编号,当一个进程占有编号为i的资源时,那么它下一次申请资源只能申请编号大于i的资源。如图所示:

这样虽然避免了循环等待,但是这种方法是比较低效的,资源的执行速度会变慢,并且可能在没有必要的情况下拒绝资源的访问,比如说,进程c想要申请资源1,如果资源1并没有被其他进程占有,此时将它分配个进程c是没有问题的,但是为了避免产生循环等待,该申请会被拒绝,这样就降低了资源的利用率
死锁避免和死锁预防的区别
  死锁预防是设法至少破坏产生死锁的四个必要条件之一,严格的防止死锁的出现;而死锁避免则不那么严格的限制产生死锁的必要条件的存在,因为即使死锁的必要条件存在,也不一定发生死锁。死锁避免是在系统运行过程中注意避免死锁的最终发生。
实例演示
下面通过一个例子对安全状态和不安全状态进行更深的了解 :
  

  如上图所示系统处于安全状态,系统剩余3个资源,可以把其中的2个分配给P3,此时P3已经获得了所需的资源,执行完毕后还能还给系统4个资源,此时系统剩余5个资源所以满足P2(P2所需的资源不超过系统当前剩余量与P3当前占有资源量之和),同理P1也可以在P2执行完毕后获得自己需要的资源。
  如果P1提出再申请一个资源的要求,系统从剩余的资源中分配一个给进程P1,此时系统剩余2个资源,新的状态图如下:那么是否仍是安全序列呢?那我们来分析一下
  

系统当前剩余2个资源,分配给P3后P3执行完毕还给系统4个资源,但是P2需要5个资源,P1需要6个资源,他们都无法获得资源执行完成,因此找不到一个安全序列。此时系统转到了不安全状态。

避免死锁的几种方式:
在有些情况下死锁是可以避免的。三种用于避免死锁的技术:
1. 加锁顺序
2. 加锁时限
3. 死锁检测
加锁顺序
当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。
如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。看下面这个例子:

Thread 1:
lock A 
lock B

Thread 2:
wait for A
lock C (when A locked)

Thread 3:
wait for A
wait for B
wait for C

  

如果一个线程(比如线程3)需要一些锁,那么它必须按照确定的顺序获取锁。它只有获得了从顺序上排在前面的锁之后,才能获取后面的锁。
例如,线程2和线程3只有在获取了锁A之后才能尝试获取锁C(获取锁A是获取锁C的必要条件)。因为线程1已经拥有了锁A,所以线程2和3需要一直等到锁A被释放。然后在它们尝试对B或C加锁之前,必须成功地对A加了锁。
按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁(并对这些锁做适当的排序),但总有些时候是无法预知的。
加锁时限
另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限,该线程则放弃对该锁请求。若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行(加锁超时后可以先继续运行干点其它事情,再回头来重复之前加锁的逻辑)。
以下是一个例子,展示了两个线程以不同的顺序尝试获取相同的两个锁,在发生超时后回退并重试的场景:

Thread 1 locks A
Thread 2 locks B

Thread 1 attempts to lock B but is blocked
Thread 2 attempts to lock A but is blocked

Thread 1's lock attempt on B times out
Thread 1 backs up and releases A as well
Thread 1 waits randomly (e.g. 257 millis) before retrying.

Thread 2's lock attempt on A times out
Thread 2 backs up and releases B as well
Thread 2 waits randomly (e.g. 43 millis) before retrying.

  

在上面的例子中,线程2比线程1早200毫秒进行重试加锁,因此它可以先成功地获取到两个锁。这时,线程1尝试获取锁A并且处于等待状态。当线程2结束时,线程1也可以顺利的获得这两个锁(除非线程2或者其它线程在线程1成功获得两个锁之前又获得其中的一些锁)。
需要注意的是,由于存在锁的超时,所以我们不能认为这种场景就一定是出现了死锁。也可能是因为获得了锁的线程(导致其它线程超时)需要很长的时间去完成它的任务。
此外,如果有非常多的线程同一时间去竞争同一批资源,就算有超时和回退机制,还是可能会导致这些线程重复地尝试但却始终得不到锁。如果只有两个线程,并且重试的超时时间设定为0到500毫秒之间,这种现象可能不会发生,但是如果是10个或20个线程情况就不同了。因为这些线程等待相等的重试时间的概率就高的多(或者非常接近以至于会出现问题)。
(超时和重试机制是为了避免在同一时刻出现的竞争,但是当线程很多时,其中两个或者多个线程的超时时间一样或者接近的可能性就会很大,因此就算出现竞争而导致超时后重试,由于超时时间一样,他们又会同时开始重试,导致新一轮的竞争,带来了新的问题)
这种机制存在一个问题,在Java中不能对synchronized同步块设置超时时间。你需要创建一个自定义锁,或使用Java5中java.util.concurrent包下的工具。
死锁检测
死锁检测是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。
每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。
当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例如,线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B是否已经请求了线程A当前所持有的锁。如果线程B确实有这样的请求,那么就是发生了死锁(线程A拥有锁1,请求锁7;线程B拥有锁7,请求锁1)。
当然,死锁一般要比两个线程互相持有对方的锁这种情况要复杂的多。线程A等待线程B,线程B等待线程C,线程C等待线程D,线程D又在等待线程A。线程A为了检测死锁,它需要递进地检测所有被B请求的锁。从线程B所请求的锁开始,线程A找到了线程C,然后又找到了线程D,发现线程D请求的锁被线程A自己持有。这时它就知道发生了死锁。
下面是一幅关于四个线程(A,B,C和D)之间锁占有和请求的关系图。像这样的数据结构就可以被用来检测死锁。


那么当检测出死锁时,这些线程该做些什么呢?
一个可行的做法是释放所有锁,回退,并且等待一段随机的时间后重试。这个和简单的加锁超时类似,不一样的是只有死锁已经发生了才回退,而不会是因为加锁的请求超时了。虽然有回退和等待,但是如果有大量的线程竞争同一批锁,它们还是会重复地死锁(原因同超时类似,不能从根本上减轻竞争)。
一个更好的方案是给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为避免这个问题,可以在死锁发生的时候设置随机的优先级。
利用银行家算法避免死锁
1、银行家算法中的数据结构
(1)可利用资源向量Available[m]。m为系统中的资源种类数,如果向量Available[j] = K,则表示系统中Rj类资源由K个。
(2)最大需求矩阵Max[n][m]。m为系统中的资源种类数,n为系统中正在运行的进程(线程)数,如果Max[i][j] = K,则表示进程i需要Rj类资源的最大数目为K个。
(3)分配矩阵Allocation[n][m]。m为系统中的资源种类数,n为系统中正在运行的进程(线程)数,如果Allocation[i][j] = K,则表示进程i当前已分得Rj类资源的数目为K个。
(4)需求矩阵Need[n][m]。m为系统中的资源种类数,n为系统中正在运行的进程(线程)数,如果Need[i][j] = K,则表示进程i还需要Rj类资源K个。
以上三个矩阵间的关系:
Need[i][j] = Max[i][j] - Allocation[i][j]
2、银行家算法
设Request( i)是进程Pi的请求向量,如果Request(i) [j] = K,表示进程Pi需要K个Rj类型的资源。
(1)如果Request(i) [j] <= Need[i][j],转向步骤(2)。
(2)如果Request(i) [j] <= Available[j] ,转向步骤(3)。
(3)系统尝试着把资源分给进程Pi。
Available[j] = Available[j] - Request(i) [j];
Allocation[i][j] = Allocation[i][j] + Request(i) [j];
Need[i][j] = Need[i][j] - Request(i) [j];
(4)系统执行安全性算法,检查此次资源分配后系统是否处于安全状态。
3、安全性算法
(1)设置两个向量:
1》工作向量Work[m],它表示系统可提供给进程继续运行所需要的各类资源数目,初始值Work = Available。
2》Finish:它表示系统是否有足够的资源分配给进程,使其运行完成。开始时Finish[i] = false,当有足够的资源分配给进程时Finish[i] = true。
(2)从进程(线程)集合中找到一个能满足下述条件的进程(线程)。
1》Finish[i] = false
2》Need[i][j] <= Work[j],如果找到转到步骤3》,没找到转到步骤4》。
3》Work[j] = Work[j] + Allocation[i][j] ;
Finish[i] = true;
go to step 2;
4》如果所有进程(线程)的Finish[i] = true都满足,表示系统处于安全状态,反之系统处于不安全状态。
死锁检测与解除 ----- 在检测到运行系统进入死锁,进行恢复。
允许系统进入到死锁状态
死锁检测
下图截自《操作系统--精髓与设计原理》


死锁的解除
如果利用死锁检测算法检测出系统已经出现了死锁 ,那么,此时就需要对系统采取相应的措施。常用的解除死锁的方法:
1、抢占资源:从一个或多个进程中抢占足够数量的资源分配给死锁进程,以解除死锁状态。
2、终止(或撤销)进程:终止或撤销系统中的一个或多个死锁进程,直至打破死锁状态。
a、终止所有的死锁进程。这种方式简单粗暴,但是代价很大,很有可能会导致一些已经运行了很久的进程前功尽弃。
b、逐个终止进程,直至死锁状态解除。该方法的代价也很大,因为每终止一个进程就需要使用死锁检测来检测系统当前是否处于死锁状态。另外,每次终止进程的时候终止那个进程呢?每次都应该采用最优策略来选择一个“代价最小”的进程来解除死锁状态。一般根据如下几个方面来决定终止哪个进程:
进程的优先级
进程已运行时间以及运行完成还需要的时间
进程已占用系统资源
进程运行完成还需要的资源
终止进程数目
进程是交互还是批处理

扫描二维码关注公众号,回复: 3392840 查看本文章

本文是一篇收集整理性质的文章(主要是为了自己看起来方便),参考了以下文章:

https://www.cnblogs.com/Kevin-ZhangCG/p/9038223.html

https://blog.csdn.net/t_x_l_/article/details/73159636

https://blog.csdn.net/guaiguaihenguai/article/details/80303835

https://www.cnblogs.com/hadoop-dev/p/6899171.html

https://blog.csdn.net/snow_5288/article/details/72808852

https://blog.csdn.net/qq_38663729/article/details/80058980

http://ifeve.com/deadlock-prevention/

猜你喜欢

转载自www.cnblogs.com/liujianfeng/p/9717889.html