竞态与死锁的理解

39 阅读23分钟

1、竞态条件:

  • 定义:竞态条件指的是一种特殊的情况,在这种情况下各个执行单元以一种没有逻辑的顺序执行动作,从而导致意想不到的结果。
  • 举例1:线程T修改资源R后,释放了它对R的写访问权,之后又重新夺回R的读访问权再使用它,并以为它的状态仍然保持在它释放它之后的状态。但是在写访问权释放后到重新夺回读访问权的这段时间间隔中,可能另一个线程已经修改了R的状态。(写——读之间,该变量已经被其他线程修改
  • 举例2:另一个经典的竞态条件的例子就是生产者/消费者模型。生产者通常使用同一个物理内存空间保存被生产的信息。一般说来,我们不会忘记在生产者与消费者的并发访问之间保护这个空间。容易被我们忘记的是生产者必须确保在生产新信息前,旧的信息已被消费者所读取。如果我们没有采取相应的预防措施,我们将面临生产的信息从未被消费的危险。(生产者生产出来的信息,消费者未消费
  • 危害漏洞:如果静态条件没有被妥善的管理,将导致安全系统的漏洞。同一个应用程序的另一个实例很可能会引发一系列开发者所预计不到的事件。一般来说,必须对那种用于确认身份鉴别结果的布尔量的写访问做最完善的保护。如果没有这么做,那么在它的状态被身份鉴别机制设置后,到它被读取以保护对资源的访问的这段时间内,很有可能已经被修改了。已知的安全漏洞很多都归咎于对静态条件不恰当的管理。其中之一甚至影响了Unix操作系统的内核。

2、死锁:

(1)死锁介绍

  • 定义:死锁指的是由于两个或多个执行单元之间相互等待对方结束而引起阻塞的情况。每个线程都拥有其他线程所需要的资源,同时又等待其他线程已经拥有的资源,并且每个线程在获取所有需要资源之前都不会释放自己已经拥有的资源。
  • 举例1:一个线程T1获得了对资源R1的访问权,一个线程T2获得了对资源R2的访问权,T1请求对R2的访问权但是由于此权力被T2所占而不得不等待,T2请求对R1的访问权但是由于此权力被T1所占而不得不等待。T1和T2将永远维持等待状态,此时我们陷入了死锁的处境!这种问题比你所遇到的大多数的bug都要隐秘,
  • 举例2:打电话双方,互相拨号,同时占用通信信道,互相等待。
  • 几种解决方案
  • 1.在同一时刻不允许一个线程访问多个资源。避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
  • 2.为资源访问权的获取定义一个关系顺序。换句话说,当一个线程已经获得了R1的访问权后,将无法获得R2的访问权。当然,访问权的释放必须遵循相反的顺序。
  • 3.为所有访问资源的请求系统地定义一个最大等待时间(超时时间),并妥善处理请求失败的情况。尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
  • 4.对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
  • 5.避免同一个线程同时获取多个锁。
  • 分析:前两种技术效率更高但是也更加难于实现。事实上,它们都需要很强的约束,而这点随着应用程序的演变将越来越难以维护。尽管如此,使用这些技术不会存在失败的情况。

大的项目通常使用第三种方法。事实上,如果项目很大,一般来说它会使用大量的资源。在这种情况下,资源之间发生冲突的概率很低,也就意味着失败的情况会比较罕见。我们认为这是一种乐观的方法。

(2)死锁的情形

  • 1.数据库系统的设计:检测到一组事务发生死锁时(通过在表示等待关系的有向图中搜索循环),将选择一个牺牲者并放弃这个事务,作为牺牲者的事务会释放自己持有的资源,从而使得其他事务能够继续进行。后面应用程序可以重新执行被强制中止的事务。
  • 2.JVM发生死锁的时候,发生死锁的线程就永远不能再使用,只能中止并重启引用程序。

2、死锁分类(锁顺序死锁、资源死锁)

(1)锁顺序死锁:

1.锁顺序死锁出现的原因:使用加锁机制来确保线程安全,但是过渡的使用加锁。比如,线程A先锁住left,后尝试锁住right,而线程B先锁住right,后尝试锁住left,则A和B都各自拥有资源left和right,互相等待对方的资源,所以永久等待。

2.锁顺序死锁分析

  • 两个线程试图以不同的顺序来获取相同的锁,就会发生死锁。如果按照相同的顺序来请求锁,就不会出现循环的加锁依赖性,因此就不会出现死锁。
  • 如果所有的线程都以相同的顺序来获取锁,程序不会出现顺序性死锁,可以通过定义获得锁的顺序来避免死锁。
  • 如果在持有锁的情况下调用某个外部的方法,那么就需要警惕活跃性问题,因为在这个外部方法中可能会获取其他的锁(可能产生死锁),或者阻塞时间过长,导致其他的线程无法及时获得当前被持有的锁。

3.解决方法:开放调用,在调用外部某个方法时,不需要持有锁。

(2)资源死锁:

1.资源死锁出现的原因:使用线程池和信号量限制对资源的使用。

2.资源死锁的情形(资源池,数据库连接池中):

  • 正如当多个线程互相持有彼此正在等待的锁而又不释放自己已持有锁时发生死锁,当多个线程在相同资源集合上等待时,也可能会发生死锁。如,线程A持有数据库C的连接,并等待数据库D的连接;而线程B持有数据库D的连接,并等待与数据库C的连接。(资源池越大,出现该情况概率越小)
  • 另一种形式的资源死锁是线程饥饿死锁,一个任务提交另一个任务,并等待被提交恩物在单线程中的执行完成,第一个任务就一直等待,并使得另个任务以及这个Executor中执行的其他任务都停止执行。如果某些任务需要等待其他任务的结果,那么这些任务往往是产生饥饿死锁的主要矛盾,有界线程池/资源池与互相依赖的任务不能一起使用。

3.解决:有界线程池/资源池与互相依赖的任务不能一起使用。

3、死锁的避免与诊断

(1)支持定时的锁:

显式的使用Lock类中的定时tryLock功能来代替内置锁机制(使用内置锁时,只要没有获得锁,就会永远等待),而显式锁会指定一个超时时限,在等待超过该时间后,tryLock会返回一个失败信息。如果不能获得所需要的锁,定时的锁或者轮询锁会释放已经得到的锁,然后重新尝试获得所有的锁。(失败的记录会被记录到日志中,并采取措施),这样技术只有在同时获得两个锁时才有效,如果在嵌套的方法中请求多个锁,那么即使你知道已经持有了外层锁,也无法释放它。

(2)通过线程转储信息来分析死锁:

JVM可以通过线程转储来帮助识别死锁的发生。线程的转储包括各个运行中的线程的栈追踪信息,同时包含加锁信息,例如每个线程持有哪些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取那个锁,在生成线程转储信息之前,JVM将在等待关系图中通过搜索循环来找死锁。如果发现一个死锁,则获取相应的死锁信息,如在死锁中涉及哪些锁和线程,以及这个锁的获取操作位于程序的哪些位置。

4、其他的活跃性危险:

(1)饥饿

1.饥饿出现原因:当前线程由于无法访问所需要的资源而不能继续执行的时候,就发生饥饿,引发饥饿最常见的资源就是CPU时钟周期。还有就是Java程序中对线程的优先级使用不当,或者某个线程持有锁时,无限制的执行一些无法结束的结构(无限循环或者无限制地等待某个资源),导致其他需要获得该锁的线程无法获取它而导致饥饿。

2.解决方案:要避免使用线程优先级,因为改变线程的优先级会增加平台依赖性,并导致活跃性问题。使用默认的优先级就行了。

(2)糟糕的响应性

不良的锁管理会导致糟糕的响应性;在GUI应用程序中使用后台线程会导致糟糕的响应性,后台线程会与事件线程共同竞争CPU的时钟周期。。

(3)活锁

1.活锁出现的原因1:该问题尽管不会阻塞线程,但是不能继续执行,因为线程总是不断重复执行相同的操作,而且总失败,通常发生在处理事务消息的应用程序中。

举例1:处理事务消息的引用程序不能成功处理某个消息,消息处理机制就回滚整个事务,并将它重新放到队列的开头,消息处理器在处理这个消息时存在错误并导致失败,然后每次这个消息都从队列中取出并传递到存在错误的处理器时,就会发生事务的回滚,处理器反复被调用并返回相同的结果(毒药消息)。处理器并没有被阻塞,但是无法继续执行下去。(过度的错误恢复代码,不可修复的错误作为可修复的错误)。

2.活锁出现的原因2:当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行下去,就发生活锁。

举例2:两个过于礼貌的人在半路上面对面相遇,彼此都让出对方的路,然后在另一条路上又相遇了,反复避让下去。

3.解决方案:要解决活锁问题,需要在重试机制中引入随机性。如网络中两台机器需要用相同载波发送数据包,数据包发送冲突,然后过段时间重发又发生冲突,并不断冲突下去,如果使用随机指数退避算法,就不会发生冲突。

在并发应用中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。

5、死锁的条件

1.死锁多个进程或线程竞争某一共享资源,而出现的一种互相等待的现象

2.产生死锁的主要原因:1 系统资源不够 2 进程或者线程运行推进的顺序不合适3 资源分配不当

3.死锁的四个必要条件

  • 互斥条件:一个资源每次只能被一个进程使用。(一个资源只被一个进程使用)任务使用的资源至少有一个是不能共享的。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不变。(自己的资源不释放,等待所需要的其他资源)至少有一个任务它必须持有一个资源且正在等待获取一个当前被别的任务持有的资源。
  • 不剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺。(自己的资源,使用完前不被剥夺)资源不能被任务抢占,任务必须把资源释放当做普通事件。
  • 循环等待条件:若干进程之间形成的一种头尾相接的循环等待资源关系。(进程之间循环等待资源)必须有循环等待,这时,一个任务等待其他任务所持有的资源,后者又在等待另一个任务所持有的资源,这样一直下去,直到有一个任务在等待第一个任务所持有的资源,大家都被锁住。

只要上述条件之一不满足,就不会产生死锁。防止死锁最容易的是破坏第4个条件,只要改变等待资源的顺序即可。

6、预防死锁

防止死锁的发生只需破坏死锁产生的四个必要条件之一即可。

1) 破坏互斥条件

  • 如果允许系统资源都能共享使用,则系统不会进入死锁状态。但有些资源根本不能同时访问,如打印机等临界资源只能互斥使用。所以,破坏互斥条件而预防死锁的方法不太可行,而且在有的场合应该保护这种互斥性。

2) 破坏不剥夺条件

  • 当一个已保持了某些不可剥夺资源的进程,请求新的资源而得不到满足时,它必须释放已经保持的所有资源,待以后需要时再重新申请。这意味着,一个进程已占有的资源会被暂时释放,或者说是被剥夺了,或从而破坏了不可剥夺条件。
  • 该策略实现起来比较复杂,释放已获得的资源可能造成前一阶段工作的失效,反复地申请和释放资源会增加系统开销,降低系统吞吐量。这种方法常用于状态易于保存和恢复的资源,如CPU的寄存器及内存资源,一般不能用于打印机之类的资源。

