死锁原理
死锁:一组相互竞争系统资源或者进行通信的进程间“永久”阻塞的现象。
资源分为两类:可重用资源和可消耗资源。
可重用资源:一次只能被一个进程使用且不会被耗尽的资源。如处理器、内存和外存等。
可消耗资源:可被创建和销毁的资源。如中断、信号和缓冲区中的内容等。
死锁的三个必要条件:
- 互斥:资源只能互斥使用。
- 占有并等待:进程能在等待获取资源的过程中持有已获得的资源。
- 不可抢占:不能强行抢占进程已持有的资源。
这三个条件满足有可能产生死锁,加上一个条件,构成死锁产生的充分条件:
4. 循环等待:存在一个进程链,每个进程占有相邻的一边进程需要的资源,同时请求相邻的另一边进程需要的资源。
对于死锁的处理方法:1.预防、2.避免、3.检测。
死锁预防
死锁预防(deadlock prevention)是设计一种排除死锁发生的可能性的策略。避免产生死锁的必要条件中的一条发生的方法称为间接死锁预防;避免循环等待发生的方法称为直接死锁预防。
互斥无法预防,必须保证互斥功能能够正常生效。
占有并等待预防,进程一次性请求所有资源,当部分资源暂时得不到时阻塞直到请求的所有资源都获得时解除。缺点是阻塞时间长;分配给一个进程的某些资源长时间不会被使用,同时不能被其他进程使用。
不可抢占预防,占有部分资源的进程在请求资源时若得不到满足则必须释放已占有的资源;当高优先级进程请求的资源被低优先级进程占有时,可以通过 OS 抢占的方式,使低优先级进程释放占有的资源。
循环等待预防,将所有的资源按照某种方式规定一种顺序,所有进程必须根据资源的顺序请求资源。优点是进程执行速度变慢;非必要情况下资源拒绝访问。
死锁避免
死锁避免(deadlock avoidance)和死锁预防差别很小,允许三个必要条件的发生,检查发生死锁的可能性,使得出现死锁的可能性为 0。
在死锁避免中,通过判断资源分配请求是否可能发生死锁来决定是否允许该请求。
死锁避免方法:若一个进程的请求会导致死锁,则不启动该进程;若一个进程的资源分配请求会导致死锁,则不同意该请求。
当启动一个新进程时,评估该进程每种类型的资源需要的最大资源量和当前所有进程需要的同种类型资源的最大资源量之和是否大于计算机中该类型资源的最大量,如果大于,则不启动该进程;如果小于,则启动该进程。
资源分配拒绝策略又称银行家算法(banker algorithm)。在此策略中,系统状态指当前系统全部资源分配给所有进程、系统剩余可用的资源、所有进程还未完全满足的资源等情况。安全状态(safe state)指当前系统剩余资源有至少一种分配方式使得当前进程中有进程可以完全获取需要的资源执行完毕的情况;非安全状态与之相反,剩余资源无论怎么分配都无法使得当前进程中任一个进程需要的资源得到满足从而可以执行。
进程请求一组资源时,假设同意该请求,在此前提下,系统状态发生改变,此时检查系统状态是否处于安全状态,如果处于,则同意该请求;否则,拒绝该请求并将进程阻塞直到同意该请求后系统状态处于安全状态。
/*
** 银行家算法(资源分配拒绝策略)
*/
// 系统状态
struct state {
int resource[m]; // 系统中假设有m种类型的资源,每种资源的总量一定
int available[m]; // 当前系统中每种类型资源的可分配数量。
// 系统中当前n个进程对每种资源需要的最大量。
// 单个进程仅当占有全部所需的资源时才能正常运行
int claim[n][m];
int alloc[n][m]; // 系统中当前n个进程对每种资源的已占有数量。
};
// 分配策略
if (alloc[i][*] + request[*] > claim[i][*]) {
error;
} else if (request[*] > available[*]) {
suspend process;
} else {
alloc[i][*] += request[*];
available[*] -= available[*];
/* 改变系统状态为 newstate */;
}
if (safe(newstate)) {
/* 进行资源分配 */;
} else {
/* 系统状态恢复成原始状态 */;
suspend process;
}
// 检测系统状态是否安全
boolean safe(state s) {
int currentAvail[m];
currentAvail = available;
rest = { /* 当前所有的进程 */};
possible = true;
while (possible) {
/* 从 rest 中找一个进程 k 使得 claim[k][*] - alloc[k][*] 小于等于 currentAvail */;
if (/* 找到了 */) {
currentAvail += alloc[k][*]; // 找到的进程默认会执行完毕,然后释放占有的资源
rest -= {/* 进程 k */};
} else {
possible = false;
}
}
return rest == null;
}
死锁避免的优点:
- 不需要抢占和回归进程。
- 相比死锁预防限制较少。
缺点:
- 必须事先声明每个进程需要的最大资源数量。
- 多个进程必须无关。
- 系统资源数量固定。
- 占有资源的进程不能退出。
死锁检测
死锁检测(deadlock detection)通过周期性的执行一个算法检测当前是否存在死锁,通过检测是否存在循环等待现象。
一般检测的时机是在请求资源时,优点是可以尽早发现死锁、算法相对简单,缺点是需要占用较多的处理器时间。
一种常用的检测算法是,通过检查当前所有进程中某一个进程还需要的资源是否可以得到满足。如果可以,则假设该进程会执行完毕,将其目前占用的资源释放为空闲资源,否则终止算法;如果算法没有终止,标记该进程,重复该算法,直到所有进程都被检查。算法结束后,所有的进程都被标记,说明没有产生死锁;存在进程没有被标记,说明当前存在死锁。
当检测到死锁时,需要使用某一策略解除死锁。策略有:
- 取消所有和死锁相关进程。
- 将死锁相关进程状态回滚到某一没有死锁的时间点。
- 连续取消死锁相关进程中的某一个,直到不存在死锁。
- 连续抢占资源直到不存在死锁。
上述 3 和 4 对于选择具体的进程进行回滚或者被抢占采取的策略有:
目前为止消耗的处理器时间最少。
目前为止产生的输出最少。
预计剩下的时间最长。(这一点难以预测)
目前为止分配的资源总量最少。
优先级最低。
上述几种解决死锁的措施各有优缺点,OS 可以针对具体情况采用不同的措施。
哲学家就餐问题
一个圆桌旁边有5个座椅围成一圈,每两个座椅之间放入一把叉子。每个座椅上面有一个哲学家,哲学家每天只会进行思考和进餐两种行为,每个哲学家需要拥有左右两边的叉子才能进餐。在进餐的时候需要保证叉子的使用是互斥的、不发生死锁和饥饿问题。
/*
** 一次只允许4位哲学家入座进餐的解决方案
*/
semaphore fork[5] = {1};
semaphore room = 4;
int i;
void philosopher(int i) {
while (true) {
think();
wait(room);
wait(fork[i]);
wait(fork[(i + 1) % 5]);
eat();
signal(fork[(i + 1) % 5]);
signal(fork[i]);
signal(room);
}
}
void main() {
parbegin(philosopher(0),...philosopher(4));
}
另一种方案:
/*
** 使用管程解决哲学家就餐问题
*/
monitor diningController;
cond forkReady[5];
boolean fork[5] = {true};
void getForks(int pid) {
int left = pid;
int right = (++pid) % 5;
if (!fork[left]) {
cwait(forkReady[left]);
}
fork[left] = false;
if (!fork[right]) {
cwait(forkReady[right]);
}
fork[right] = false;
}
void releaseForks(int pid) {
int left = pid;
int right = (++pid) % 5;
if (empty(forkReady[left]) {
fork[left] = true;
} else {
csignal(forkReady[left]);
}
if (empty(forkReady[right]) {
fork[right] = true;
} else {
csignal(forkReady[right]);
}
}
void philosopher(int k) {
while (true) {
think();
getForks(k);
eat();
releaseForks(k);
}
}
参考
[1] William Stallings, 操作系统——精髓与设计原理(8th), 2017.