死锁

一、死锁的概念:

1、死锁的现象描述

  在很多应用中,需要一个进程排他性的访问若干种资源而不是一种。例如,两个进程准备分别将扫描的文档记录到CD上。进程A请求使用扫描仪,并被授权使用。但进程B首先请求CD刻录机,也被授权使用。这时,A请求使用CD刻录机,但这个请求在B释放CD刻录机前会被拒绝。但是,进程B非但不会释放CD刻录机,还去请求扫描仪。这时,两个进程僵持不下,都被阻塞,并一直处于这样的状态。这种状况就叫做死锁(deadlock)。

2、死锁的规范定义

  如果一个进程集合中的每个进程都在等待只能由该进程集合中的其他进程才能引发的事件,那么,该进程集合是死锁的。

3、死锁的原因

(1)系统资源不足;(2)进程(线程)推进的顺序不恰当;(3)资源分配不当。

二、产生死锁的四个必要条件:

  1、互斥条件。每个资源要么已经分配给了一个进程,要么就是可用的。

  2、占有和等待条件。已经得到可某个资源的进程可以再请求新的资源。

  3、不可抢占条件。已经分配给一个进程的资源不能强制性地被抢占,它只能由占有它的进程显式的释放。

  4、循环等待条件。死锁发生时,系统中一定有两个或两个以上的进程组成的一条环路,该环路中的每一个进程都在等待着下一个进程所占有的资源。

  死锁发生时,以上四个条件一定是同时满足的。如果有任意一条不成立,那么死锁就不会发生。

三、模拟死锁

  我们来模拟一个出现死锁的程序, 然后通过常规方式来确定是否出现了死锁, 以及在那些线程上出现的。 如下是经典的死锁程序:
 

注: 线程A获取锁1, 线程B获取锁2, 然后线程A/B分别去获取锁2/1, 两者谁也不松手的, 又不得对方的, 运行后:

在 './mylock' 命令后面加 "&" ,使之后台运行,然后用 'ps aux | grep mylock' 查看线程号,再使用pstack来快速扫描堆栈查看:

第二次执行 pstack(pstack –进程号)的输出结果:

  连续多次查看这个进程的函数调用关系堆栈进行分析:当进程吊死时,多次使用 pstack 查看进程的函数调用堆栈,死锁线程将一直处于等锁的状态,对比多次的函数调用堆栈输出结果,确定哪两个线程(或者几个线程)一直没有变化且一直处于等锁的状态(可能存在两个线程 一直没有变化)。

输出分析:

根据上面的输出对比可以发现,线程 3 和线程 2 一直处在等锁状态(pthread_mutex_lock),在连续两次的 pstack 信息输出中没有变化,所以我们可以推测线程 4 和线程 5 发生了死锁。

四、如何有效地预防死锁

  预防死锁的根本办法就是要使死锁产生的4个必要条件之一不存在。下面来分析一下破坏这些条件的可能性。

1、破坏互斥条件

  破坏互斥条件即允许多个进程同时访问资源。由于多数资源的必须互斥访问这一固有特性不能改变,因此,死锁的预防通过破坏这个必要条件在多场合是行不通的。例如,打印机资源必须互斥使用,否则几个进程同时使用,每个进程各打印一行,这种输出信息的方式显然是不能被用户接受的。

2、破坏占有和等待条件

  采用资源静态分配法可破坏这一条件,该方法是指在进程运行前,一次性地_请分配它运行所需的全部资源。若系统有足够的资源分配给某一进程,则一次性地将其所需资源分配给该进程,这样,在进程运行期间便不会再提出任何资源请求,从而使等待条件不成立。如果分配时有一种资源要求不能满足,则进程需要的其他资源也先不分配给进程,从而避免进程在等待期间占用任何资源,破坏了占用条件,从而避免死锁的发生。

    优缺点:该方法控制简单且容易实现,但由于进程运行期间对所需资源的全部占用,使得某些使用时间很短的资源被长时间占用,这样会严重影响系统 资源的充分利用,导致资源利用率降低,同吋也影响到未获得全部资源的进程推迟运行。

3、破坏不剥夺条件

  采用剥夺式控制方法可以破坏该条件,该方法是使一个已保持了某些资源的进程,由于新的资源要求目前得不到满足,它必须先暂时释放巳保持的所有资源(一种剥夺式),然后去等待,以后再一起向系统提出申请,这样也能防止死锁。

  优缺点:这种方法实现起来相对较难,为了保护进程自动放弃资源的现场以及后来的再次恢复,需要付出高昂的代价,并且这种方法只适用于处理机和存储器资源,对其他资源,此法不宜使用。