3) 破坏请求和保持条件

  • 釆用预先静态分配方法,即进程在运行前一次申请完它所需要的全部资源,在它的资源未满足前,不把它投入运行。一旦投入运行后,这些资源就一直归它所有,也不再提出其他资源请求,这样就可以保证系统不会发生死锁。
  • 这种方式实现简单,但缺点也显而易见,系统资源被严重浪费,其中有些资源可能仅在运行初期或运行快结束时才使用,甚至根本不使用。而且还会导致“饥饿”现象,当由于个别资源长期被其他进程占用时,将致使等待该资源的进程迟迟不能开始运行。

4) 破坏循环等待条件

  • 为了破坏循环等待条件,可釆用顺序资源分配法。首先给系统中的资源编号,规定每个进程,必须按编号递增的顺序请求资源,同类资源一次申请完。也就是说,只要进程提出申请分配资源Ri,则该进程在以后的资源申请中,只能申请编号大于Ri的资源。
  • 这种方法存在的问题是,编号必须相对稳定,这就限制了新类型设备的增加;尽管在为资源编号时已考虑到大多数作业实际使用这些资源的顺序,但也经常会发生作业使用资源的顺序与系统规定顺序不同的情况,造成资源的浪费;此外,这种按规定次序申请资源的方法,也必然会给用户的编程带来麻烦。

 上面我们讲到的死锁预防是排除死锁的静态策略, 它使产生死锁的四个必要条件不能同时具备,从而对进程申请资源的活动加以限制,以保证死锁不会发生。下面我们介绍排除死锁的动态策略--死锁的避免,它不限制进程有关申请资源的命令,而是对进程所发出的每一个申请资源命令加以动态地检查,并根据检查结果决定是否进行资源分配。就是说,在资源分配过程中若预测有发生死锁的可能性,则加以避免。这种方法的关键是确定资源分配的安全性。

