16、死锁检测与预防:死锁的四个必要条件、银行家算法、死锁检测算法、实际项目中的死锁预防策略

死锁这东西,说白了就是多线程编程里最让人头疼的问题之一。我刚开始写并发程序那会儿,经常半夜被运维电话叫醒——"服务挂了,所有线程都卡住了"。嗯,后来我才明白,这就是典型的死锁场景。

今天咱们就把死锁这件事彻底聊透。从理论到实战,我会把我在项目中踩过的坑、总结的经验都抖出来。

16.1 死锁的四个必要条件

死锁不是凭空发生的。它必须同时满足四个条件。缺一个,死锁就形成不了。我习惯把这四个条件记成"互请不循"——虽然不太好听,但好记。

条件 说明 打个比方
互斥条件 资源一次只能被一个线程占用 厕所里只能进一个人
请求与保持 线程拿着资源不放,还去申请别的资源 你占着坑,还伸手要别人的纸
不可剥夺 资源不能被强制拿走,只能主动释放 除非你出来,否则没人能把你拽出来
循环等待 形成一条等待链,A等B,B等C,C等A 三个人互相等对方先出来

关键点:这四个条件必须同时满足才会死锁。所以预防死锁的思路很简单——破坏其中任意一个条件就行。

我在项目中遇到过最经典的死锁场景:线程A持有锁1,去申请锁2;线程B持有锁2,去申请锁1。两个线程就这么互相干瞪眼,谁也别想往下走。你想想看,这多耽误事。

16.2 银行家算法

银行家算法,名字听着挺高大上,其实核心思想特别朴素——借钱之前先算算对方还不还得起。

这个算法是Dijkstra搞出来的。我当年学的时候觉得这玩意儿太理论了,直到后来做嵌入式系统,资源有限,线程又多,才真正体会到它的价值。

算法核心思想

系统在分配资源之前,先模拟一下:如果我把资源给你,系统还能不能找到一个安全序列,让所有线程都能顺利完成?如果能,就分配;如果不能,就让你等着。

说白了就是:不干没把握的事

数据结构

银行家算法需要维护几张表:

  • Available:每种资源当前可用的数量
  • Max:每个线程对每种资源的最大需求
  • Allocation:每个线程当前已分配的资源
  • Need:每个线程还需要的资源(Need = Max - Allocation)

代码示例

// 银行家算法的安全检查核心
int isSafeState(int available[], int need[][], int allocation[][], int n, int m) {
    int work[m];
    int finish[n] = {0};
    int safeSeq[n];
    int count = 0;
    
    // 初始化work = available
    for (int i = 0; i < m; i++) {
        work[i] = available[i];
    }
    
    while (count < n) {
        int found = 0;
        for (int i = 0; i < n; i++) {
            if (finish[i] == 0) {
                // 检查need[i]是否都小于等于work
                int j;
                for (j = 0; j < m; j++) {
                    if (need[i][j] > work[j]) break;
                }
                if (j == m) {  // 所有资源都满足
                    // 模拟释放资源
                    for (int k = 0; k < m; k++) {
                        work[k] += allocation[i][k];
                    }
                    safeSeq[count++] = i;
                    finish[i] = 1;
                    found = 1;
                }
            }
        }
        if (!found) return 0;  // 找不到安全序列
    }
    return 1;  // 安全状态
}

我的经验:银行家算法在实际项目中用得并不多,因为大多数场景下资源需求是动态变化的,很难提前知道每个线程的"最大需求"。但它的思想非常值得借鉴——做资源分配时,留点余量,别把路走死。

16.3 死锁检测算法

与其费劲预防,不如出了问题再解决——这就是死锁检测的思路。我个人觉得,在复杂的生产环境中,死锁检测往往比预防更实用。

检测原理

死锁检测算法其实跟银行家算法的安全检查很像。它维护一个资源分配图,然后定期检查图中是否有环。有环,就说明有死锁。

具体做法是:

  1. 把每个线程和每种资源都看作一个节点
  2. 线程申请资源,画一条从线程到资源的边
  3. 资源分配给线程,画一条从资源到线程的边
  4. 定期检查图中是否存在循环
资源分配图与死锁检测 线程A 线程B 资源R1(打印机) 资源R2(扫描仪) 申请R2 已分配 申请R1 已分配 检测到循环等待 → 死锁! 申请边 分配边

检测时机