4、破坏循环等待条件

  采用资源顺序分配法可破坏该条件。这种分配方法的基本思想是:把系统的全部资源分成多个层次,一个进程得到某一层的一个资源后,它只能再请较一层的资源;当一个进程要释放某层的一个资源时,必须先释放所占有的较高层的资源;当一个进程获得了某一层的一个资源后,它想再申请该层中的另一个资源,就必须先释放在该层中已占有的资源。或者说,进程释放资源的顺序是按照申请资源的相反顺序进行的。这样可以预防循环等待现象的发生,因此不会发生死锁。使用该方法要特別注意的问题是对资源所处层次的安排。在通常情况下,把各进程经常用到的、比较普遍的资源安排在较低的层次上,把重要且相对匮乏的资源安排在较高的层次上,以便实现对各资源的最大限度的利用。

  优缺点:该方法相对于前面介绍的方法,在资源利用率和系统吞吐量上都有明显的改善。但也存在一些缺陷:

  (1)低层次的资源必须在进程请求分配髙层次的资源之前提前申请,这对于暂时不需使用的低层次资源来说,会因空闲等待而产生浪费。

  (2)各类设备的资源层次一经设定,便不能经常随意改动,这就限制了新类型设备的增加。

  (3)各资源的层次是按照大多数进程使用资源的顺序设置的。对于资源使用与此层次相闪配的进程,资源能得到有效的利用,否则,资源的浪费现象将

然存在。

五、如何解除死锁

  死锁的解除实质上就是如何让释放资源的进程能够继续运行。为了解除死锁就要剥夺资源,此时,需要考虑一下几个问题:

  (1)选择一个牺牲进程,即要剥夺哪个进程的哪些资源;

  (2)重新运行或回到某一点开始继续运行.若从一个进程那里剥夺了资源,要为该进程作些什么事情?显然,这个进程是不能继续正常执行了.必须将该进程回到七点或某个状态,以后在重新开始执行.令进程夭折的方法虽然简单,但代价大;而更有效的方法是只让它退回到足以解除死锁的地步即可.那么,问题转换成进程回退的状态由什么组成?怎样才能更方便的确定该状态,这就要求系统保持根多的有关进程运行的信息;

  (3)怎样保证不发生”饿死”现象,即如何保证并不总是剥夺同一进程的资源,而导致该进程处于”饥饿”状态;

  (4)“最小代价”,即最经济合算的算法,使得进程回退带来的开销最小.但是,”最小开销”是很不精确的,进程重新运行的开销包括很多因素

  (a)进程的优先级

  (b)进程已经运行了多长时间了,该浸沉完成其任务还需要多长时间 

  (c)该进程使用的资源种类和数量?这些资源能简单的剥夺吗? 

  (d)为完成任务,进程还需要多少资源?

  (e)有多少进程要被撤销 

  (f)该进程被重新启动运行的次数. 

  一旦决定一个进程必须回退,就一定要确定这个进程回退多少.最简单的方法是从头来,让其重新运行,这将会使一个进程的工作”前功尽弃”. 

死锁解除法可归纳为两大类

1、剥夺资源 

  使用挂起/激活机制挂起一些进程,剥夺它们占有的资源给死锁进程,以解除死锁,待以后条件满足时,在激活被挂起的进程.。

  由于死锁是由进程竞争资源而引起的,所以,可以从一些进程那里强行剥夺足够数量的资源分配给死锁进程,以解除死锁状态.剥夺的顺序可以是以话费最小资源数为依据.每次剥夺后,需要再次调用死锁检测算法,资源被剥夺的进程为了在得到该资源,必须重新提出申请,为了安全的释放资源,该进程就必须返回到分配资源前的某一点.经常使用的方法有:

  (1) 欢迎算法,即恢复计算结果和状态.

  (2) 建立检查点主要是用来恢复分配前的状态.这种发放对实时系统和长时间运行的数据处理来说是一种常用技术.在实时系统中,经常在某些程序地址插入检查的程序段,即采用检查点的技术来验证系统的正确性,如发现故障,可从检查点重新启动.因此,在有些实时系统中,一旦发现死锁,可以在释放某进程的资源后,从检查点重新启动。

2.撤销进程

  撤销死锁进程.将它们占有的资源分配给另一些死锁进程,知道死锁解除为止。可以撤销所有死锁进程,或者琢个撤销死锁进程,美撤销一个进程就检测死锁是否继续存在,若已没有死锁,就停止进程的撤销。如果按照某种顺序依次撤销已死锁的进程,知道获得为解除死锁所需要的足够可用的资源为止,那么在极端情况下,这种发放可能造成除一个死锁进程外,其余的死锁进程全部被撤销的局面。

   按照什么原则撤销进程?较实用而又简单的方法是撤销那些代价最小的进程,或者使撤销进程的数目最小.一下几点可作为衡量撤销代价的标准:

  (1)进程优先数,即被撤销进程的优先数;

  (2)进程类的外部代价.不同类型的进程可以规定出各自的撤销代价.系统可根据这些规定,撤销代价最小的进程,达到解除死锁的目的;

  (3)运行代价,即重新启动进程并运行到当前撤销点所需要的代价.这一点可由系统记帐程序给出;

   优缺点:撤销发的优点是简单明了,但有时可能不分青红皂白的撤销一些甚至不影响死锁的进程。

