9.5 KiB
既然你对“鸵鸟算法”情有独钟,那理解**死锁(Deadlock)**的定义和那四个“死穴”就非常简单了。
简单来说,死锁就是:一组线程(或进程)中的每一个都在等待仅由该组中其他线程持有的资源,导致所有人无限期地僵持下去。
这就好比四个哲学家每人手里拿了一根筷子,都在等右边的人放下筷子,结果谁也吃不上饭。
死锁的 4 个必要条件 (Coffman Conditions)
要发生死锁,以下 4 个条件必须同时满足。只要破坏其中任何一个,死锁就无法成立(这也是预防死锁的理论依据)。
1. 互斥条件 (Mutual Exclusion)
- 定义:资源是“独占”的。一段时间内,某资源只能被一个线程占用。
- 通俗解释:这把锁(或打印机、文件)我拿着的时候,你绝对碰不了。如果资源可以共享(比如只读文件),就不会有死锁。
2. 请求与保持条件 (Hold and Wait)
- 定义:一个线程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他线程占有。
- 通俗解释:吃着碗里的,看着锅里的。我手里抓着 A 锁不放,同时伸手去够 B 锁,哪怕 B 锁现在拿不到,我也不松开 A 锁。
3. 不可剥夺条件 (No Preemption)
- 定义:资源在未使用完之前,不能被其他线程强行夺走,只能由持有它的线程自愿释放。
- 通俗解释:你不松手,天王老子也拿不走。除非你自己完成任务主动
unlock,否则别人没法强制把你踢下线。
4. 循环等待条件 (Circular Wait)
- 定义:存在一个线程等待序列 ${P_1, P_2, ..., P_n}$,其中
P_1等待P_2占有的资源,P_2等待P_3的...P_n等待P_1的。 - 通俗解释:形成了一个“闭环”。张三等李四,李四等王五,王五等张三。大家都在等下家,谁也动不了。
如何“破局”?(针对这四个条件的防御)
如果你不想当鸵鸟,想正经解决问题,可以针对这四个点下手:
| 破坏对象 | 常用手段 |
|---|---|
| 破坏互斥 | 尽量使用无锁编程(如 std::atomic)或只读共享资源。 |
| 破坏请求与保持 | 实行“打包申请”:线程开始前必须一次性拿到所有需要的锁,拿不到就一个也不占。 |
| 破坏不可剥夺 | 如果申请新锁失败,主动释放已占有的所有锁,过会儿再重试(类似回滚)。 |
| 破坏循环等待 | 最常用:强制锁排序。给所有锁编号(1, 2, 3...),所有线程必须按从小到大的顺序拿锁。 |
💡 一个硬核小知识
在 Linux 内核开发中,有一个著名的静态分析工具叫 lockdep(Runtime locking correctness validator)。它会在后台盯着你的加锁操作,一旦发现你加锁的顺序可能形成“循环等待”,哪怕还没真的发生死锁,它也会直接在终端给你刷屏报错。
既然你知道了这四个条件,想不想试着写一个只有 2 个线程,但能稳定触发这 4 个条件的 C 语言小程序?我们可以亲手制造一个闭环。
看来你深得分布式系统和操作系统内核开发的精髓——“只要我看不见,Bug 就追不上我!” 哈哈。
在多线程编程里,鸵鸟算法(Ostrich Algorithm)确实是一种极具“哲学气息”的策略。当处理**死锁(Deadlock)**的成本远高于死锁发生的概率时,开发者就会像鸵鸟一样把头埋进沙子里,假装什么都没发生:直接重启,或者干脆不管。
既然你提到了这个,那我们正好结合刚才的生产者-消费者项目,看看如果不小心,你的程序会怎么陷入死锁,以及为什么有时候“鸵鸟”一下反而是明智的。
1. 生产者-消费者里的“死锁陷阱”
在多线程里,死锁通常是因为互相等待。 想象一下:
- 生产者拿到了锁,发现队列满了,准备睡觉(
wait)。 - 消费者想进来拿东西,但锁被生产者拿着,进不去。
- 生产者在等消费者拿走东西腾出位子,消费者在等生产者把锁还回来。
- 结果:两个人都僵住了。
这就是为什么 pthread_cond_wait 设计得非常天才:它在让线程睡觉的一瞬间,会自动释放它持有的互斥锁。 这样消费者才能进来,拿走东西,然后发信号叫醒生产者。
2. 为什么 Unix/Linux 偏爱鸵鸟算法?
你可能知道,Linux 内核在处理进程死锁时,很大程度上也是采用鸵鸟算法。
- 成本评估:如果要完美检测并预防所有死锁,系统需要实时维护极其复杂的资源分配图(Wait-for Graph)。这会吃掉大量的 CPU 和内存。
- 工程现实:如果一个死锁一年才发生一次,而预防它会让系统每天慢 10%,那正常的架构师都会选择:“随它去吧,死锁了让用户重启就行。”
3. 一个让你“不得不”用鸵鸟算法的小项目建议
想不想试试写一个**“必然死锁”**的程序?你可以亲手感受一下那种程序运行到一半突然像被点穴了一样的感觉。
任务:哲学家就餐问题 (Dining Philosophers)
这是理解鸵鸟算法的最佳案例:
- 有 5 个哲学家(线程)围成一圈。
- 桌上有 5 根筷子(5 把互斥锁)。
- 每个人必须同时拿左手和右手的筷子才能吃饭。
- 死锁发生:如果每个人都同时拿起了左手的筷子,然后都在等右手那根……全场静止。
你的下一个实验:
你可以尝试写一个简化版的哲学家就餐:
- 定义两个锁
lockA和lockB。 - 线程 1 先锁 A 再锁 B。
- 线程 2 先锁 B 再锁 A。
- 看看跑多少次之后,你的终端会彻底“静止”?
如果你写出来了,你是打算用“银行家算法”去解决它,还是坚持你的“鸵鸟哲学”,直接 Ctrl+C 重启大法?
面对死锁,如果不能像“鸵鸟”一样装死,那就必须在发现死锁后进行手术。在操作系统和分布式数据库中,通常有三条路可以走。
这不仅仅是“重启”那么简单,而是有层次的对抗:
1. 资源抢占 (Resource Preemption)
这是最温和但实现最难的方法。
- 核心逻辑:强制从某个线程手里“夺走”它占有的资源(比如一个互斥锁或一块内存),交给另一个正在等待的线程。
- 代价:被抢占的线程会处于一种“半死不活”的状态。因为它可能正改数据改到一半,突然资源没了。
- 应用场景:通常用于内存资源或虚拟资源。对于 C 语言里的
pthread_mutex,操作系统很难直接抢占,因为这会导致内存状态完全错乱。
2. 回滚 (Rollback)
这是数据库系统(如 MySQL, Oracle)处理死锁的标配。
- 核心逻辑:系统维护一个日志(Log)。当检测到死锁时,系统挑选一个“倒霉蛋”事务,撤销它已经执行的所有操作,退回到它开始之前的状态。
- 如何挑选倒霉蛋:通常选择代价最小的(比如刚开始运行没多久、修改数据最少的事务)。
- 结果:释放该事务持有的所有锁,其他事务就能继续跑了。回滚后的事务通常会延迟一会再自动重试。
3. 杀死进程/线程 (Termination)
这是最暴力也最有效的手段,也是 Linux 内核 OOM Killer 的逻辑。
- 一次性全杀:简单粗暴,但之前跑的所有进度全丢了。
- 逐个杀死:杀掉一个死锁环里的线程,看死锁是否解除;如果不解除,再杀下一个。
- C 语言中的现实:在
pthread里,如果你用pthread_cancel强制干掉一个线程,它持有的锁不会自动释放(除非你写了非常复杂的清理 Handler)。- 后果:剩下的线程依然在等那把永远不会被释放的锁。
- 结论:在 C/C++ 层面,直接杀线程通常会导致整个程序死锁得更彻底。最靠谱的还是杀掉整个进程重启。
🛠️ 现实中的“标准操作流程” (SOP)
如果你在开发你的 hguard 或者其他系统级应用时遇到了死锁,建议按以下顺序操作:
-
超时机制 (Timeout):
- 不要永远等死。使用
pthread_mutex_timedlock而不是pthread_mutex_lock。 - 如果 5 秒没拿到锁,直接报错退出或重试。这是预防死锁演变成“系统停摆”的最强武器。
- 不要永远等死。使用
-
看门狗 (Watchdog):
- 专门开一个低优先级的监控线程。
- 如果发现某些关键变量(比如你那个
ans)在 10 分钟内没动过,直接发信号重启程序。
-
日志先行:
- 在加锁前后打
DEBUG日志。 - 死锁发生后,通过日志发现是“张三等李四,李四等张三”,然后在代码里强制锁排序(比如所有线程必须先拿 A 锁,再拿 B 锁)。
- 在加锁前后打
总结
- 回滚是高富帅(数据库)的做法。
- 抢占是理想主义者的做法。
- 杀死并重启是现实主义者(和鸵鸟粉丝)的做法。
既然你现在手头有 C 语言的 Demo,要不要试着把 pthread_mutex_lock 改成 pthread_mutex_timedlock?这样即使发生了死锁,你的程序也能优雅地弹出一句 "Wait timeout, maybe deadlock!" 而不是在那儿干坐着。