上一课我们学会了用锁解决竞态。但锁这个东西,用不好会"反噬":你握着锁 A 等锁 B,对方握着锁 B 等锁 A——于是谁也不放,大家全卡死。这就是并发世界里最"优雅"、也最让人头疼的僵局——死锁(Deadlock)

从"窄桥相遇"说起

想象一条窄得只能过一辆车的桥。桥的两头,各开来一辆车,在桥中间迎面相遇

  • 甲车过不去,因为乙车挡着;
  • 乙车也过不去,因为甲车挡着;
  • 谁都不愿意倒车让路——于是两辆车都卡在原地,谁也动不了。

这就是死锁的生活版。换成计算机:

线程 A 持有锁 A,想拿锁 B;线程 B 持有锁 B,想拿锁 A。A 等 B、B 等 A,形成环,谁也推进不了,系统僵死。

死锁一旦发生,程序不会报错、不会崩溃,就是静静地卡住——比崩溃还难查,因为没有任何异常信号。


14.1 死锁是什么

一句话理解:两个或多个线程互相等着对方手里的锁,谁也不放,结果全部卡死。

关键特征:

  • 至少两个线程参与;
  • 每个线程都占着一部分资源(锁),又等着别人手里的资源;
  • 等待关系形成,导致谁都无法前进。

死锁的三个"近亲"要先分清(后面 14.5 细讲):

  • 死锁:大家都卡死,动不了;
  • 活锁:大家都在"动",但谁都推进不了;
  • 饥饿:某个线程永远抢不到资源。

14.2 死锁的四个必要条件(缺一不可)

科学家总结出,死锁要发生,必须同时满足四个条件。缺任何一个,死锁都不可能发生——这是理解死锁和设计对策的钥匙。

条件含义
互斥资源一次只能给一个人用
持有并等待拿着已有的资源,还去等新的资源
不可剥夺别人手里的资源,你抢不走
循环等待A 等 B、B 等 A,形成等待环

逐个看窄桥的例子:

  1. 互斥:桥一次只能过一辆车(资源独占);
  2. 持有并等待:甲车占了桥的一边,还等着乙车让路(占着已有、等新的);
  3. 不可剥夺:谁也不能把对方"拽"下车强行清路(资源抢不走);
  4. 循环等待:甲等乙让路,乙等甲让路(成环)。
一句话理解:破坏其中任意一个条件,死锁就不可能发生。这句话是所有死锁对策的总纲。

14.3 三种应对思路

对付死锁,有三条路,各有取舍:

① 预防(Prevention)——从设计上不让它发生

思路:主动破坏四个条件之一,让死锁"无源可生"。常见做法:

  • 一次性申请全部资源:要么全拿到,要么一个都不拿。这破坏了"持有并等待"——因为你不会"拿着 A 去等 B";
  • 按固定顺序加锁:所有线程都按"先 A 后 B"的统一顺序拿锁。这破坏了"循环等待"——因为不可能出现"A 等 B、B 等 A"的环。
预防的代价:可能降低并发度、增加资源占用,但胜在简单可靠

② 避免(Avoidance)——分配前先"算一卦"

思路:不破坏条件,而是在每次分配资源前判断"这样分会不会导致死锁",可能出事就不分。代表算法就是下一节的银行家算法

避免的代价:需要预先知道每个进程要多少资源,且每次分配都要计算,开销大。现实中用得少。

③ 检测与恢复(Detection & Recovery)——允许发生,事后收拾

思路:不管它,允许死锁发生,然后用机制检测出来,再强制恢复(比如杀掉某个进程、回滚它的操作)。

这是数据库系统常用的做法——靠"超时 + 回滚某个事务"来打破死锁。代价小、适合复杂环境。

14.4 银行家算法(直觉)

"避免"这条路,最经典的就是银行家算法。先掌握它的直觉,不用记细节。

一句话理解:像银行放贷——只把钱借给"还得起"的人。OS 在每次分配资源前,模拟"如果借出去,还能不能让所有进程都完成",不能就不借。

银行家放贷,要判断"借出去之后,对方还得起吗?还得起才借,免得坏账"。操作系统同理:

  • 系统里有若干资源(比如 12 台打印机);
  • 每个进程已经借了一部分,还需要一部分;
  • 每次分配前,OS 都要模拟:"如果我把这台借给它,剩下的还够不够让所有进程都跑完?"

举个具体例子(详纲里的场景):

  • 系统有 12 台打印机;
  • A 已借 4,还需 4;
  • B 已借 2,还需 6;
  • C 已借 3,还需 3;
  • 当前剩余 3 台

这 3 台只够借给 C(C 只需 3,借完就还,腾出更多);如果借给 A 或 B,他们借完还差、又拿不到更多,就会互相卡死。所以银行家算法会拒绝借给 A/B,只借给 C。

核心思想就一句:分配前先验证"安全",不安全就不分配。 它用"预演未来"来避免死锁,代价是必须提前知道每个进程的最大需求——这也是它难落地的原因。