五、死锁模型及解决算法

 事务 B 完成之后事务 A 才能完成,但是事务 B 由事务 A 阻塞。该条件也称为循环依赖关系:事务 A 依赖于事务 B,事务 B 通过对事务 A 的依赖关系关闭循环。

死锁的发生, 必然意味着有向图(依赖关系)的构建存在环。
  关于模型, 我们可以这么假定, 锁为有向边, 申请锁的线程A为起点, 拥有锁的线程B为终点.。这样就形成线程A到线程B的一条有向边, 而众多的锁(边)和线程(点), 就构成了一个有向图。

让我们从反向获取线程拥有的锁列表这个思路出发, 如何去实现?  拦截lock/unlock操作, 并添加汇报线程与锁关系的功能, 那自然能构建有向图. 进而实现自动检测死锁情况。
  这边我们可以借助宏扩展
(宏不会递归展开, 这是关键)来巧妙实现这个功能。

 1 #include <sys/syscall.h>
 2  
 3 #define gettid() syscall(__NR_gettid)
 4  
 5 // 拦截lock, 添加before, after操作, 记录锁与线程的关系
 6 #define pthread_mutex_lock(x)                                            
 7     do {                                                                 
 8         printf("before=>thread_id:%d apply mutex:%p\n", gettid(), x);    
 9         pthread_mutex_lock(x);                                           
10         printf("after=>thread_id:%d acquire mutex:%p\n", gettid(), x);   
11     } while (false);
12  
13 // 拦截unlock, 添加after操作, 解除锁和线程的关系
14 #define pthread_mutex_unlock(x)                                          
15     do {                                                                 
16         pthread_mutex_unlock(x);                                         
17         printf("unlock=>thread_id: %d release mutex:%p\n", gettid(), x); 
18     } while(false);

注: gettid函数用于获取线程实际的id, 重命名的pthread_mutex_lock/pthread_mutex_unlock宏, 添加了对before/after拦截调用, 并汇报记录了锁与线程的关系。

 扩展:锁的本质

1.作用:保证数据一致性,使得在某一时间点,只有一个线程进入临界区代码。

2.本质:是内存中的一个整形数,不同的数值表示不同的状态,比如1表示空闲状态和加锁状态。加锁时判断锁是否为空,为空的话修改加锁状态返回成功,如果已经上锁返回失败,解锁时就把锁状态修改为空闲状态。
原子性保证:加锁步骤1.读内存表示锁的变量,2.判断锁的状态,3.若已加锁返回失败,4.否则置锁为上锁状态,5.返回成功
每一步对应一条汇编语句,可认为每一步都是原子的,当发生中断时
 
3.情景分析:两个线程会同时获得锁的情况
多核:两个核上的代码同时申请一个锁,两个核同时读取锁变量,同时判断锁是空闲的,再各自修改锁变量为上锁状态,都返回成功,这样两个核同时获取到了锁。
中断: 当线程A执行完第一步后,发生了中断,os调度线程B,线程B也来加锁并且加锁成功,此时又发生中断,OS调度线程A执行,从第二步开始,也加锁成功。
“test and set”指令将读取内存、判断和设置值作为一个原子操作。单核环境下,锁的操作肯定是原子性了,但多核环境,多个核心他们的锁操作是没有干扰的,都能够同时执行“test and set”,还是会出现两个线程同时获取到锁的情况,所以硬件提供了锁内存总线的机制,在锁内存总线的状态下执行“test and set”操作就可以保证一个只有一个核执行成功,也就保证了不会存在多线程获取到锁的情况。
 
4.硬件层面上的实现
cpu会通过对总线加锁的手段来解决多核同时获取锁的情况:
在cpu芯片上有一个HLOCK Pin,可以通过发送指令来操作,将#HLOCK Pin电位拉低,并持续到这条指令执行完毕,从而将总线锁住,这样同一总线上的其他CPU就不能通过总线来访问内存了。最开始这些功能是用来测试cpu的,后来被操作系统实现而封装成各种功能:关键代码段,信号量等。
在加锁的代码编译成汇编后,会有个lock指令前缀,lock会使得紧跟在其后的指令变成atomic instruction,暂时的锁一下总线,指令执行完,总线就解锁了。
 
总结:在硬件层面,cpu提供了原子操作、锁内存总线等机制,OS根据这几个cpu硬件机制就能够实现锁,在基于锁,就能实现各种各样的同步机制(信号量、消息等等),要理解os提供的各种同步手段,需要先理解os是怎么实现锁的。
posted @ 2017-06-14 22:19  滴巴戈  阅读(544)  评论(0编辑  收藏  举报