7、安全序列

  我们首先引入安全序列的定义:所谓系统是安全的,是指系统中的所有进程能够按照某一种次序分配资源,并且依次地运行完毕,这种进程序列{P1,P2,...,Pn}就是安全序列。如果存在这样一个安全序列,则系统是安全的;如果系统不存在这样一个安全序列,则系统是不安全的。

  安全序列{P1,P2,...,Pn}是这样组成的:若对于每一个进程Pi,它需要的附加资源可以被系统中当前可用资源加上所有进程Pj当前占有资源之和所满足,则{P1,P2,...,Pn}为一个安全序列,这时系统处于安全状态,不会进入死锁状态。

  虽然存在安全序列时一定不会有死锁发生,但是系统进入不安全状态(四个死锁的必要条件同时发生)也未必会产生死锁。当然,产生死锁后,系统一定处于不安全状态。 

8、银行家算法

  这是一个著名的避免死锁的算法,是由Dijstra首先提出来并加以解决的。

(1)背景知识

  一个银行家如何将一定数目的资金安全地借给若干个客户,使这些客户既能借到钱完成要干的事,同时银行家又能收回全部资金而不至于破产,这就是银行家问题。这个问题同操作系统中资源分配问题十分相似:银行家就像一个操作系统,客户就像运行的进程,银行家的资金就是系统的资源。

