死锁:条件、检测、预防与银行家算法

死锁(Deadlock)是并发系统中一类经典而且代价高昂的故障:多个进程或线程因相互等待对方持有的资源,而全部陷入永久阻塞。与竞态条件不同,死锁不会破坏数据完整性,但它会让系统「冻结「在某一点,服务完全丧失可用性。

死锁(Deadlock)是并发系统中一类经典而且代价高昂的故障:多个进程或线程因相互等待对方持有的资源,而全部陷入永久阻塞。与竞态条件不同,死锁不会破坏数据完整性,但它会让系统"冻结"在某一点,服务完全丧失可用性。本文从 Coffman 四条件出发,遍历经典案例、处理策略、银行家算法,并延伸到 Linux lockdep 等工程实践,帮助你建立对死锁的系统认知。

1. 死锁产生的四个必要条件(Coffman Conditions)

1971 年,Coffman、Elphick 和 Shoshani 归纳出死锁同时满足的四个必要条件。必须强调的是,它们是必要条件而非充分条件:四者同时成立不一定导致死锁,但死锁一旦发生,四者必定全部成立;反过来说,只要打破其中任意一条,死锁就不可能发生。

1.1 互斥条件(Mutual Exclusion)

资源一次只能被一个进程占用。如果资源可被共享(如只读文件),就不会因争夺而发生死锁。

1.2 占有并等待(Hold and Wait)

进程已经持有至少一个资源,同时又提出新的资源请求,并在请求失败时阻塞等待,而不是释放已占有的资源。

1.3 不可抢占(No Preemption)

已分配给进程的资源不能被强制剥夺,只能由持有者显式释放。如果操作系统可以在必要时强制回收资源并重新分配,死锁链就会被打断。

1.4 循环等待(Circular Wait)

存在一个进程集合 {P1, P2, …, Pn},使得 P1 等待 P2 占有的资源,P2 等待 P3 占有的资源,……,Pn 等待 P1 占有的资源,形成闭环。

理解这四条定律的价值在于:它直接给出了预防和避免死锁的四种切入角度。

2. 经典死锁案例

2.1 双线程双锁(最常见)

线程 A 先拿锁 L1、再拿 L2;线程 B 先拿锁 L2、再拿 L1。两者几乎同时执行时,A 持有 L1 等待 L2,B 持有 L2 等待 L1,死锁形成。

#include <stdio.h>
#include <pthread.h>

pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock_b = PTHREAD_MUTEX_INITIALIZER;

void* thread1(void* arg) {
    pthread_mutex_lock(&lock_a);
    printf("Thread 1: acquired lock_a\n");
    // 刻意引入延迟,提高死锁触发概率
    struct timespec ts = {0, 100000000};
    nanosleep(&ts, NULL);
    pthread_mutex_lock(&lock_b);
    printf("Thread 1: acquired lock_b\n");
    pthread_mutex_unlock(&lock_b);
    pthread_mutex_unlock(&lock_a);
    return NULL;
}

