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 死锁检测算法
与其费劲预防,不如出了问题再解决——这就是死锁检测的思路。我个人觉得,在复杂的生产环境中,死锁检测往往比预防更实用。
检测原理
死锁检测算法其实跟银行家算法的安全检查很像。它维护一个资源分配图,然后定期检查图中是否有环。有环,就说明有死锁。
具体做法是:
- 把每个线程和每种资源都看作一个节点
- 线程申请资源,画一条从线程到资源的边
- 资源分配给线程,画一条从资源到线程的边
- 定期检查图中是否存在循环
检测时机
什么时候做检测?我一般建议:
- 定时检测:每隔几秒跑一次检测算法
- 触发检测:当线程等待时间超过阈值时触发
- 按需检测:系统资源利用率异常高时启动检测
注意:死锁检测本身是有开销的。如果系统里线程特别多(比如上千个),每次检测都要遍历所有节点,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