(2)问题的描述

  一个银行家拥有一定数量的资金,有若干个客户要贷款。每个客户须在一开始就声明他所需贷款的总额。若该客户贷款总额不超过银行家的资金总数,银行家可以接收客户的要求。客户贷款是以每次一个资金单位(如1万RMB等)的方式进行的,客户在借满所需的全部单位款额之前可能会等待,但银行家须保证这种等待是有限的,可完成的。

  例如:有三个客户C1,C2,C3,向银行家借款,该银行家的资金总额为10个资金单位,其中C1客户要借9各资金单位,C2客户要借3个资金单位,C3客户要借8个资金单位,总计20个资金单位。某一时刻的状态如图所示。

  

| | C1 2(7) | | ----------- | | C2 2(1) | | C3 4(4) | | 余额2 | | | C1 2(7) | | ----------- | | C3 4(4) | | 余额4 | | | C1 2(7) | | ----------- | | 余额8 | | | 余额10 | | -------- | |   | | | | | ------------------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------- | ------------------------- | - | - | - | - | | (a) | (b) | (c) | (d) | | | | | |   |   |   |   |   |   |   |   |

                                 ****

银行家算法示意图

  对于a图的状态,按照安全序列的要求,我们选的第一个客户应满足该客户所需的贷款小于等于银行家当前所剩余的钱款,可以看出只有C2客户能被满足:C2客户需1个资金单位,小银行家手中的2个资金单位,于是银行家把1个资金单位借给C2客户,使之完成工作并归还所借的3个资金单位的钱,进入b图。同理,银行家把4个资金单位借给C3客户,使其完成工作,在c图中,只剩一个客户C1,它需7个资金单位,这时银行家有8个资金单位,所以C1也能顺利借到钱并完成工作。最后(见图d)银行家收回全部10个资金单位,保证不赔本。那麽客户序列{C1,C2,C3}就是个安全序列(C2、C3、C1),按照这个序列贷款,银行家才是安全的。否则的话,若在图b状态时,银行家把手中的4个资金单位借给了C1,则出现不安全状态:这时C1,C3均不能完成工作,而银行家手中又没有钱了,系统陷入僵持局面,银行家也不能收回投资。

  综上所述,银行家算法是从当前状态出发,逐个按安全序列检查各客户谁能完成其工作,然后假定其完成工作且归还全部贷款,再进而检查下一个能完成工作的客户,......。如果所有客户都能完成工作,则找到一个安全序列,银行家才是安全的。

  从上面分析看出,银行家算法允许死锁必要条件中的互斥条件,占有且申请条件,不可抢占条件的存在,这样,它与预防死锁的几种方法相比较,限制条件少了,资源利用程度提高了。

这是该算法的优点。其缺点是:

   〈1〉这个算法要求客户数保持固定不变,这在多道程序系统中是难以做到的。   

   〈2〉这个算法保证所有客户在有限的时间内得到满足,但实时客户要求快速响应,所以要考虑这个因素。  

   〈3〉由于要寻找一个安全序列,实际上增加了系统的开销

9、死锁的检测和解除

先前的死锁预防以及避免算法都是在进程分配资源时施加限制条件或进行检测,若系统为进程分配资源时不采取任何措施,就应该提供死锁检测和解除的手段。

(1)资源分配图

系统死锁,可利用资源分配图来描述。如图2-17所示,用圆圈代表一个进程,用框代表一类资源。由于一种类型的资源可能有多个,用框中的一个点代表一类资源中的一个资源。从进程到资源的有向边叫请求边,表示该进程申请一个单位的该类资源;从资源到进程的边叫分配边,表示该类资源已经有一个资源被分配给了该进程。

 

 

在图2-17所示的资源分配图中,进程P1已经分得了两个R1资源,并又请求一个R2 资源;进程P2分得了一个R1和一个R2资源,并又请求一个R1资源。

(2)死锁定理

通过简化资源分配图的方法检测系统状态S是否为死锁状态。简化步骤如下:

 

 

  • 1) 在资源分配图中,找出既不阻塞又不是孤点的进程Pi(即找出一条有向边与它相连,且该有向边对应资源的申请数量小于等于系统中已有空闲资源数量。若所有的连接该进程的边均满足上述条件,则这个进程能继续运行直至完成,然后释放它所占有的所有资源)。消去它所有的请求边和分配边,使之成为孤立的结点。在图2-18(a)中,P1是满足这一条件的进程结点,将P1的所有边消去,便得到图(b)所示的情况。
  • 2) 进程Pi所释放的资源,可以唤醒某些因等待这些资源而阻塞的进程,原来的阻塞进程可能变为非阻塞进程。如图2-18(c)所示。
  • S为死锁的条件是当且仅当S状态的资源分配图是不可完全简化的,该条件为死锁定理。

(3)死锁的解除

一旦检测出死锁,就应立即釆取相应的措施,以解除死锁。死锁解除的主要方法有:

  • 1) 资源剥夺法。挂起某些死锁进程,并抢占它的资源,将这些资源分配给其他的死锁进程。但应防止被挂起的进程长时间得不到资源,而处于资源匮乏的状态。
  • 2) 撤销进程法。强制撤销部分、甚至全部死锁进程并剥夺这些进程的资源。撤销的原则可以按进程优先级和撤销进程代价的高低进行。
  • 3) 进程回退法。让一(多)个进程回退到足以回避死锁的地步,进程回退时自愿释放资源而不是被剥夺。要求系统保持进程的历史信息,设置还原点。

(4)避免死锁的方法

1)避免一个线程同时获取多个锁;

2)避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源;

3)尝试使用定时锁,使用lock.tryLock(timeout)来代替使用内部锁机制;

4)对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。

10、死锁代码实现步骤

1)两个线程里面分别持有两个Object对象:lock1和lock2,这两个lock作为同步代码块的锁;

2)线程1的run()方法中同步代码快先获取lock1的对象所,Thread.sleep(),时间不需要太多,50毫秒差不多;然后接着获取lock2的对象锁。(sleep可以防止线程1启动一下子连续获得lock1和lock2两个对象的对象锁;

3)线程2的run()方法中同步代码块先获取lock2的对象锁,接着获取lock1的对象锁,这时lock1的对象锁已经被线程1锁持有,线程2肯定是要等待线程1释放lock1的对象锁的;

4)线程1睡完后,线程2已经获取lock2的对象锁,线程1此时尝试获取lock2的对象锁便被阻塞,而线程2准备获取线程1的对象锁时,也被阻塞。