从哲学家就餐问题彻底认识死锁
第一节 哲学家就餐问题
第二节 什么是死锁
第三节 死锁的定义
第四节 死锁发生的条件
第五节 如何避免死锁
5.1 动态避免,银行家算法(杠杆分配),在资源分配上下文章
5.2 静态避免,从任务代码上避免死锁
第六节 死锁的综合治理
第一节 哲学家就餐问题
假设有五位哲学家围坐在一张圆形餐桌旁,做以下两件事情之一:吃饭,或者思考。吃东西的时候,他们就停止思考,思考的时候也停止吃东西。餐桌中间有一大碗意大利面,每两个哲学家之间有一双筷子。因为用一只筷子很难吃到意大利面,所以假设哲学家必须用两只筷子吃东西。他们只能使用自己左右手边的那两只筷子。如下图所示:
每个哲学家拿起左手右手边的两只筷子,五个哲学家五双筷子,哲学家们愉快的吃起了面顺带对我表示了感谢,好了问题结束。(咦?好像哪里不对)
真正的问题是这样的,每两个哲学家之间只有一只筷子而不是一双,问题如下图所示:
哲学家必须用两只筷子吃东西,而且他们只能使用自己左右手边的那两只筷子。
对于上面的问题,我们很自然的能想到一个算法:
1.等待左边筷子可用,拿起左边的筷子
2.等待右边筷子可用,拿起右边的筷子
3.吃饭
4.放下两根筷子
显然,在尝试次数足够的情况下,有一种情况是无法避免的:每个哲学家都拿起了左边的筷子,并在等待右边的筷子。因为右边的筷子正在右侧哲学家的手中,右侧哲学家也在等待更右边的哲学家放下筷子,大家互相持有他人等待的资源并等待着他人释放,导致大家都得不到资源从而出于饥饿状态,这就是死锁。
第二节 什么是死锁
我们知道,线程的执行需要资源,每个线程都会以某种顺序使用资源,如果请求资源时被拒绝了,线程无法继续向下执行,就会等待。线程使用资源总是有三步:
1.请求资源
2.使用资源
3.释放资源
在操作系统中,线程在请求资源失败时必须等待,这种等待有两种方式。一是阻塞等待;而是非阻塞等待,也就是立即返回,做其它事情,然后再尝试请求,或者失败退出从而终止线程。
如果线程采用非阻塞的方式等待资源则不会发生死锁,可以想象,一个哲学家请求右手边的筷子失败了,放下左手拿着的筷子放弃吃饭,继续思考。那么左边的哲学家便有了两双筷子,吃完放下两双筷子则左右的哲学家都可以得到两双筷子,整个资源分配的局面就活了,不会发生死锁。
然而采用阻塞方式等待资源的话,死锁便有可能发生,每个哲学家都拿着一直筷子等待另一只筷子可用,每个哲学家都不会放下手中的筷子,这样大家都在无休止的等待。
如果有n个线程 T1~Tn ,以及n个资源 R1~Rn 。其中 Ti 持有资源 Ri ,但又请求资源 Ri+1 ,这样便行成了死锁。我们可以通过有向图来表示这种持有和等待的关系,实线表示已经持有资源,虚线表示在等待该资源,如下图所示:
每个线程都在等待某一个资源,因此没有线程可以推进,因此造成死锁。
第三节 死锁的定义
如果一组线程,每一个线程都在等待一个事件的发生(这里的事件通常指资源的释放),而每个线程等待的事件都只能由线程组中另一个线程发出,则称这组线程发生了死锁。
我们来看一组明显会造成死锁的代码:
//线程1
new Thread(){ @Override public void run(){ synchronized(A){ synchronized(B){ doSomeThing.... } } } }
//线程2
new Thread(){ @Override public void run(){ synchronized(B){ synchronized(A){ doSomeThing.... } } } }
如果 线程1 与 线程2 交替执行, 1 获得锁 A ;然后 2 执行 , 2 获得锁 B 。
1 请求 B 被拒绝,因为 B 被 2 持有; 2 请求 A 被拒绝 ,因为 A 被 1 持有。
至此,1 ,2 都不能执行,形成了如下循环等待:
我们并不是说该程序一定会发生死锁,如果线程 1 获得两个锁后线程 2 才开始执行,就不会发生死锁。但线程 1 与 2 交替运行时,发生死锁的概率比不发生死锁的概率还要大得多。我们不会讨论有多大的概率发生死锁,我们考虑死锁问题的维度是 可能会发生死锁 与 不可能发生死锁。
我们写的程序当然最好是不可能发生死锁的,这就要求我们明确定义出何时会发生死锁并进行规避,那么死锁发生的条件是什么呢?
第四节 死锁发生的条件
死锁的发生必须满足四个条件:
1. 资源有限。 非常直观的一个条件,正如第一节的问题,如果桌子上有五双筷子。每个哲学家都可以同时获得足够的筷子去吃饭,死锁便不会发生。
2. 持有等待。 即一个线程在请求新资源时,不会释放已获得的资源。如果哲学家请求右手边的筷子失败则放下左手边的筷子,那么左边的哲学家便会有两只筷子,资源得以盘活,大家可以顺序的拿起筷子吃饭。
3. 不能抢占。 如果一个哲学家请求右手边的筷子失败,就去抢夺右边哲学家手里的筷子,那么他也会得到两只筷子。
4.循环等待。 也就是第二节图片中的例子,你等我我等你,大家的等待和持有关系形成了一个闭环。
以上四个条件对死锁的形成来说缺一不可,我们只要打破其中一个条件,死锁便不会发生。那么我们如何打破这些条件来避免死锁呢?
第五节 如何避免死锁
避免死锁有两种方式:动态避免与静态避免。
5.1 动态避免,银行家算法,在资源分配上下文章
首先说一个比较有趣的例子,银行的杠杆,让我们看看银行是如何避免死锁的。
补充:对于银行来说,流动资金便是资源(计算机资源或信号量),贷款者便是资源请求方(线程)。
我们都知道,用户进行贷款一定是有需要完成的目的,我们为了简单起见,将用户的目的统一假设为拿钱来做生意,而且用户均遵守信用(有钱则会还款)。
如果用户可以贷到其信用额度的最大值的话,可以达到用户计划的预期,生意可以做成,用户可以按时还款。
如果用户得不到信用额度的最大值,将导致用户成本不足,无法成功的完成生意,则没有足够的钱来偿还贷款,造成银行呆账。
银行的资金有限,那么银行在放贷时必须考虑有没有一种分配方式可以使其储备满足每个用户的最大需求,即满足每个用户的最大信用额度的贷款。
比如假设银行有12000的流动资金,此时有A、B、C三个人申请贷款。
A信用额度为4000元
B信用额度为6000元
C信用额度为10000元
而贷款要求为:
A申请2000元
B申请4000元
C申请3000元
如果银行一次性全部同意并放款,银行将只剩3000元流动资金,此时如果 C 继续申请其剩下的7000元额度,银行将无钱可放。C生意失败导致银行面临 3000 元坏账的风险。
而如果银行只批准A与B的要求,则银行将剩下6000元流动资金。无论 A 申请剩下的2000元额度还是 B 申请剩下的2000元额度,银行都可以满足。则A 、 B 都可以顺利完成任务还款。而等待 A 与 B 均完成任务并还款后,再批准 C 的要求,这样银行剩下的流动资金为9000元,可以满足 C 剩下的7000元的额度要求,顺利支撑 C 完成生意。
也就是说,银行在审核贷款要求时,必须考虑如果同意贷款,剩下的流动资金有没有办法使所有已经贷款的用户都得到最大贷款额度的满足。也就是说,每次贷款审核,银行都要考虑发放该贷款是否会使银行进入一种非安全状态。
这样每个人的信用额度虽然都不会超过银行的总流动资金,但是所有人的信用额度加起来将远远大于银行的流动资金总数,这便是杠杆。而在杠杆下通过每笔贷款都经过仔细审核来动态的分配资源,便可以避免资金陷入贷款者的死锁(互相等待他人还债后银行放款给自己)。
07年美国次贷危机便是一个算法失误导致死锁的实例,很多公司与个人还不起贷款导致大批银行的破产。
动态避免算法便是在每次进行资源分配时,都需要仔细计算,确保该资源的申请不会使系统进入一个不安全状态。安全状态是指我们能够找到一种资源分配的方法和顺序,使每个在运行的线程都可以得到其需要的资源。如果资源的分配将使系统进入不安全状态,则拒绝。动态避免是一种在资源分配上下功夫的防止死锁的手段。
事实上很少有程序采取动态避免的策略。第一个原因是算法的复杂,我们需要维护资源的使用情况以及线程的持有资源数及需要资源数等信息;第二个原因是我们很难提前预知每个线程到底需要多少资源;第三个原因是如果线程数较多,每次分配资源时的计算将占用大量的时间。
5.2 静态避免,从任务代码上避免死锁
5.1小节我们讨论了从资源分配上对死锁进行动态避免的策略,下面我们讨论一下如何从任务代码上避免死锁。
1. 消除非抢占条件。我们将资源变为可以抢占的,哲学家可以互相抢筷子。这个策略听起来可行,但并不普适。比如如果抢占的资源不是筷子而是锁,那么锁将失去其原有的语义,这样的后果是不堪设想的!
2. 消除持有等待。这个办法是可行的,如果哲学家请求筷子失败便放下手中的持有的筷子去睡觉,每次放下筷子都会叫醒旁边睡觉的哲学家让其再次尝试(或者我们让哲学家要么拿起两只筷子,要么睡觉等待其他哲学家放下筷子时叫醒),哲学家们会顺序的得到两只筷子并去吃饭。
3. 上一小节我们讨论了使用银行家算法来进行动态的资源分配,用其解决哲学家问题是可行的,但算法将会比较复杂。我们换个角度从资源分配上来解决这个问题。之所以会发生死锁,从资源的角度来说,如果五个哲学家同时拿起一只筷子,那么桌子上将没有筷子,任何一个哲学家都无法进餐。我们如果只允许同一时间,最多有4个哲学家可以拿起筷子,那么四个哲学家中至少有一个可以拿到足够的筷子进餐(桌子上还剩一支筷子),死锁便不会发生。
这种解法其实也属于银行家算法,因为四个哲学家拿起筷子时,桌子上将只剩一只筷子。第五个哲学家尝试拿筷子,我们判断如果允许其拿起筷子,那么剩下的资源将无法满足任何一个哲学家拿起两只筷子筷子吃饭,隧拒绝其请求。
因为本问题中只有两个角色,筷子与哲学家。即资源只有一种(筷子),所以依赖关系比较简单,我们得以将银行家算法静态化。
4. 消除循环等待条件。如第三中的样例,我们规定所有需要A和B的线程都需要先获得A 再获得B ,那么死锁便不会发生。因为不会出现一个线程持有B 并等待A 的情况,便不会有循环等待发生。 对于哲学家就餐问题,我们也可以通过规定拿筷子的顺序来打破循环等待。比如,我们可以给哲学家编号,奇数号码的哲学家必须先拿左手边的筷子,偶数号码的哲学家必须先拿右手边的筷子,如下图所示:
第六节 死锁的综合治理
前面几个小节我们介绍了死锁的定义、死锁发生的条件、如何避免死锁。
对于死锁发生的四个必要条件:资源有限、资源不可抢占、持有等待、循环等待,我们只要打破其中任何一个,死锁都不会发生。
事情看起来并不复杂,但事实却是,经验再丰富的程序员在进行多道编程时都很难完全避免死锁。
主要原因是当资源与线程数增多时,很难确定其依赖关系。我们必须从所有可能的依赖关系中找出会发生死锁的情况并进行治理,这在实际编程时是很难做到的(情况简单除外)。
就比如哲学家就餐问题,其 PV 原语(同步、互斥关系)可能非常简单:
P(leftChopsticks)
P(rightChopsticks)
eat();
V(rightChopsticks)
V(leftChopsticks)
但是在各哲学家(线程)交替运行的情况下,其瞬时依赖关系会有非常多的可能。
我们除了通过考虑资源分配、顺序的访问资源以及非持有等待上进行设计尽量避免死锁的发生外,在死锁发生时也要第一时间保护现场并进行排查(复现在很多时候是非常困难的)。
我们可以通过 jps 命令查看需要查看的Java进程的vmid:
后通过 jstack 查看该进程中的堆栈情况:
向下拉,我们需要的信息在下面:
可以看到jvm发现了一个死锁,Thread-0 与 Thread-1 间发生了循环等待。
也可以用 jconsole 命令来查看:
点击“检测死锁”:
然后就可以查看发生死锁的线程的状态: