文章目录
死锁
在计算机系统中有很多独占性的资源,在任一时刻它们都只能被一个进程使用。常见的有打印机、磁带以及系统内部表中的表项。打印机同时让两个进程打印将造成混乱的打印结果;两个进程同时使用同一文件系统表中的表项会引起文件系统的瘫痪。正因为如此,操作系统都具有授权一个进程(临时)排他地访问某一种资源的能力。
两个进程独占性的访问某个资源,从而等待另外一个资源的执行结果,会导致两个进程都被阻塞,并且两个进程都不会释放各自的资源。这种情况就是死锁。
死锁可以发生在任何层面,在不同的机器之间可能会发生死锁,在数据库系统中也会导致死锁,比如进程A对记录R1加锁,进程B对记录R2加锁,然后进程A和B都试图把对象的记录加锁,这种情况下就会产生死锁。
资源
大部分死锁都和资源有关。在进程对设备,文件具有独占性(排他性)时会产生死锁。我们把这类需要排他性使用的对象称为资源。资源可以是硬件设备或一组信息。通常在计算机中有多种资源。简单来说,资源就是随着时间的推移,必须能获得,使用以及释放的任何东西。
可抢占资源和不可抢占资源
资源分为两类:可抢占和不可抢占的。
可抢占资源可以从拥有它的进程中抢占而不会产生任何副作用,存储器就是一类可抢占的资源。任何进程都能够抢先获得内存的使用权。
不可抢占资源是指在不引起相关的计算失败的情况下,无法把它从占有它的进程处抢占过来。如果一个进程已开始刻盘,突然将蓝光光盘刻录机分配给另一个进程,那么将损坏光盘。在任何时刻光盘刻录机都是不可抢占的。
某个资源是否可抢占取决于上下文环境。
总的来说,死锁与不可抢占资源有关,有关可抢占资源的潜在死锁通常可以通过在进程之间重新分配资源而化解。所以,我们的重点放在不可抢占资源上。
使用一个资源所需要的时间顺序可以用抽象的形式表示如下:
- 请求资源
- 使用资源
- 释放资源
若请求时资源不可用,则请求进程被迫等待。在一些操作系统中,资源请求失败时进程会自动被阻塞,在资源可用时再唤醒它。在其他的系统中,资源请求失败会返回一个错误代码,请求的进程会等待一段时间,然后重试。
当一个进程请求资源失败时,它通常会处于这样一个小循环中:请求资源,休眠,再请求。这个进程虽然没有被阻塞,但是从各角度来说,它不能做任何有价值的工作,实际和阻塞状态一样。在后面的讨论中。我们假设:如果某个进程请求资源失败,那么它就进入休眠状态。
请求资源的过程是非常依赖于系统的。在某些系统中,提供了request
系统调用,用于允许进程资源请求。在另一些系统中,操作系统只知道资源是一些特殊文件,在任何时刻它们最多只能被一个进程打开。一般情况下,这些特殊文件用open
调用打开。如果这些文件正在被使用,那么,发出open
调用的进程会被阻塞,一直到文件的当前使用者关闭该文件为止。
资源获取
对于数据库系统中的记录这类资源,应该由用户进程来管理其使用。一种允许用户管理资源的可能方法是为每一个资源配置一个信号量。这些信号量都被初始化为1。互斥信号量也能起到相同的作用。上述的三个步骤可以实现为信号量的down
操作来获取资源,使用资源,最后使用up
操作来释放资源。这三个步骤如下所示。
有时候,进程需要两个或更多的资源,它们可以顺序获得,如下所示。如果需要两个以上的资源,通常都是连续获取。
//一个资源
typedef int semaphore;
semaphore resource_1;
void process_A(void)
{
down(&resource_1);
use_resource_1();
up(&resource_1);
}
//两个资源
typedef int semaphore;
semaphore resource_1;
semaphore resource_2;
void process_A(void)
{
down(&resource_1);
down(&resource_2);
use_both_resources();
up(&resource_2);
up(&resource_1);
}
到目前为止,进程的执行不会出现问题。在只有一个进程参与时,所有的工作都可以很好地完成。当然,如果只有一个进程,就没有必要这么慎重地获取资源,因为不存在资源竞争。
现在考虑两个进程(A和B)以及两个资源的情况。下面代码描述了两种不同的方式,在第一段代码中,两个进程以相同的次序请求资源;在第二段代码中,它们以不同的次序请求资源。
在第一段代码中,其中一个进程先于另一个进程获取资源。这个进程能够成功地获取第二个资源并完成它的任务。如果另一个进程想在第一个资源被释放之前获取该资源,那么它会由于资源加索而被阻塞,直到该资源可用为止。
第二段代码中,可能其中一个进程获取了两个资源并有效地阻塞了另外一个进程,直到它使用完这两个资源为止。但是,也有可能进程A获取了资源1,进程B获取了资源2,每个进程如果都想请求另一个资源就会被阻塞,那么,每个进程都无法继续运行。这种情况就是死锁。
//无死锁的代码
typedef int semaphore;
semaphore resource_1;
semaphore resource_2;
void process_A(void)
{
down(&resource_1);
down(&resource_2);
use_both_resources();
up(&resource_2);
up(&resource_1);
}
void process_B(void)
{
down(&resource_1);
down(&resource_2);
use_both_resources();
up(&resource_2);
up(&resource_1);
}
//有可能出现死锁的代码
semaphore resource_1;
semaphore resource_2;
void process_A(void)
{
down(&resource_1);
down(&resource_2);
use_both_resources();
up(&resource_2);
up(&resource_1);
}
void process_B(void)
{
down(&resource_2);
down(&resource_1);
use_both_resources();
up(&resource_1);
up(&resource_2);
}
死锁简介
死锁的规范定义如下:
如果一个进程集合中的每个进程都在等待只能由该进程集合中的其他进程才能引发的事件。那么,该进程集合就是死锁的。
由于所有的进程都在等待,所以没有一个进程能引发可以唤醒该进程集合中的其他进程的事件,这样,所有的进程都只好无限期等待下去。在这一模型中。我们假设进程只含有一个线程,并且被阻塞的进程无法由中断唤醒。无中断条件使死锁的进程不能被时钟中断等唤醒,从而不能引发释放该集合中的其他进程的事件。
在大多数情况下,每个进程所等待的事件是释放进程集合中其他进程所占有的资源。换言之,这一死锁进程集合中的每一个进程都在等待另一个死锁的进程已经占有的资源。但是由于所有进程都不能运行,它们中的任何一个都无法释放资源,所以没有一个进程可以被唤醒。进程的数量以及占有或者请求的资源数量和种类都是无关紧要的,而且无论资源是何种类型(软件或者硬件)都会发生这种结果。这种死锁称为资源死锁。
资源死锁的条件
发生资源死锁的4个必要条件:
- 互斥条件。每个资源要么已经分配给了一个进程,要么就是可用的。
- 占有和等待条件。已经得到了某个资源的进程可以再请求新的资源。
- 不可抢占条件。已经分配给一个进程的资源不能强制性地被抢占,它只能被占有它的进程显式地释放。
- 环路等待条件。死锁发生时,系统中一定有两个或两个以上的进程组成的一条环路,该环路中的每个进程都在等待着下一个进程所占有的资源。
死锁发生时,以上四个条件一定是同时满足的。如果其中一个条件不成立,死锁就不会发生。
值得注意的是,每一个条件都与系统的一种可选策略相关。一种资源能否同时分配给不同的进程?一个进程能够在占有一个资源的同时请求另一个资源?资源能够被抢占?循环等待环路是否存在?
死锁建模
Holt指出如何用有向图建立上述四个条件的模型。在有向图中有两类节点:用圆形表示的进程,用方形表示的资源。从资源结点到进程结点的有向边代表该资源已被请求,授权并被进程占用。在图6-3a中,当前资源R正被进程A占用。
由进程节点到资源节点的有向边表明当前进程正在请求该资源,并且该进程已被阻塞,处于等待该资源的状态。在图6-3b中,进程B正等着资源S。图6-3c说明进入了死锁状态:进程C等待着资源T,资源T被进程D占用着,进程D又等待着由进程C占用着的资源U。这样两个进程都得等待下去。图中的环表示与这些进程和资源有关的死锁。在本例中,环是C-T-D-U-C。
我们再看看使用资源分配图的方法。假设有三个进程A,B,C以及三个资源R,S,T。三个进程对资源的请求和释放序列分别对应图6-4a~图6-4c。操作系统可以随时选择任一非阻塞进程运行,所以它可选择A运行一直到A完成其所有工作,接着运行B,最后运行C。
上述的执行次序不会引起死锁(因为没有资源的竞争),但程序也没有任何并行性。进程在执行过程中,不仅要请求和释放资源,还要做计算或者输入、输出工作。如果进程是串行运行,不会出现一个进程等待I/O的同时让另一个进程占用CPU进行计算的情形。因此,严格的串行操作有可能不是最优的。不过,如果所有的进程都不执行I/O操作,那么最短作业优先调度会比轮转调度优越,所以在这种情况下,串行运行有可能是最优的。
如果假设进程操作包含I/O和计算,那么轮转法是一种合适的调度算法。对资源请求的次序可能会如图6-4d所示。图6-4e~图6-4j是按这个次序执行的响应的资源分配图。在出现请求4后,如图6-4h所示,进程A被阻塞等待S,后续两步中的B和C也会被阻塞,结果如图6-4j所示,产生环路并导致死锁。
不过正如前面所讨论的,并没有规定操作系统要按照某一特定的次序来运行这些进程。特别地,对与一个有可能引起死锁的资源请求,操作系统可以干脆不批准请求,并把该进程挂起(即不参与调度)一直到处于安全状态为止。在图6-4中,假设操作系统知道有引起死锁的可能,那么它可以不把资源S分配给B,这样B被挂起,假如只运行进程A和C,那么资源请求和释放的过程会如图6-4k所示,而不是如图6-4d所示。这一过程的资源分配图在图6-4l~图6-4q中给出,其中没有死锁产生。
在第q步执行完成后,就可以把资源S分配给B了,因为A已经完成,而且C获得了它所需要的所有的资源。尽管B会因为请求T而等待,但是不会引起死锁,B只需要等待C结束。
总而言之,有四种处理死锁的策略。
- 忽略该问题。
- 检测死锁并恢复。让死锁发生,检测它们是否发生,一旦发生死锁,采取行动解决问题。
- 仔细对资源进行分配,动态地避免死锁。
- 通过破坏引起死锁的四个必要条件之一,防止死锁的产生。
鸵鸟算法
最简单的解决是鸵鸟算法:把头埋在沙子里,假装根本没有问题发生。每个人对该方法的看法都不相同。数学家认为这种方法根本不能接受,不论代价多大,都要彻底防止死锁的产生;工程师们想要了解死锁发生的频度,系统因各种原因崩溃的发生次数以及死锁的严重性。如果死锁平均每5年发生一次,而每个月系统都会因硬件故障,编译器错误或者操作系统故障而崩溃一次,那么大多数的工程师是不会以性能损失和可用性的代价去防止死锁。
死锁检测和死锁恢复
第二种技术是死锁检测和恢复。在使用这种技术时,系统并不试图阻止死锁的产生,而是允许死锁发生,当检测到死锁发生后,采取措施进行恢复。下面将考察检测死锁的几种方法以及恢复死锁的几种方法。
每种类型一个资源的死锁检测
我们从最简单的例子开始,即每种资源类型只有一个资源。这样的系统可能有扫描仪,蓝光光盘刻录机,绘图仪额磁带机,但每种设备都不超过一个,即排除了同时有两台打印机的情况。
可以对这样的系统构造一张资源分配图,如图6-3所示。如果这张图包含了一个或一个以上的环,那么死锁就存在。在此环中的任何一个进程都是死锁进程。如果没有这样的环,系统就没有发生死锁。
我们讨论一下更复杂的情况,假设一个系统包括A到G共7个进程,R到W共6中资源。资源的占有情况和进程对资源的请求情况如下:
- A进程持有R资源,且需要S资源。
- B进程不持有任何资源,但需要T资源。
- C进程不持有任何资源,但需要S资源。
- D进程持有U资源,且需要S资源和T资源。
- E进程持有T资源,且需要V资源。
- F进程持有W资源,且需要S资源。
- G进程持有V资源,且需要U资源。
问题是:“系统是否存在死锁?如果存在的话,死锁涉及了哪些进程?”
要回答这一问题,我们可以构造一张资源分配图,如图6-5a所示。可以直接观察到这张图中包含了一个环,如图6-5b所示。在这个环中,我们可以看出进程D,E,G已经死锁。进程A,C,F没有死锁。这是因为可把S资源分配给他们中的任一个,而且它们中的任一进程完成后都能释放S,于是其他两个进程可依次执行,直至执行完毕。
每种类型多个资源的死锁检测
如果有多种相同的资源存在,就需要采用另一种方法来检测死锁。现在我们提供一种基于矩阵的算法来检测从 P 1 P_1 P1到 P n P_n Pn这 n n n个进程中的死锁。假设资源的类型数为 m m m, E 1 E_1 E1代表资源类型1, E 2 E_2 E2代表资源类型2, E i E_i Ei代表资源类型 i ( 1 ⩽ i ⩽ m ) i(1\leqslant i \leqslant m) i(1⩽i⩽m)。 E E E是现有资源向量,代表每种已存在的资源总数。比如,如果资源类型1代表磁带机,那么 E 1 = 2 E_1=2 E1=2就表示系统有两台磁带机。
在任意时刻,某些资源已被分配所以不可用。假设A是可用资源向量,那么 A i A_i Ai表示当前可供使用的资源数(即没有被分配的资源)。如果仅有的两态磁带机都已经分配出去了,那么 A 1 A_1 A1的值为0。
现在我们需要两个数组: C C C代表当前分配矩阵, R R R代表请求矩阵。 C C C的第 i i i行代表 P i P_i Pi当前所持有的每一种类型资源的资源数。所以, C i j C_{ij} Cij代表进程 i i i所持有的资源 j j j的数量。同理, R i j R_{ij} Rij代表 P i P_i Pi所需要的资源 j j j的数量。这四种数据结构如图6-6所示。
这四种数据结构之间有一个重要的恒等式。具体地说,某种资源要么已分配要么可用。这个结论意味着:
∑
i
=
1
n
C
i
j
+
A
j
=
E
j
\sum^{n}_{i=1} C_{ij} + A_j = E_j
i=1∑nCij+Aj=Ej
换言之,如果我们将所有已分配的资源
j
j
j的数量加起来再和所有可供使用的资源数相加,结果就是该类资源的总数。
死锁检测算法就是基于向量的比较。我们定义向量A和向量B之间的关系为 A ⩽ B A\leqslant B A⩽B以表明 A A A的每一个分量要么等于要么小于和B向量相对应的分量。从数学上来说, A ⩽ B A\leqslant B A⩽B当且仅当 A i ⩽ B i ( 0 ⩽ i ⩽ m ) A_i \leqslant B_i(0\leqslant i \leqslant m) Ai⩽Bi(0⩽i⩽m)。
每个进程起初都是没有标记过的。算法开始会对进程做标记,进程被标记后就表明它们能够被执行,不会进入死锁。当算法结束时,任何没有标记的进程都是死锁进程。该算法假定了一个最坏情形:所有的进程在退出以前都会不停地获取资源。
死锁检测算法如下:
- 寻找一个没有标记的进程 P i P_i Pi,对于它而言 R R R矩阵的第 i i i行向量小于或等于A。
- 如果找到了一个这样的进程,那么将 C C C矩阵的第 i i i行向量加到A中,标记该进程,并转到第1步。
- 如果没有这样的进程,那么算法终止。
算法结束时,所有没有标记过的进程(如果存在的话)都是死锁进程。
现在我们知道如何检测死锁(至少是在这种预先知道静态资源请求的情况下)。但问题在于何时去检测它们。一种方法就是每当有资源请求时去检测,毫无疑问越早发现越好。但这种方法会占用高昂的CPU事件;另一种方法是每隔 k k k分钟检测一次,或者当CPU的使用率降到某一域值时去检测。考虑到CPU使用效率的原因,如果死锁进程数达到一定数量,就没有多少进程可运行了。所以CPU会经常空闲。
从死锁中恢复
利用抢占恢复
在某些情况下,可能会临时将某个资源从它的当前所有者哪里转移给另一个进程。许多情况下,尤其是对运行在大型主机上的批处理操作系统来说,需要人工进行干预。
在不通知原进程的情况下,将某一资源从一个进程强行取走给另一个进程使用,接着又送回。这种做法是否可行主要取决于该资源本身的特性。用这种方法恢复通常比较困难或者说不太可能。若选择挂起某个进程,则在很大程度上取决于哪一个进程拥有比较容易收回的资源。
利用回滚恢复
如果系统设计人员以及主机操作员了解到死锁有可能发生,它们就可以周期性地对进程进行检查点检查。进程检查点检查就是将进程的状态写入一个文件以备以后重启。该检查点中不仅包括存储映像,还包括了资源状态,即哪些资源分配给了该进程。为了使这一过程更有效,新的检查点不应覆盖原有的文件,而应写到新文件中。这样,当进程执行时,将会有一些列的检查点文件被累积起来。
一旦检查到死锁,就很容易发现需要哪些资源。为了进行恢复,要从上一个较早的检查点上开始,这样拥有所需要资源的进程会回滚到一个时间点,在此时间点之前该进程获得了一些其他的资源。在该检查点后所作的所有工作都丢失。实际上,是将该进程复位到一个更早的状态,那时它还没有取得所需的资源,接着就把这个资源分配给一个死锁进程。如果复位后的进程试图重新获得对该资源的控制,它就必须一直等到该资源可用时为止。
通过杀死进程恢复
最直接也是最简单的解决死锁的方法是杀死一个或若干个进程。一种方法是杀掉环中的一个进程。如果走运的话,其他进程将可以继续。如果这样做行不通的话,就需要继续杀死别的进程直到打破死锁环。
另一种方法是选一个环外的进程作为牺牲品以释放该进程的资源。在使用这种方法时,选择一个要被杀死的进程要特别小心,它应该正好持有环中某些进程所需的资源。比如,一个进程可能持有一台绘图仪而需要一台打印机,而另一个进程可能持有一台打印机而需要一台绘图仪,因而这两个进程是死锁的。第三个进程可能持有另一台同样的打印机和另一台同样的绘图仪而且正在运行着。杀死第三个进程将释放这些资源,从而打破前两个进程的死锁。
有可能的话,最好杀死可以从头开始重新运行而且不会带来副作用的进程。比如,编译晋亨可以被重复运行,由于它只需要读入一个源文件和产生一个目标文件。如果将它中途杀死,它的第一次运行不会影响到第二次运行。
死锁避免
在讨论死锁检测时,我们假设当一个进程请求资源时,它一次就请求所有的资源。不过在大多数系统中,一次只请求一个资源。系统必须能够判断分配资源是否安全,并且只能在保证安全的条件下分配资源。问题是:是否存在一种算法总能做出正确的选择从而避免死锁?答案是肯定的,但条件是必须事先获得一些特定的信息。
资源轨迹图
避免死锁的主要算法是基于一个安全状态的概念。
在图6-8中,我们看到一个处理两个进程和两种资源(打印机和绘图仪)的模型。横轴表示进程A执行的指令,纵轴表示进程B执行的指令。进程A在 I 1 I_1 I1处请求一台打印机,在 I 3 I_3 I3处释放,在 I 2 I_2 I2处请求一台绘图仪,在 I 4 I_4 I4处释放。进程B在 I 5 I_5 I5到 I 7 I_7 I7之间需要绘图仪,在 I 6 I_6 I6到 I 8 I_8 I8之间需要打印机。
图6-8中的每一点都表示出两个进程的连接状态。初始点为p
,没有进程执行任何指令。如果调度程序选中A先运行,那么在A执行一段指令后到达q
,此时B没有执行任何指令。在q
点,如果轨迹沿着垂直方向移动,表示调度程序选中B运行。在单处理机情况下,所有路径都只能是水平或垂直方向的,不会出现斜向的。因此,运动方向一定是向上或向右,不会向左或向下,因为进程的执行不可能后退。
当进程A由r
向s
移动穿过
I
1
I_1
I1线时,它请求并获得打印机。当进程B到达t
时,它请求绘图仪。
图中的阴影部分是我们感兴趣的,画着从左下到右上斜线的部分表示在该区域中两个进程都拥有打印机,而互斥使用的规则决定了不可能进入该区域。另一种斜线的区域表示两个进程都拥有绘图仪,且同样不可进入。
如果系统一旦进入由
I
1
I_1
I1,
I
2
I_2
I2,
I
5
I_5
I5,
I
6
I_6
I6组成的矩形区域,那么最后一定会达到
I
2
I_2
I2和
I
6
I_6
I6的交叉点,这时就产生死锁。在该点处,A请求绘图仪,B请求打印机,而且这两种资源均已被分配。这整个矩形区域都是不安全的,因此绝不能进入这个区域。在点t
处唯一的办法是运行进程A直到
I
4
I_4
I4,过了
I
4
I_4
I4后,可以按任何路线前进,直到终点u
。
需要注意的是,在点t
进程B请求资源。系统必须决定是否分配。如果系统把资源分配给B,系统进入不安全区域,最终形成死锁。要避免死锁,应该将B挂起,直到A请求并释放绘图仪。
安全状态和不安全状态
我们将要研究的死锁避免算法使用了图6-6中的有关信息。在任何时刻,当前状态包括了E,A,C和R。如果没有死锁发生,并且即使所有进程突然请求对资源的最大需求,也仍然存在某种调度次序能够使得每一个进程运行完毕,则称该状态是安全的。通过使用一个资源的例子很容易说明这个概念。在图6-9a中有一个A拥有3个资源实例但最终可能会需要9个资源实例的状态。B当前拥有2个资源实例,将来共需要4个资源实例。同样,C拥有2个资源实例,还需要另外5个资源实例。总共有10个资源实例,其中有7个资源已经分配,还有3个资源是空闲。
图6-9a的状态是安全的,这是由于存在一个分配序列使得所有的进程都能完成。也就是说,这个方案可以单独地运行B,直到它请求并获得另外两个资源实例,从而到达图6-9b的状态。当B完成后,就到达了图6-9c的状态。然后调度程序可以运行C,再到达图6-9d的状态。当C完成后,到达了图6-9e的状态。现在A可以获得它所需要的6个资源实例,并且完成。这样系统通过仔细地调度,就能够避免死锁,所以图6-9a的状态是安全的。
现在假设初始状态如图6-10a所示。但这次A请求并得到另一个资源,如图6-10b所示。我们还能找到一个序列来完成所有工作么?我们试一试,调度程序可以运行B,直到B获得所需资源,如图6-10c所示。
最终,进程B完成,状态如图6-10d所示,此时进入困境了。只有4个资源实例空闲,并且所有活动进程都需要5个资源实例。任何分配资源实例的序列都无法保证工作的完成。于是,从图6-10a到图6-10b的分配方案,从安全状态进入到了不安全状态。从图6-10c的状态触发运行进程A或C也都不行。回头来看,A的请求不应该满足。
值得注意的是,不安全状态并不是死锁。从图6-10b,系统能运行一段时间。实际上,甚至有一个进程能够完成。而且,在A请求其他资源实例前,A可能先释放一个资源实例,这就可以让C先完成,从而避免了死锁。因而,安全状态和不安全状态的区别是:从安全状态出发,系统能够保证所有进程都能完成;从不安全状态出发,就没有这样的保证。
单个资源的银行家算法
Dijkstra提出了一种能够避免死锁的调度算法,称为银行家算法。该模型基于一个小城镇的银行家,他向一群客户分别承诺了一定的贷款额度。算法要做的是判断对请求的满足是否会导致进入不安全状态。如果是,就拒绝请求;如果满足请求后系统仍然是安全的,就予以分配。在图6-11a中我们看到4个客户A,B,C,D,每个客户都被授予一定数量的贷款单位(比如1单位是1千美元),银行家知道不可能所有客户同时都需要最大贷款额,所以他只保留10个单位而不是22个单位的资金来为客户服务。这里将客户比作进程,贷款单位比作资源,银行家比作操作系统。
客户们各自做自己的生意,在某些时候需要贷款(相当于请求资源)。在某一时刻,具体情况如图6-11b所示。这个状态是安全的,由于保留着2个单位,银行家能够拖延除了C以外的其他请求。因而可以让C先完成,然后释放C所占的4个单位资源。有了这4个单位资源,银行家就可以给D或B分配所需的贷款单位,以此类推。
考虑假如向B提供了另一个他所请求的贷款单位,如图6-11b所示,那么我们就有如图6-11c所示的状态,该状态是不安全的。如果忽然所有的客户都请求最大的限额,而银行家无法满足其中一个的要求,那么就会产生死锁。不安全状态并不一定引起死锁,由于客户不一定需要最大贷款额度,但银行家不敢抱有这种侥幸心理。
银行家算法就是对每一个请求进行检查,检查如果满足这一请求是否会达到安全状态。若是,那么就满足该请求;否则,就推迟对这一请求的满足。为了检查状态是否安全,银行家需要考虑他是否有足够的资源满足某一客户。如果可以,那么这笔贷款就是能够收回的,并且接着检查最接近最大限额的一个客户,以此类推。如果所有投资最终都能被收回,那么该状态是安全的,最初的请求可以批准。
多个资源的银行家算法
可以把银行家算法进行推广以处理多个资源。图6-12说明了多个资源的银行家算法如何工作。
在图6-12中我们看到两个矩阵。左边的矩阵显示出为5各进程分别已分配的各种资源数,右边的矩阵显示了使各进程完成运行所需要的各种资源数。这些矩阵就是图6-6中的C和R。和一个资源的情况一样,各进程在执行前给出所需的全部资源量,所以在系统的每一步中都可以计算出右边的矩阵。
图6-12最右边的三个向量分别表示现有资源
E
E
E,已分配资源
P
P
P和可用资源
A
A
A。由
E
E
E可知系统中共有6台磁带机,3台绘图仪,4台打印机和两台蓝光光驱。由
P
P
P可知当前已分配了5台磁带机,3台绘图仪,2台打印机和2台蓝光光驱。该向量可通过将左边矩阵的各列相加获得,可用资源向量可通过从现有资源中减去已分配资源获得。
检查一个状态是否安全的算法如下:
- 查找右边矩阵中是否有一行,其没有被满足的资源数均小于或等于A。如果不存在这样的行,那么系统将会死锁,因为任何进程都无法运行结束(假定进程会一直占有资源直到它们终止为止)。
- 假如找到了这样一行,那么可以假设它获得所需的资源并运行结束,将该进程标记为终止,并将其资源加到向量A上。
- 重复以上两步,或者直到所有的进程都标记为终止,其初始状态是安全的;或者所有进程的资源需求都得不到满足,此时就是发生了死锁。
如果在第一步中同时有若干进程均符合条件,那么不管挑选哪一个运行都没有关系,因为可用资源或者会增多,或者至少保持不变。
图6-12中所示的状态是安全的,若进程B现在现在再请求一台打印机,可以满足它的请求,因为所得系统状态仍然是安全的(进程D可以结束,然是是A或E结束,剩下的进程相继结束)。
假设进程B获得两态可用打印机中的一台以后,E试图获得最后一台打印机,假如分配给E,可用资源向量会减到 ( 1 , 0 , 0 , 0 ) (1,0,0,0) (1,0,0,0),这时会引起死锁。显然E的请求不能立即满足,必须延迟一段时间。
银行家算法代码如下
// Banker's Algorithm
#include <iostream>
using namespace std;
int main()
{
// P0, P1, P2, P3, P4 are the Process names here
int n, m, i, j, k;
n = 5; // Number of processes
m = 3; // Number of resources
int alloc[5][3] = { { 0, 1, 0 }, // P0 // Allocation Matrix
{ 2, 0, 0 }, // P1
{ 3, 0, 2 }, // P2
{ 2, 1, 1 }, // P3
{ 0, 0, 2 } }; // P4
int max[5][3] = { { 7, 5, 3 }, // P0 // MAX Matrix
{ 3, 2, 2 }, // P1
{ 9, 0, 2 }, // P2
{ 2, 2, 2 }, // P3
{ 4, 3, 3 } }; // P4
int avail[3] = { 3, 3, 2 }; // Available Resources
int f[n], ans[n], ind = 0;
for (k = 0; k < n; k++) {
f[k] = 0;
}
int need[n][m];
for (i = 0; i < n; i++) {
for (j = 0; j < m; j++)
need[i][j] = max[i][j] - alloc[i][j];
}
int y = 0;
for (k = 0; k < 5; k++) {
for (i = 0; i < n; i++) {
if (f[i] == 0) {
int flag = 0;
for (j = 0; j < m; j++) {
if (need[i][j] > avail[j]){
flag = 1;
break;
}
}
if (flag == 0) {
ans[ind++] = i;
for (y = 0; y < m; y++)
avail[y] += alloc[i][y];
f[i] = 1;
}
}
}
}
cout << "Following is the SAFE Sequence" << endl;
for (i = 0; i < n - 1; i++)
cout << " P" << ans[i] << " ->";
cout << " P" << ans[n - 1] <<endl;
return (0);
}
死锁预防
破坏互斥条件
先考虑破坏互斥使用条件。如果资源不被一个进程所独占,那么死锁肯定不会产生。当然,允许两个进程同时使用打印机会造成混乱,通过采用假脱机打印机技术可以允许若干个进程同时产生输出。该模型中唯一真正请求使用物理打印机的进程是打印机守护进程,由于守护进程绝不会请求别的资源,所以不会因打印机而产生死锁。
假设守护进程被设计为在所有输出进入假脱机之前就开始打印,那么如果一个输出进程在头一轮打印之后决定等待几个小时,打印机就可能空置。为了避免这种情况,一般将守护进程设计成在完整的输出文件就绪后才开始打印。例如,若两个进程分别占用了可用的假脱机磁盘空间的一般用于输出,而任何一个也没有能够完成输出,那么会怎么样?在这种情况下,就会有两个进程,其中每一个都完成了部分的暑促,但不是它们的全部输出,于是无法继续运行下去。没有一个进程能够完成,结果在磁盘上出现了死锁。
避免分配那些不是绝对必需的资源,尽量做到尽可能少的进程可以真正请求资源。
破坏占有并等待条件
禁止已持有资源的进程再等待其他资源便可以消除死锁。一种实现方法是规定所有进程在开始执行前请求所需的全部资源。如果所需的全部资源可用,那么就将它们分配给这个进程,于是该进程肯定能够运行结束。如果有一个或多个资源正在被使用,那么就不进行分配,进程等待。
这种方法的一个直接问题是很多进程直到运行时才知道它需要多少资源。实际上,如果进程能够知道它需要多少资源,就可以使用银行家算法。另一个问题是这种方法的资源利用率不是最优的。例如,有一个进程先从输入磁带上读取数据,进行一小时的分析,最后会写到输出磁带上,同时会在绘图仪上绘出。如果所有资源都必须提前请求,这个而进程就会把输出磁带机和绘图仪控制住一小时。
不过,一些大型机批处理系统要求用户在所提交的作业的第一行列出它们需要多少资源。然后,系统立即分配所需的全部资源,并且知道作业完成才回收资源。虽然这加重了编程人员的负担,也造成了资源的浪费,但这的确防止了死锁。
另一种破坏占有并等待条件的略有不同的方案是,要求当一个进程请求资源时,先暂时释放其当前占用的所有资源,然后再尝试一次获得所需的全部资源。
破坏不可抢占条件
破坏第三个条件(不可抢占)也是可能的。假如一个进程已经分配到一套打印机,且正在进行打印输出,如果由于它需要的绘图仪无法获得而强制性的把它占有的打印机抢占掉,会引起一片混乱。但是,一些资源可以通过虚拟化的方式来避免发生这样的情况。假脱机打印机向磁盘输出,而且只允许打印机守护进程访问你真正的物理打印机,这种方式可以消除设计打印机的死锁,然而却可能带来由磁盘空间导致的死锁。但是对于大容量磁盘,要消耗完所有的磁盘空间一般是不可能的。
然而,并不是所有的资源都可以进行类似的虚拟化。
破坏环路等待条件
消除环路等待条件有几种方法。一种是保证每一个进程在任何时刻只能占用一个资源,如果要请求另外一个资源,它必需先释放第一个资源。但假如进程正在把一个大文件从磁带机上读入并送到打印机打印,那么这种限制是不可接受的。
另外一种避免出现环路等待的方法是将所有资源统一编号。如图6-13a所示。现在的规则是:进程可以在任何时刻提出资源请求,但是所有请求必需按照资源编号的顺序(升序)提出。进程可以先请求打印机后请求磁带机,但不可以先请求绘图仪后请求打印机。
按照此规则,资源分配图中肯定不会出现环。
该算法的一个变种是取消必须按升序请求资源的限制,而仅仅要求不允许进程请求比当前所占有资源编号低的资源。所以,若一个进程最初请求9号和10号资源,而随后释放两者,那么它实际上相当于从头开始,所以没有必要阻止它现在请求1号资源。
尽管对资源编号的方法消除了死锁的问题,但几乎找不出一种使每个人都满意的编号次序。当资源包括进程表项,假脱机磁盘空间,加锁的数据库记录以及其他抽象资源时,潜在的资源以及各种不同用途的数目会变得很大,以至于使编号方法根本无法使用。
死锁预防的各种方法
条件 | 处理方式 |
---|---|
互斥 | 一切使用假脱机技术 |
占有和等待 | 在开始就请求全部资源 |
不可抢占 | 抢占资源 |
环路等待 | 对资源按序编号 |
其他问题
两阶段加锁
两阶段加锁:在第一阶段,进程试图对所有所需的记录进行加锁,一次锁一个记录。如果第一阶段加锁成功,就开始第二阶段,完成更新后释放锁。在第一阶段并没有做实际的工作。
如果在第一阶段某个进程需要的记录已经被加锁,那么该进程释放它所有加锁的记录,然后重新开始第一阶段。从某种意义上说,这种方法类似于提前或者至少是未实施一些不可逆的操作之前请求所有资源。在两阶段加锁的一些版本中,如果在第一阶段遇到了已加锁的记录,并不会释放锁然后重新开始,这就可能产生死锁。
不过,在一般情况下,这种策略并不通用。例如,在实时系统和进程控制系统中,由于一个进程缺少一个可用资源就半途中断它,并重新开始该进程,这是不可接受的。如果一个进程已经在网络上读写消息,更新文件或从事任何不能安全地重复做的事,那么重新运行进程也是不可接受的。只有当程序员仔细地安排了程序,使得在第一阶段程序可以在任意一点停下来,并重新开始而不会产生错误,这时这个算法才可行,但很多应用并不能按这种方式来设计。
通信死锁
到目前为止,我们的工作都集中在资源死锁上。若一个进程请求某个其他进程持有的资源,就必须等待直到其使用者释放资源。这些资源有时是硬件或软件对象,资源死锁是竞争性同步的问题。进程在执行过程中如果与竞争的进程无交叉,便会顺利执行。进程将资源加锁,是为了防止交替访问资源而产生不一致的资源状态。交替访问加锁的资源将有可能产生死锁。
资源死锁是最普遍的一种类型,但不是唯一的一种。另一种死锁发生在通信系统中(比如说网络),即两个或两个以上进程利用发送信息来通信时。一种普遍的情形是进程A向进程B发送请求信息,然后阻塞直到B回复。假设请求信息丢失,A将阻塞以等待恢复,而B会阻塞等待一个向发送命令的请求,因此发生死锁。
尽管如此,但这并不是一个经典的资源死锁。A没有占有B所需的资源,反之亦然。事实上,并没有完全可见的资源。但是,根据标准的定义,在一系列进程中,每个进程因为等待另外一个进程引发的事件而产生阻塞,这就是一种死锁。相比于更加常见的资源死锁,这种情况叫做通信死锁。通信死锁是协同同步的异常情况,处于这种死锁中的进程如果是各自独立执行的,则无法完成服务。
通信死锁不能通过对资源排序(因为没有)或者通过仔细地安排调度来避免(因为任何时刻地请求都是不允许被延迟的)。可以通过超时来避免这样的问题。在通信过程中,只要一个信息被发出后,发送者就会启动一个定时器,定时器会记录消息的超时时间,如果超时时间到了但是消息还没有返回,就会认为消息已经丢失并重新发送,通过这种方式,可以避免通信死锁。
并非所有在通信系统或者网络发生的死锁都是通信死锁。资源死锁也会发生。如下图所示。当一个数据包从一个主机进入路由器时,它被放入一个缓冲区中,然后传输到另一个路由器,再到另一个,直至目的地。这些缓冲区都是资源并且数目优先。在图6-15中,每个路由器都有8个缓冲器。假设路由器A的所有数据包需要发送到B,B的所有数据报需要发送到C,C的所有数据包需要发送到D,然后D的所有数据报需要发送到A。那么没有数据报可以移动,因为在另一端没有缓冲区。这就是一个典型的资源死锁。
活锁
在某些情况下,当进程意识到它不能获取所需要的下一个锁时,就会尝试礼貌地释放已经获得的锁,然后等待1ms,再尝试一次。从理论上来说,这是用来检测并预防死锁的好方法。但是,如果另一个进程在相同的时刻做了相同的操作,那么就像两个人在一条路上相遇并同时给对方让路一样,相同的步调将导致对方都无法前进。
设想try_lock
原语,调用进程可以检测互斥量,要么获取它,要么返回失败。换句话说,就是它不会阻塞。程序员可以将其与acquire_lock
并用,后者也试图获得锁,但是如果不能获得就会产生阻塞。现在设想有一堆并行运行的进程(可能在不同的CPU核上)用到了两个资源,如下所示。每一个进程都需要两个资源,并使用try_lock
原语试图获取锁。如果获取失败,那么进程便会放弃它所持有的锁并再次尝试。再下面代码中,进程A运行时获得了资源1,进程2运行时获得了资源2。接下来,它们分别试图获取另一个锁并都失败了。于是它们便会释放当前持有的锁,然后再试一次。这个过程会一直重复,直到有个无聊的用户(或其他实体)前来解救其中的某个进程。很明显,这个过程中没有进程阻塞,甚至可以说进程正在活动,所以这不是死锁。然而,进程并不会继续往下执行,可以称之为活锁。
//礼貌的进程可能导致活锁
void process_A(void)
{
acquire_lock(resource_1);
while(try_lock(&resource_2) == FAIL)
{
release_lock(&resource_1);
wait_fixed_time();
acquire_lock(&resource_1);
}
use_both_resources();
release_lock(&resource_2);
release_lock(&resource_1);
}
void process_A(void)
{
acquire_lock(&resource_2);
while(try_lock(&resource_1) == FAIL)
{
release_lock(&resource_2);
wait_fixed_time();
acquire_lock(&resource_2);
}
use_both_resources();
release_lock(&resource_1);
release_lock(&resource_2);
}
活锁和死锁也经常出人意料地产生。在一些系统中,进程表中容纳的进程数决定了系统允许的最大进程数量,因此进程表属于优先的资源。如果由于进程表满了而导致一次fork
运行失败,那么一个合理的方法是:该程序等待一段随机长的事件,然后再次尝试运行fork
。
饥饿
与死锁和活锁非常相似地一个问题是饥饿。在动态运行的系统中,在任何时刻都可能请求资源。这就需要一些策略来决定在什么时候谁获得什么资源。虽然这个策略表面上很有道理,但依然有可能使一些进程永远得不到服务,虽然它们并不是死锁进程。
作为一个例子,考虑打印机分配。设想系统采用某种算法来保证打印机分配分配不产生死锁。现在假设若干进程同时都请求打印机,究竟哪一个进程能获得打印机呢?
一个可能的分配方案是把打印机分配给打印最小文件进程(假设这个信息可知)。这个方法让尽量多的顾客满意,而且看起来很公平。我们考虑下面的情况:在一个繁忙的系统中,有一个进程有一个很大的文件要打印,每当打印机空闲,系统纵观所有进程,并把打印机分配给打印最小文件的进程。如果存在一个固定的进程流,其中的进程都是只打印小文件,那么,要打印大文件的进程永远也得不到打印机。很简单,它会“饥饿而死”。
饥饿可以通过先来先服务资源分配策略来避免。在这种机制下,等待最久的进程会是下一个被调度的进程。随着时间的推移,所有进程都会变成最“老”的,因而,最终能够获得资源而完成。