14.5 活锁与饥饿

死锁的另外两个"亲戚",也要分清楚:

① 活锁(Livelock)

一句话理解:线程都在"动",但谁都推进不了(像两人让路,都往同一边让,又撞上)。

死锁是"都卡死不动";活锁是"都在动,但动得没有进展"。经典类比:两个人在窄路迎面走,都礼貌地让路——结果同时往左让,又同时往右让,反复撞上,谁也过不去。

活锁里,线程并没有阻塞,它们在不停地改变状态、反复尝试,但永远推进不了。看起来"很忙",实际啥也没干成。

② 饥饿(Starvation)

一句话理解:某个线程永远抢不到资源,饿死(如优先级太低一直被插队)。

还记得第 10 课讲的"优先级调度——低优先级可能饿死"吗?就是这回事:一个线程优先级太低,总被高优先级的插队,永远轮不到它,活活"饿死"。

三者对比:死锁是"都卡死",活锁是"都在瞎忙",饥饿是"有人饿死"。死锁/活锁是"全体受害",饥饿是"个别倒霉"。

动手实验:现场制造一个死锁

// lesson14.c —— 两线程互抢两把锁,制造死锁
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

pthread_mutex_t a = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t b = PTHREAD_MUTEX_INITIALIZER;

void* t1(void* arg) {
    pthread_mutex_lock(&a);
    printf("线程1 拿到锁A\n");
    sleep(1);                    // 制造"交叉等待"窗口
    pthread_mutex_lock(&b);      // 想拿 B,但 B 在 t2 手里
    printf("线程1 拿到锁B\n");
    pthread_mutex_unlock(&b);
    pthread_mutex_unlock(&a);
    return NULL;
}

void* t2(void* arg) {
    pthread_mutex_lock(&b);
    printf("线程2 拿到锁B\n");
    sleep(1);
    pthread_mutex_lock(&a);      // 想拿 A,但 A 在 t1 手里
    printf("线程2 拿到锁A\n");
    pthread_mutex_unlock(&a);
    pthread_mutex_unlock(&b);
    return NULL;
}

int main(void) {
    pthread_t x, y;
    pthread_create(&x, NULL, t1, NULL);
    pthread_create(&y, NULL, t2, NULL);
    pthread_join(x, NULL);
    pthread_join(y, NULL);
    printf("程序结束(死锁时这行永远打不出来)\n");
    return 0;
}

编译运行:

gcc lesson14.c -o lesson14 -lpthread && ./lesson14

预期结果:程序打印出"线程1 拿到锁A"和"线程2 拿到锁B"后,就卡住了——"程序结束"这行永远打不出来。因为 t1 在等 B(被 t2 持有)、t2 在等 A(被 t1 持有),环形成了。

Ctrl+C 强制退出。

修复思路:让两个线程都按"先 A 后 B"的固定顺序加锁,死锁即消失——这正是"破坏循环等待"。你亲手改一下试试,体会"固定加锁顺序"这个技巧有多好用。


深入点:两个延伸话题

① 死锁检测算法

"检测与恢复"这条路上,系统怎么发现死锁?两种常见办法:

  • 资源分配图找环:把"进程→资源"的申请关系画成图,如果图中出现,就说明有死锁;
  • 周期性检查:定期扫描,看哪些进程长时间处于"等待中"且没有进展。

检测到之后,恢复手段通常是"杀掉某个进程"或"回滚它的资源",打破那个环。

② 数据库中的死锁

死锁不只是操作系统的专利,数据库里天天发生:两个事务各自锁住一部分数据行,又都想改对方锁住的行,于是互相等待。

数据库的解法,正是"检测 + 恢复":靠超时机制死锁检测器发现死锁,然后回滚其中一个事务,把锁释放掉。这和 OS 的"检测与恢复"思路同源——允许发生,事后收拾


小结与思考题

这一课,我们看清了锁的"反噬"——死锁:

  • 死锁:互相等对方手里的锁,形成环,全部卡死;
  • 四个必要条件:互斥、持有并等待、不可剥夺、循环等待(缺一不可);
  • 三种对策:预防(破坏条件)、避免(银行家算法预演)、检测与恢复(允许发生、事后收拾);
  • 银行家算法:像放贷一样,只把资源分给"还得起"的进程;
  • 活锁(都在瞎忙)与饥饿(有人饿死)是死锁的两位亲戚。

留三个问题:

  1. 死锁的四个必要条件分别是什么?
  2. "固定加锁顺序"破坏了哪个条件?
  3. 活锁和死锁的本质区别是什么?
到这里,第三阶段(并发与同步)收官——竞态、锁、信号量、死锁,并发世界的地雷和排雷工具都摸了一遍。从下一课起,我们进入第四阶段:内存管理,去回答一个更底层的问题:程序到底"住在"内存的哪里? 你打印出来的那个变量地址,是真实的内存地址吗?答案,下回揭晓。

标签: 计算机基础, 操作系统, Linux

添加新评论