void* thread2(void* arg) {
    pthread_mutex_lock(&lock_b);
    printf("Thread 2: acquired lock_b\n");
    struct timespec ts = {0, 100000000};
    nanosleep(&ts, NULL);
    pthread_mutex_lock(&lock_a);
    printf("Thread 2: acquired lock_a\n");
    pthread_mutex_unlock(&lock_a);
    pthread_mutex_unlock(&lock_b);
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, thread1, NULL);
    pthread_create(&t2, NULL, thread2, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

编译运行后,两个线程大概率会卡在各自的第二次 pthread_mutex_lock 调用上,程序永远挂起。

2.2 数据库事务死锁

事务 T1 先更新账户 A,再更新账户 B;事务 T2 先更新账户 B,再更新账户 A。当两段更新操作并发执行时,数据库层面的行锁同样会产生死锁。主流数据库(MySQL、PostgreSQL、SQL Server)的死锁检测器会自动发现这种环并选择牺牲者回滚,释放资源。但牺牲者的事务失败会抛异常,应用层必须做好重试逻辑。

2.3 哲学家就餐问题(Dining Philosophers)

五位哲学家围坐在圆桌旁,每人左右各有一支筷子。哲学家只有同时拿到左右两支筷子才能吃饭。如果五位哲学家同时拿起左手边的筷子,那么每人都在等待右边哲学家的筷子,形成完美的循环等待,所有人一起饿死。这个经典模型深刻揭示了循环等待条件的危害——它在多节点、对称拓扑的系统中尤其危险。

一种简单解法是引入"编号":给每支筷子编号,哲学家必须按编号从小到大的顺序取筷子。这样循环等待被打破,死锁不可能发生。另一种做法是限制同时就餐的哲学家数量(例如最多四人),利用鸽巢原理保证至少有一位哲学家能拿到两只筷子。

3. 死锁处理策略

操作系统和分布式系统对死锁的处理可归纳为四种策略。

3.1 预防(Prevention):打破 Coffman 四条件

预防是在系统设计阶段就消除死锁的可能性,核心思路是破坏四条件中的至少一条:

  • 打破互斥:让资源可共享。这只适用于少数场景(只读数据),对写操作独占资源无能为力。
  • 打破占有并等待:要求进程一次性申请所有资源,申请不到就不执行。这种方式资源利用率极低,现实中很少使用。
  • 打破不可抢占:如果进程申请新资源失败,必须释放已持有的全部资源,再重新申请。实现复杂,且可能导致活锁(livelock)和大量回滚开销。
  • 打破循环等待:定义全局的资源获取顺序,所有进程严格按顺序申请。这是工程中最常用、最可靠的预防手段。

3.2 避免(Avoidance):银行家算法

避免策略不限制资源申请方式,而是在每次分配前进行安全性检查,确保系统不会进入不安全状态。最著名的算法是 Dijkstra 于 1965 年提出的银行家算法(Banker’s Algorithm)

3.3 检测与恢复(Detection & Recovery)

系统不预防也不避免死锁,而是定期或在资源紧张时运行检测算法。一旦发现死锁,通过终止进程抢占资源来恢复。检测的代价通常较高(图算法),恢复又可能引发数据不一致,因此需要权衡。

3.4 鸵鸟算法(Ostrich Algorithm)

直接忽略死锁问题。这是 Unix/Linux、Windows 等现代操作系统对用户态线程锁的默认态度:死锁是小概率事件,而全面预防和检测带来的性能与复杂度开销不可接受。应用层的死锁由开发者负责。这种"无为而治"的策略看似不负责任,但在工程中非常理性——它把死锁风险从内核推到了更可控的应用层。

4. 银行家算法详解

银行家算法之所以得名,是因为它模拟了银行家的放贷逻辑:银行不会把所有资金贷给任何一个客户,而是保留一部分作为储备,确保即使所有客户同时要求最大额度,银行也不会破产。

4.1 核心数据结构

设有 n 个进程,m 类资源:

  • Available[m]:当前每类可用资源的数量。
  • Max[n][m]:每个进程对每类资源的最大需求。
  • Allocation[n][m]:每个进程当前已分配的资源数量。
  • Need[n][m]:每个进程还需要的资源数量,Need[i][j] = Max[i][j] - Allocation[i][j]

4.2 安全性算法

安全性算法判断当前状态是否"安全",即是否存在一种进程执行顺序,使得每个进程都能顺利完成。

Work = Available
Finish[i] = false for all i

while exists i such that Finish[i] == false and Need[i] <= Work:
    Work = Work + Allocation[i]
    Finish[i] = true

if all Finish[i] == true:
    system is in SAFE state
else:
    system is in UNSAFE state

若系统处于安全状态,则必然无死锁;若处于不安全状态,则可能发生死锁(注意是不安全而非一定死锁)。

4.3 资源请求算法

当进程 Pi 请求资源 Request[i] 时,系统按以下步骤处理:

  1. Request[i] > Need[i],报错(请求超出声明的最大需求)。
  2. Request[i] > Available,Pi 必须等待。
  3. 尝试分配,更新状态:
    • Available = Available - Request[i]
    • Allocation[i] = Allocation[i] + Request[i]
    • Need[i] = Need[i] - Request[i]
  4. 运行安全性算法检查新状态是否安全。
  5. 若安全,正式分配;若不安全,回滚此次尝试,Pi 继续等待。

4.4 一个完整示例

假设系统有 3 类资源 {A, B, C},总数量为 (10, 5, 7)。当前状态如下:

进程AllocationMaxNeed
P0(0, 1, 0)(7,5,3)(7,4,3)
P1(2, 0, 0)(3,2,2)(1,2,2)
P2(3, 0, 2)(9,0,2)(6,0,0)
P3(2, 1, 1)(2,2,2)(0,1,1)
P4(0, 0, 2)(4,3,3)(4,3,1)

Available = (3, 3, 2)

执行安全性算法:

  • 初始 Work = (3, 3, 2)
  • P1 的 Need (1,2,2) <= Work,可满足。执行后 Work = (5, 3, 2)
  • P3 的 Need (0,1,1) <= Work,可满足。执行后 Work = (7, 4, 3)
  • P4 的 Need (4,3,1) <= Work,可满足。执行后 Work = (7, 4, 5)
  • P0 的 Need (7,4,3) <= Work,可满足。执行后 Work = (7, 5, 5)
  • P2 的 Need (6,0,0) <= Work,可满足。执行后 Work = (10, 5, 7)

安全序列为 <P1, P3, P4, P0, P2>,系统处于安全状态。

假设 P1 请求 (1, 0, 2),检查后发现小于 Available (3, 3, 2),尝试分配后运行安全性算法仍能得到安全序列,因此允许分配。这就是银行家算法的完整决策流程。

4.5 为什么银行家算法只是"理论武器"

尽管银行家算法优雅且正确,现代操作系统几乎不直接使用它,原因有三:

  1. 需要预先知道最大需求:进程在运行前很难准确预测自己需要多少资源。
  2. 进程数量动态变化:系统不断创建和销毁进程,静态的 Max 矩阵难以维护。
  3. 算法复杂度高:每次资源请求都要执行 O(m * n^2) 的安全检查,代价太高。

因此,银行家算法更多是教学工具,而非生产系统方案。

5. 工程中的死锁检测

5.1 资源分配图(Resource Allocation Graph, RAG)

死锁检测最直观的模型是 RAG。图中有两类节点:

  • 进程节点(圆形)
  • 资源节点(矩形,每个实例用小圆点表示)

两种有向边:

  • 分配边:资源实例指向进程,表示该资源已被分配给该进程。
  • 请求边:进程指向资源,表示进程正在请求该资源。

关键定理:如果资源分配图中不存在环,则系统一定无死锁;如果存在环,且环中每类资源只有一个实例,则死锁一定存在;如果资源有多个实例,环只是死锁的必要条件,还需进一步分析。

检测算法本质上是图上的环检测,可用深度优先搜索(DFS)实现。

5.2 分布式系统中的 Wound-Wait 与 Wait-Die

在分布式数据库中,锁分布在不同节点,RAG 的全局构建成本极高。基于时间戳(timestamp)的乐观方案被广泛采用:

  • Wait-Die(老等少,少杀老):当老事务请求被少事务持有的锁时,老事务等待(wait);当少事务请求被老事务持有的锁时,少事务自杀(die)并回滚。老事务永不等待少事务,避免循环。
  • Wound-Wait(老抢少,少等老):当老事务请求被少事务持有的锁时,老事务"伤害"(wound)少事务,迫使其回滚;当少事务请求被老事务持有的锁时,少事务等待。

两种方案都保证时间戳的偏序关系不会形成环,区别在于Wait-Die 中老事务可能饿死,而 Wound-Wait 中事务一旦回滚就会被赋予新时间戳,最终能前进

6. Linux lockdep:运行时的锁序检测

6.1 什么是 lockdep

lockdep(lock dependency validator)是 Linux 内核中一个强大的静态锁依赖检查器。它不是真正"静态"地分析源码,而是在运行时动态追踪所有锁的获取顺序,构建锁的依赖图,并在发现潜在的死锁循环时即时报警。lockdep 能发现的那种 bug,即使实际运行中千次万次都不会触发死锁,但只要存在锁顺序上的逻辑矛盾,它就能报告出来。

6.2 启用与编译

使用 lockdep 需要在内核编译时开启配置:

CONFIG_DEBUG_KERNEL=y
CONFIG_LOCKDEP=y
CONFIG_LOCK_STAT=y
CONFIG_DEBUG_LOCK_ALLOC=y

在启用 lockdep 的内核上,每次锁操作时都会记录调用栈和锁的依赖关系。对性能有明显影响,因此只用于调试和测试环境。

6.3 触发与读取 lockdep 报告

假设内核代码中存在如下锁顺序冲突:

// 路径 A:先拿 lock_a,再拿 lock_b
mutex_lock(&lock_a);
mutex_lock(&lock_b);  // lockdep 记录:a -> b

// 路径 B:先拿 lock_b,再拿 lock_a
mutex_lock(&lock_b);
mutex_lock(&lock_a);  // lockdep 记录:b -> a,与 a->b 冲突!

lockdep 会在第二次交叉发生时在 dmesg 中输出报告,核心信息包括:

======================================================
WARNING: possible circular locking dependency detected
------------------------------------------------------
caller/1 is trying to acquire lock:
 (&lock_a){+.+.}, at: [<...>] some_function+0x...

but task is already holding lock:
 (&lock_b){+.+.}, at: [<...>] another_function+0x...

which lock already depends on the new lock.

报告会完整展示依赖链条的栈回溯,开发者可以直接定位到冲突的两个代码路径。这是调试内核并发问题最强大的工具之一。

7. 死锁预防最佳实践

理论归理论,工程中的死锁预防需要可执行、可审查的规范。

7.1 全局锁排序(Global Lock Ordering)

为系统中所有锁定义一个全序关系(如按内存地址或按业务层次)。任何代码获取多个锁时,必须严格按此顺序。这是预防循环等待最直接的方法。在大型项目中,应有文档或代码注释明确每种锁在层级中的位置。

7.2 锁层级文档

在代码库中维护一个 LOCKING 文件或头文件注释,列出所有锁及其允许的获取顺序。例如 Linux 内核中就有一份详细的锁层级文档,新贡献者在引入新锁时必须说明它插入到层级中的哪个位置。

7.3 带超时与回退的 Try-Lock

不要无限期阻塞等待锁。使用 pthread_mutex_timedlock 或在 Go 中使用 context.WithTimeout 配合 select。如果超时,释放已持有的所有锁,短暂延迟后重试。这打破了"占有并等待"条件,同时避免活锁的一个技巧是引入随机回退(randomized backoff)

struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 1;  // 1 秒超时
if (pthread_mutex_timedlock(&lock_b, &ts) != 0) {
    pthread_mutex_unlock(&lock_a);  // 释放已持锁
    usleep(rand() % 1000);          // 随机回退
    goto retry;
}

7.4 避免不必要的嵌套锁

嵌套锁是死锁的温床。审视设计:是否可以用更粗粒度的单一锁替代多锁?是否可以改用无锁数据结构(lock-free data structures)?Channel 通信有时候比共享内存加锁更简洁安全。

7.5 RAII 自动释放

利用语言特性确保锁在作用域结束时自动释放。C++ 的 std::lock_guard/std::unique_lock、Rust 的 MutexGuard、Python 的 with threading.Lock(),都遵循 RAII 原则。这至少能消除"忘记 unlock 导致资源永久持有"这一类问题,虽然不能直接防止循环等待,但能减少死锁的触发面。在 C 语言中,可以用 __attribute__((cleanup)) 或宏包装模拟 RAII。

总结

死锁不是不可战胜的幽灵,而是一类条件明确、机理清晰的并发故障。Coffman 四条件为我们提供了分析框架:互斥、占有并等待、不可抢占、循环等待,缺一不可。从哲学家就餐问题到双线程双锁,死锁的模式重复出现;从银行家算法到 Wound-Wait,理论工具为系统设计提供了安全保证;而 lockdep 和全局锁排序则是落地工程的具体手段。

在实际项目中,你最可能采取的策略是"鸵鸟算法 + 预防":不追求绝对的形式化安全,而是通过全局锁序、try-lock 超时、RAII 自动释放等手段,把死锁概率压到足够低,并保留诊断和快速修复的能力。记住,死锁的预防成本必须与风险相匹配——过度设计同样是一种工程债务。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 进程与线程:从 PCB 到内核调度实体
  2. 虚拟内存与分页机制:从 MMU 到 TLB
  3. 系统性能诊断与调优:strace、perf、bpftrace