什么时候做检测?我一般建议:

  • 定时检测:每隔几秒跑一次检测算法
  • 触发检测:当线程等待时间超过阈值时触发
  • 按需检测:系统资源利用率异常高时启动检测

注意:死锁检测本身是有开销的。如果系统里线程特别多(比如上千个),每次检测都要遍历所有节点,CPU消耗不小。我曾经在一个高并发服务里每100ms跑一次检测,结果检测本身成了性能瓶颈。后来改成1秒一次,问题就解决了。

16.4 实际项目中的死锁预防策略

理论说完了,咱们聊聊实战。我在实际项目中总结了几条死锁预防的"铁律",分享给你。

策略一:固定锁顺序

这是最简单、最有效的办法。所有线程在申请多个锁时,都按照相同的顺序来申请。

// 错误做法:锁顺序不一致
void threadA() {
    pthread_mutex_lock(&lock1);
    pthread_mutex_lock(&lock2);  // A: 先1后2
    // ... 干活 ...
    pthread_mutex_unlock(&lock2);
    pthread_mutex_unlock(&lock1);
}

void threadB() {
    pthread_mutex_lock(&lock2);
    pthread_mutex_lock(&lock1);  // B: 先2后1 → 可能死锁!
    // ... 干活 ...
    pthread_mutex_unlock(&lock1);
    pthread_mutex_unlock(&lock2);
}

// 正确做法:统一先1后2
void threadB_fixed() {
    pthread_mutex_lock(&lock1);  // 跟A保持一致
    pthread_mutex_lock(&lock2);
    // ... 干活 ...
    pthread_mutex_unlock(&lock2);
    pthread_mutex_unlock(&lock1);
}

策略二:使用trylock

申请锁的时候别死等。用pthread_mutex_trylock,拿不到就放弃,回头再试。

void safe_lock_two(pthread_mutex_t *lock1, pthread_mutex_t *lock2) {
    while (1) {
        pthread_mutex_lock(lock1);
        if (pthread_mutex_trylock(lock2) == 0) {
            break;  // 两个锁都拿到了
        }
        pthread_mutex_unlock(lock1);  // 拿不到第二个,释放第一个
        // 稍微等一会儿再试,避免忙等
        usleep(100);
    }
}

我的习惯:在关键路径上,我一般用固定锁顺序。在不太关键的路径上,用trylock。前者性能好,后者更安全。你根据场景选。

策略三:减少锁的粒度

锁的粒度越粗,死锁概率越高。我见过有人用一个全局大锁保护所有数据,结果死锁频发。后来拆成多个细粒度锁,问题就解决了。

但注意,锁太细也有问题——管理复杂,还可能引入死锁。这是个权衡。

策略四:资源分级

给资源分个等级。线程只能从低等级往高等级申请资源,不能逆向。这其实就是固定锁顺序的泛化版本。

等级 资源类型 说明
1级 普通数据锁 最常用,粒度最小
2级 集合结构锁 保护链表、哈希表等
3级 全局状态锁 保护系统级配置

线程想拿3级锁,必须先持有1级或2级锁。但反过来不行——你不能拿着3级锁去申请1级锁。

策略五:超时机制

给锁操作加个超时。超过一定时间没拿到锁,就放弃并释放已持有的锁,回头重试。

struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 2;  // 2秒超时

int ret = pthread_mutex_timedlock(&lock, &ts);
if (ret == ETIMEDOUT) {
    // 超时了,释放其他锁,重试
    pthread_mutex_unlock(&held_lock);
    // 记录日志,方便排查
    log_warning("Lock timeout, retrying...");
    // 重新尝试
}

避坑指南:我曾经在一个项目中只用了超时机制,没做锁顺序控制。结果虽然不会永久死锁了,但频繁的超时重试导致CPU飙升,性能惨不忍睹。后来加上锁顺序控制,超时几乎再也没触发过。所以我的建议是:锁顺序是根本,超时是兜底

16.5 小结

死锁这东西,说难也难,说简单也简单。你只要记住:

  • 四个条件同时满足才会死锁
  • 破坏任意一个条件就能预防
  • 固定锁顺序是最实用的预防手段
  • 死锁检测适合复杂场景,但要注意性能开销
  • 实际项目中,多种策略组合使用效果最好

嗯,死锁这块内容就聊到这儿。代码写得再漂亮,一死锁全白搭。希望今天这些内容能帮你少踩几个坑。


公众号:蓝海资料掘金营,微信deep3321