第 06 章 死锁、活锁与饥饿:秩序的三种崩塌方式
上一章我们讲了锁如何维持秩序。这一章讲一个更让人头疼的问题:当多个锁、多个执行流相互作用时,秩序会以三种不同的方式崩塌。
死锁、活锁、饥饿。它们听起来像三种病,其实是同一个病根——多个执行流在争夺有限资源时,协调失败——的三种症状。
这一章的目标,不是让你背下”死锁的四个必要条件”(那个背了也没用),而是让你从系统设计的层面,学会怎么让死锁根本不可能发生。
引子:三个真实的故事
故事一(死锁): 一个银行转账系统,晚上 11 点突然所有转账请求卡死。排查发现:线程 A 正在做”账户 1 → 账户 2”的转账,先锁了账户 1;线程 B 正在做”账户 2 → 账户 1”的转账,先锁了账户 2。A 等着锁账户 2,B 等着锁账户 1。谁都不放手,永远等下去。
故事二(活锁): 两个人在走廊迎面相遇,都往左让,撞上;都往右让,又撞上;再往左……两个人都在”积极避让”,但就是走不过去。 系统里,两个进程都在”重试 + 让出”,但每次都撞在一起。
故事三(饥饿): 1997 年,NASA 的火星探测器 Mars Pathfinder 在火星上反复重启,差点任务失败。原因:一个低优先级的气象数据采集任务,持有一个”总线锁”;一个高优先级的通信任务,等这个锁;而一个中优先级任务在不停运行,抢占 CPU,导致低优先级任务迟迟不能释放锁。高优先级任务被”事实上”饿死,触发看门狗重启。
三个故事,三种崩塌。这一章我们逐个拆解,并找到”从设计上预防”的方法。
给你一句总纲:
死锁是”永久阻塞”(谁都不动),活锁是”永久空转”(都在动但没进展),饥饿是”永久得不到”(有人动,但轮不到我)。
它们的共同根源是:在竞争资源时,缺少一个”全局的、公平的、有界的”协调机制。
本章路线图
第一幕:死锁 —— 秩序的僵死
Q1 死锁的四个必要条件是什么?为什么它们缺一不可?
Q2 破坏四个条件中的任何一个,就能防死锁——具体怎么做?
Q3 锁顺序(lock ordering)为什么能防死锁?怎么定顺序?
Q4 银行家算法为什么理论上完美,工程上没人用?
Q5 死锁怎么检测?检测到了怎么破?
第二幕:Go 里的死锁
Q6 Go 的 channel 为什么会死锁?怎么避免?
第三幕:活锁 —— 秩序的假活跃
Q7 活锁是什么?它和死锁的本质区别是什么?
Q8 活锁在工程里长什么样?(重试风暴、自旋竞争)
第四幕:饥饿 —— 秩序的偏心
Q9 饥饿是什么?优先级反转为什么会导致饥饿?
Q10 Mars Pathfinder 的教训:优先级继承怎么解决它?
第五幕:工程防线
Q11 怎么设计一个"死锁几乎不可能发生"的系统?
第一幕:死锁 —— 秩序的僵死
Q1 死锁的四个必要条件是什么?为什么它们缺一不可?
这是操作系统的经典知识点,但你得理解”为什么是这四个”,而不只是背下来。
死锁要发生,必须同时满足四个条件:
条件一:互斥(Mutual Exclusion)
资源同一时刻只能被一个执行流使用。
如果一个资源可以共享(比如只读数据),就不会死锁。
条件二:持有并等待(Hold and Wait)
执行流已经持有一个资源,又在等待另一个资源。
如果"要么一次拿全,要么一个都不拿",就不会死锁。
条件三:不可剥夺(No Preemption)
已持有的资源,不能被强行抢走,只能自愿释放。
如果资源可以被剥夺(比如优先级高的抢优先级低的),就不会死锁。
条件四:循环等待(Circular Wait)
存在一个环:A 等 B 的资源,B 等 C 的资源,C 等 A 的资源。
如果没有环,就不会死锁(只会是"单向等待",最终会释放)。
为什么缺一不可?
因为死锁就是这四个条件叠加的结果:
互斥 → 资源不能共享(前提)
持有并等待 → 执行流"占着 A 等 B"(死锁的姿势)
不可剥夺 → 不能强抢(僵持的原因)
循环等待 → 形成环(僵持的闭环)
四个条件里,前三个是”必要的但还不够”,第四个是”压死骆驼的最后一根稻草”。
打个比方:
互斥 = 只有一把椅子(不能两人同时坐)
持有并等待 = 一个人已经坐了椅子,还想要桌子
不可剥夺 = 不能把坐椅子的人拉起来
循环等待 = A 坐椅子等桌子,B 坐桌子等椅子
四个同时成立,两个人就都动不了。
反过来说:只要破坏任何一个条件,死锁就不可能发生。 这就是所有”防死锁”策略的出发点。
Q2 破坏四个条件中的任何一个,就能防死锁——具体怎么做?
这是”防死锁”的完整工具箱。逐个条件说。
破坏互斥:让资源可共享。
死锁的前提是"资源不能共享"。如果资源可共享,就不会有竞争。
怎么破坏:
- 读操作不加锁(用 RCU、MVCC、快照读)
- 用"不可变数据"(更新生成新版本,而不是原地改)
- 用"写时复制"(copy-on-write,第 1 章讲过)
例子:
数据库的 MVCC:读操作完全不阻塞写操作(读的是旧版本快照)
→ 从根本上消除了"读和写互斥"这个条件
破坏持有并等待:一次拿全,或者不持有地等待。
方法一:一次性申请所有资源(要么全拿到,要么一个都不拿)
比如:转账时,一次性锁两个账户(用 tryLock 尝试,拿不全就全释放)
方法二:申请新资源前,先释放已持有的
但这样可能造成"活锁"(反复拿到又释放)
例子(Go):
// 一次性拿两个锁
func transfer(from, to *Account, amount int) {
// 拿两个锁,一个拿不到就全放弃
// 但这有活锁风险
}
破坏不可剥夺:允许抢占。
如果资源可以被抢走,就不会僵持。
实现方式:
- 优先级高的任务,可以"抢占"优先级低的任务持有的资源
- 用 tryLock + 超时(拿不到锁就超时放弃,相当于"自我剥夺")
例子(Go):
// 用超时实现"自我剥夺"
ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
if err := mu.LockContext(ctx); err != nil {
// 超时了,放弃,不持有锁
}
破坏循环等待:给资源排序,按顺序申请。
如果所有执行流都"按固定的顺序"申请锁,就不会形成环。
这是最实用、最推荐的方法(下面 Q3 展开)。
一句话总结:死锁预防 = 破坏四个条件之一。而工程上最实用的是”破坏循环等待”(锁排序)和”破坏持有并等待”(一次性申请/超时)。
Q3 锁顺序(lock ordering)为什么能防死锁?怎么定顺序?
原理:如果所有代码都按”全局统一的顺序”获取锁,就不可能形成循环等待。
回到转账的例子:
// 死锁的写法:锁的顺序不一致
func transfer(from, to *Account, amount int) {
from.mu.Lock()
to.mu.Lock()
// ...
}
// transfer(A, B) 锁顺序:A → B
// transfer(B, A) 锁顺序:B → A
// → 两个并发调用形成环,死锁!
// 修复:按"账户 ID 的大小"排序,总是先锁 ID 小的
func transfer(from, to *Account, amount int) {
if from.id < to.id {
from.mu.Lock()
to.mu.Lock()
} else {
to.mu.Lock()
from.mu.Lock()
}
// ... 转账逻辑
to.mu.Unlock()
from.mu.Unlock()
}
// 现在无论 transfer(A, B) 还是 transfer(B, A),都先锁 ID 小的
// → 不存在环,不会死锁为什么这样就不会死锁?
因为如果所有线程都”先锁小 ID,再锁大 ID”,那么:
假设存在死锁环:A 等 B,B 等 C,C 等 A
如果 A 等 B,说明 A 已经锁了比 B 小的(或者 A 在等 B 这个更大的)
... 推理下去,会得到"ID 大小关系成环",矛盾。
所以按顺序锁,环不可能存在。
怎么定锁的顺序?
任何"全局唯一、可比较"的属性都可以:
- 对象的内存地址(指针比较)
- 数据库主键 ID
- 资源名的字典序
- 分片的编号
锁顺序的难点:
1. 顺序要"全局一致"
- 难在:大项目里,锁的获取分散在成百上千处代码
- 只要有一处"锁反了",死锁就可能发生
2. 顺序不是总能确定的
- 如果锁的集合是动态的(运行时才知道要锁哪些),
排序的代价和复杂度上升
3. 锁顺序解决"死锁",但不解决"活锁"和"饥饿"
- 后面会讲
锁顺序给你的启示:
“顺序”是消除”环”的最简单手段。
环(cycle)是很多问题的根源:死锁是”等待环”,循环依赖是”依赖环”,分布式系统的”环形复制”是”拓扑环”。
而消除环的通用方法,就是引入一个全局序(total order),让所有参与者按同一个方向走。这样环就断了。
例子:
- 死锁 → 锁排序
- 循环依赖 → 依赖倒置(都依赖抽象,而不是互相依赖)
- 微服务循环调用 → 规定”调用只能从上层到下层”(分层架构)
- Git 的 merge → 用 commit 的拓扑序
Q4 银行家算法为什么理论上完美,工程上没人用?
银行家算法(Banker’s Algorithm)是操作系统的经典内容,用于”死锁避免”(deadlock avoidance)。
它的思想:
银行家(操作系统)在发放贷款(分配资源)前,
先"模拟":如果我把这些资源给他,将来还能不能安全地满足所有人?
如果能 → 发放(安全状态)
如果不能 → 拒绝(可能不安全,先不发放,让他等着)
它理论上”完美”的地方:
如果系统处于"安全状态"(safe state),就一定不会死锁。
银行家算法保证系统永远处于安全状态。
为什么工程上没人用?
三个致命的现实问题:
问题一:它要求”提前声明最大需求”。
银行家算法要求每个进程"提前声明"它最多需要多少资源。
但现实中的程序,无法预知自己将来要多少内存、多少个锁。
(你能在程序启动时说出"我这次运行最多需要 3 个锁"吗?)
问题二:它要求”资源数量固定且已知”。
银行家算法假设资源(比如 10 台打印机、5 个锁)数量固定。
但现实中的资源(内存、文件句柄、连接)数量是动态变化的。
问题三:它假设”进程会按照声明的方式使用资源”。
即使你声明了"最多 3 个锁",程序里一个 bug 就可能申请第 4 个。
银行家算法无法防御"程序不按声明行事"。
最根本的问题:它太”悲观”了。
银行家算法为了保证"绝对不死锁",会拒绝很多"实际上安全"的资源分配。
结果是:系统资源利用率低,大量请求被无谓地拒绝。
银行家算法的教训(这是重点):
“理论正确”和”工程可行”之间,隔着”现实约束”。
银行家算法假设了一个”完美世界”(进程诚实、需求可知、资源固定),这些假设在现实里都不成立。
工程上,人们更倾向于”死锁检测 + 恢复”(事后处理),而不是”死锁避免”(事前预防)。 因为:
- 检测:等死锁发生了,检测到,然后杀掉一个进程(代价小,实现简单)
- 避免:每个操作都做复杂的安全性检查(代价大,且假设不成立)
这反映了一个普遍的工程哲学:
“乐观地假设它不会发生,出了问题再处理”(detect + recover)往往比”悲观地防止它发生”(avoid)更实用。
类似的例子:
- 内存管理:不做”内存耗尽预防”,而是 OOM Killer 事后处理
- 乐观锁:假设没冲突,冲突了重试
- 分布式系统:假设分区会发生,用超时 + 重试处理,而不是预防分区
当然,这个哲学的前提是:“出问题的代价可承受,且能恢复”。 如果代价不可承受(比如心脏起搏器死锁),那还是要”避免”。
Q5 死锁怎么检测?检测到了怎么破?
既然工程上选择”检测 + 恢复”,那具体怎么做?
死锁检测:等待图(wait-for graph)。
每个执行流是一个节点。
如果 A 在等 B 持有的资源,就从 A 画一条箭头到 B。
检测死锁 = 在这个图上找"环"。
找到环 = 死锁。
例子:
线程 A 持有 R1,等待 R2
线程 B 持有 R2,等待 R1
等待图:
A ──等待──→ B
↑ │
└────等待───┘
(A 等 B,B 等 A,成环 → 死锁)
在数据库里:
MySQL 的死锁检测:
InnoDB 维护一个"锁等待图",
每次加锁失败时,检查是否成环,
成环就选一个事务作为"牺牲品",回滚它。
你可以看到死锁日志:
"Deadlock found when trying to get lock; try restarting transaction"
在 Go 里:
Go 的 runtime 能检测”所有 goroutine 都阻塞”的死锁:
// 所有 goroutine 都永久阻塞,runtime 检测到并 panic
func main() {
ch := make(chan int)
<-ch // 永远等不到数据,且没有其他 goroutine 会发送
}
// 输出:fatal error: all goroutines are asleep - deadlock!但注意:Go 只能检测”全部 goroutine 都睡着”这种全局死锁,检测不了”部分 goroutine 死锁”(其他 goroutine 还在跑)。
检测到死锁怎么破?
方法一:终止(kill)一个进程/线程,打破环
- 选"代价最小"的牺牲品(比如回滚成本最低的事务)
- 数据库就是这么做的(回滚一个事务)
方法二:资源剥夺
- 强制从某个进程手里抢走资源,分给别人
- 但要保证"被剥夺的进程能恢复"(比如事务回滚)
方法三:回滚(rollback)
- 让某个进程回退到"死锁发生前"的状态
- 需要"检查点"(checkpoint)机制支持
检测的开销和时机:
何时检测?
1. 每次资源申请失败时检测(实时,但开销大)
2. 周期性检测(比如每 5 秒扫描一次,开销小但反应慢)
3. 检测到"响应变慢"时触发(启发式)
工程上,数据库是"申请失败时检测"(实时),
操作系统是"周期性检测"或"看门狗超时"(反应式)。
第二幕:Go 里的死锁
Q6 Go 的 channel 为什么会死锁?怎么避免?
channel 是 Go 的并发原语,但它也会死锁,而且死锁的方式和锁不一样。
channel 死锁的几种典型模式:
模式一:没人发送,也没人接收。
ch := make(chan int)
<-ch // 阻塞等数据,但没有任何 goroutine 会往 ch 发
// 如果这是唯一的 goroutine,runtime 会检测到死锁并 panic模式二:循环等待(channel 版的死锁环)。
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
ch1 <- 1 // 等 ch1 有人接收
v := <-ch2 // 等 ch2 有数据
_ = v
}()
go func() {
ch2 <- 2 // 等 ch2 有人接收
v := <-ch1 // 等 ch1 有数据
_ = v
}()
// goroutine 1:发到 ch1,等 ch2
// goroutine 2:发到 ch2,等 ch1
// 两个都在等对方,死锁!
time.Sleep(time.Second)
}模式三:无缓冲 channel 的”同步陷阱”。
ch := make(chan int) // 无缓冲
// 在同一个 goroutine 里,发送后立刻接收
ch <- 1 // 阻塞!因为无缓冲 channel 的发送要等接收方就绪
<-ch // 永远到不了这行
// 死锁无缓冲 channel 的语义:发送和接收必须”同时”发生。所以”先发后收”在同一 goroutine 里必然死锁。
模式四:channel 没关闭,range 永远阻塞。
ch := make(chan int)
go func() {
for v := range ch { // 等 ch 关闭才退出
fmt.Println(v)
}
fmt.Println("done")
}()
// ch 从没被 close,range 永远等,goroutine 泄漏怎么避免 channel 死锁?
1. 明确"谁发送、谁接收、谁关闭"(ownership 原则)
- 一般原则:谁发送,谁关闭
- 接收方不要随便 close(会 panic:close of closed channel)
2. 用 select + 超时/退出通道,避免"无限等待"
select {
case v := <-ch:
// 处理
case <-ctx.Done():
// 超时或取消,退出
}
3. 用带缓冲的 channel,避免"发送必须等接收"的刚性同步
ch := make(chan int, 10) // 缓冲 10,发送不用立刻等接收
4. 注意 goroutine 的"生命周期",防止"发送方没了,接收方还等"
- 用 WaitGroup 或 context 管理 goroutine 的退出
channel 死锁和锁死锁的区别(这是个值得思考的点):
锁死锁:多把锁,顺序错了,形成环
channel 死锁:通信双方互相等,或者一方等一个"永远不会来"的信号
本质相同:都是"执行流在等一个永远不会发生的事件"
一个深刻的理解:channel 是”通信”,锁是”共享内存”。
Go 的口号是”不要通过共享内存来通信,而要通过通信来共享内存”。
但 channel 也不是银弹——它把”共享内存的锁问题”,变成了”通信的同步问题”(死锁、泄漏、顺序)。两种方式都会出问题,只是问题的形态不同。
channel 死锁的本质,是”通信协议”设计错了(谁发、谁收、谁关、有没有超时)。这和第 17 章讲”分布式系统的通信协议”是同一个问题。
第三幕:活锁 —— 秩序的假活跃
Q7 活锁是什么?它和死锁的本质区别是什么?
活锁(livelock):执行流们没有阻塞,都在”积极行动”(重试、让出、回退),但就是没有任何进展。
和死锁的本质区别:
死锁:大家都"停"了(阻塞),谁都不动
活锁:大家都在"动"(重试、让出),但动了等于没动
经典的”走廊让路”比喻:
死锁:两个人迎面相遇,都站住不动,互相等对方让路
→ 永久僵持
活锁:两个人迎面相遇,都往左让,撞上;都往右让,又撞上
→ 一直在动,但永远过不去
活锁在系统里的典型形态:
形态一:CAS 重试风暴
多个线程同时 CAS,都失败,都重试,又同时失败……
(因为它们步调一致,每次都撞在一起)
形态二:事务反复回滚
两个事务冲突,都回滚重试,又冲突,又回滚……
形态三:错误重试风暴
服务 A 调 B 超时,A 重试;B 调 A 也超时,B 也重试;
重试流量互相放大,谁都恢复不了
活锁的根源:
死锁的根源是"等待"(你等我,我等你)。
活锁的根源是"过度的、无退避的重试"(你重试,我也重试,步调一致)。
关键区别:
死锁里,执行流"卡住"了(没有 CPU 活动)
活锁里,执行流"忙"着(占满 CPU),但做的是无用功
活锁比死锁更难察觉。
死锁时,系统会”卡住”,监控能发现(响应超时、CPU 空闲)。 活锁时,系统”看起来很忙”(CPU 100%),但请求一个都处理不了——你很容易误判为”流量太大”或”性能问题”,而没意识到是活锁。
Q8 活锁在工程里长什么样?(重试风暴、自旋竞争)
活锁在分布式系统里尤其常见,而且往往是”重试”导致的。看几个真实的形态。
形态一:重试风暴(retry storm)。
场景:服务 A 依赖服务 B
1. B 变慢(比如数据库抖动)
2. A 的请求超时,A 开始重试
3. 重试让 B 的负载翻倍,B 更慢
4. A 的更多请求超时,A 更疯狂地重试
5. B 彻底被打死
6. A 的所有重试都失败,A 也进入"重试死循环"
结果:A 和 B 都在"忙"(疯狂重试),但一个请求都没成功。
这是”正反馈循环”导致的活锁:越失败越重试,越重试越失败。
解法:退避(backoff)+ 熔断(circuit breaker)。
// 指数退避
for attempt := 0; attempt < maxRetries; attempt++ {
if err := callB(); err == nil {
return nil
}
// 退避:1ms, 2ms, 4ms, 8ms... 加上随机抖动
backoff := time.Duration(1<<attempt) * time.Millisecond
backoff += time.Duration(rand.Intn(1000)) * time.Microsecond
time.Sleep(backoff)
}
// 熔断:错误率过高时,直接快速失败,不再调用 B
// 让 B 有机会恢复,而不是被重试打死形态二:自旋竞争(CAS 活锁)。
多个线程同时对一个共享变量做 CAS:
for {
old := atomic.Load(&v)
if atomic.CAS(&v, old, new) {
break
}
// CAS 失败,立刻重试
}
如果线程步调一致(都在同一时刻重试),
它们会"同步地"失败,形成活锁。
最糟情况:所有线程在同一时刻读、同一时刻 CAS、同一时刻失败……
解法:在 CAS 重试里加”退避”。
for {
old := atomic.Load(&v)
if atomic.CAS(&v, old, new) {
break
}
// 加一点随机退避,打破"步调一致"
// 或者用 exponential backoff
cpu.Pause() // 或者 runtime.Gosched()
}形态三:死锁检测的”误杀循环”。
两个事务冲突 → 检测到死锁 → 回滚一个 → 另一个提交成功
但两个事务都"立刻重试" → 又冲突 → 又回滚 → 又重试……
解法:回滚后加”随机延迟”再重试,避免两个事务步调一致。
活锁的解药,归纳起来就是两个字:退避(backoff)。
死锁的解药是”排序”(消除环)。 活锁的解药是”退避”(打破同步)。
因为活锁的本质是”步调一致地撞车”,退避(尤其带随机抖动的退避)能打破这种同步。
这个思想在分布式系统里无处不在:
- 重试要退避(指数退避 + 抖动)
- 选主要随机化退避(Raft 的 leader 选举,第 18 章)
- 抢占要退避(以太网的 CSMA/CD,冲突后随机等待)
第四幕:饥饿 —— 秩序的偏心
Q9 饥饿是什么?优先级反转为什么会导致饥饿?
饥饿(starvation):某个执行流永远得不到它需要的资源,尽管资源没有被死锁。
和死锁的区别:
死锁:所有参与者都得不到(僵死)
饥饿:某个(些)参与者得不到,其他参与者正常(偏心)
和活锁的区别:
活锁:大家在"动"但都没进展
饥饿:别人在"进展",就你得不到
饥饿的典型场景:
场景一:非公平锁导致饥饿。
Go mutex 的正常模式(第 5 章讲过):
新来的 goroutine 可以"插队"(因为它在 CPU 上,CAS 更快)
等待的 goroutine 可能永远抢不过新来的
→ 饥饿
Go 的解法:饥饿模式(等待超过 1ms 就切换到公平模式)
场景二:读写锁的写者饥饿。
RWMutex:读锁可以并行,写锁互斥
如果读者源源不断,写者永远等不到(第 5 章讲过)
→ 写者饥饿
Go 的解法:有写者等待时,阻塞新来的读者
场景三:优先级反转导致饥饿(最经典的)。
优先级反转(priority inversion):
高优先级任务 H:等一个锁
低优先级任务 L:持有这个锁
中优先级任务 M:抢占 L(M 比 L 优先级高)
结果:
M 抢占 L → L 没有 CPU,迟迟不释放锁 → H 一直等锁
→ H(最高优先级)被 M(中优先级)"事实上"阻塞了
→ 优先级反转:中优先级"插队"在高优先级前面
为什么优先级反转会导致饥饿?
因为 H 在等锁,而锁的持有者 L 被 M 抢占。只要 M 持续运行(或者有源源不断的 M),L 就永远释放不了锁,H 就永远饿死。
Mars Pathfinder 事件(这是最著名的案例):
火星探测器 Pathfinder 上的软件:
- 气象数据采集任务(低优先级):持有"总线锁"
- 通信任务(高优先级):等"总线锁"
- 中优先级任务:持续运行,抢占低优先级任务
结果:
低优先级任务持有总线锁,但被中优先级抢占,锁释放不了
高优先级通信任务等锁,等不到
→ 看门狗(watchdog)认为系统出故障,反复重启
NASA 的工程师通过远程调试,在火星上(!)定位了这个问题,
修复方式是:启用"优先级继承"。
Q10 Mars Pathfinder 的教训:优先级继承怎么解决它?
优先级继承(priority inheritance):
当一个高优先级任务 H 等待一个低优先级任务 L 持有的锁时,
临时把 L 的优先级"提升"到 H 的优先级。
这样:
L(临时提升到高优先级)不会被 M 抢占
→ L 尽快运行完,释放锁
→ H 尽快拿到锁
→ 优先级反转被打破
具体过程:
1. L(优先级 1)持有锁,正在运行
2. H(优先级 3)请求这个锁,进入等待
3. 内核检测到:H 在等 L 的锁 → 把 L 提升到优先级 3
4. M(优先级 2)想运行,但 L 现在是 3,比 M 高
→ M 抢不到 CPU,L 继续运行
5. L 运行完,释放锁 → L 恢复优先级 1
6. H 拿到锁,继续运行
优先级继承解决了什么:
没有优先级继承:
H(3) 等锁 → L(1) 被 M(2) 抢占 → L 迟迟不释放 → H 饿死
有优先级继承:
H(3) 等锁 → L 临时变 3 → M(2) 抢不过 L → L 快速释放 → H 拿到锁
优先级继承的问题(它也不是完美的):
1. 只解决"优先级反转",不解决"死锁"
2. 如果锁是嵌套的(L 持有锁 A 等锁 B),继承要传播(链式继承)
复杂度上升
3. 有"优先级天花板"(priority ceiling)等更复杂的替代方案
Linux 的 futex 支持优先级继承(PI-futex),Go 的 mutex 默认不用(因为 Go 的 goroutine 没有”优先级”概念)。
Mars Pathfinder 的教训(比技术本身重要):
1. 并发 bug 可能是”优先级 + 锁 + 抢占”三者交互产生的,单独看每一部分都没错。
2. 这类 bug 极难复现——它依赖精确的时序(L 恰好持有锁时被 M 抢占)。NASA 是在火星上远程诊断的,因为他们在地球上根本复现不出来。
3. 可观测性救了这个任务——Pathfinder 有完整的日志和远程调试能力,工程师才能定位问题。如果是”黑盒”系统,只能瞎猜。
4. 优先级反转的通用解法:优先级继承、优先级天花板。核心思想都是**“锁的持有者的优先级,应该反映等待者的优先级”**——即”谁在等我,我就有多重要”。
第五幕:工程防线
Q11 怎么设计一个”死锁几乎不可能发生”的系统?
这是这一章的”落地”部分。给一套可操作的工程实践。
第一层:结构上消除(最好)。
1. 避免共享可变状态(最高境界)
- 函数式风格:数据不可变,没有共享,就没有锁,就没有死锁
- 用消息传递代替共享内存(actor 模型、channel)
2. 减少锁的数量(次好)
- 每少一个锁,就少一个死锁的可能
- 合并临界区,用一个大锁代替多个小锁(牺牲并行度换简单性)
3. 让锁的获取是"原子的"(一次性拿全)
- 一次 lock 拿所有需要的锁,避免"持有并等待"
第二层:约定上约束(实用)。
4. 锁排序(lock ordering)
- 规定全局的锁获取顺序,所有代码遵守
- 这是最实用的防死锁手段
5. 锁的持有时间尽量短
- 临界区里不要做慢操作(I/O、网络、sleep)
- 临界区里不要调用"可能再获取锁"的函数
6. 明确锁的"所有权"
- 谁加的锁,谁负责释放(用 defer 保证)
- 锁和它保护的数据放一起,通过方法访问
第三层:运行时兜底(最后防线)。
7. 用超时(tryLock with timeout)
- 拿不到锁就超时放弃,而不是无限等待
- 放弃后可以:报错、重试(带退避)、降级
8. 死锁检测 + 恢复
- 数据库:检测锁等待图,回滚牺牲品
- 服务:看门狗,检测到卡死就重启
9. 监控 + 告警
- 监控"锁等待时间"、"goroutine 数量"、"请求延迟"
- 死锁的早期信号:某个请求的延迟异常飙升
Go 里的具体实践:
// 1. 用 defer 保证释放
func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock() // 保证一定释放
a.balance += amount
}
// 2. 锁排序(转账)
func transfer(from, to *Account, amount int) {
// 总是先锁 ID 小的
low, high := from, to
if from.id > to.id {
low, high = to, from
}
low.mu.Lock()
defer low.mu.Unlock()
high.mu.Lock()
defer high.mu.Unlock()
// ... 转账
}
// 3. 避免在锁内做慢操作
func (c *Cache) Get(key string) (Value, error) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.data[key]
if !ok {
// 错!在锁内查数据库/发请求,锁持有时间太长
v = c.loadFromDB(key)
}
return v, nil
}
// 正确:锁外加载,锁内只做 map 操作
func (c *Cache) Get(key string) (Value, error) {
c.mu.Lock()
v, ok := c.data[key]
c.mu.Unlock()
if !ok {
v, _ = c.loadFromDB(key) // 锁外慢操作
c.mu.Lock()
c.data[key] = v
c.mu.Unlock()
}
return v, nil
}
// 4. channel 用 select + 超时
func worker(ch chan Job) {
for {
select {
case job := <-ch:
process(job)
case <-time.After(10 * time.Second):
// 超时了,检查是否需要退出
return
}
}
}这一章的核心,最后凝练成一句话:
死锁、活锁、饥饿,都是”协调失败”。
- 死锁:缺”排序”(消除环)
- 活锁:缺”退避”(打破同步)
- 饥饿:缺”公平”(保证轮到我)
而最高明的做法,是通过设计让”协调”这个需求本身消失(不可变数据、消息传递、减少共享)。协调越少,出错的余地越小。
思考题
思考题 1
面试官给你看这段代码,问会不会死锁:
func main() {
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
// 这里没有再次 Lock,会死锁吗?
}然后他问:sync.Mutex 是不可重入锁,为什么?如果让你实现一个”可重入锁”,你会怎么做?
参考答案
第一问:不会死锁。
这段代码只是 Lock 一次、Unlock 一次,不会死锁。面试官可能在诱导你想”重入”的问题。
真正会出问题的是”重入”:
var mu sync.Mutex
func outer() {
mu.Lock()
defer mu.Unlock()
inner() // inner 里也 Lock 同一个 mu
}
func inner() {
mu.Lock() // 死锁!mu 已经被 outer 锁住了
defer mu.Unlock()
}outer 持锁调用 inner,inner 又 Lock 同一个锁 → 死锁(自己等自己)。
第二问:为什么 sync.Mutex 是不可重入的?
原因一:重入锁更难实现,有额外开销。
可重入锁需要记录"持有者是谁"和"重入次数":
- 每次 Lock 检查"是我自己持有着吗?是的话 count++"
- 每次 Unlock 检查"count--,到 0 才真正释放"
这些检查和记录,都增加了锁的开销。
Go 的设计哲学是"简单、快",所以选择了不可重入。
原因二:可重入锁会掩盖设计问题。
可重入锁允许"在持锁的情况下再次获取同一把锁"。
这看起来方便,但实际上掩盖了"临界区边界不清"的设计问题。
如果 outer 持锁调 inner,说明 outer 和 inner 的"锁边界"不清晰:
- 到底锁保护的范围是什么?
- inner 是否应该假设"调用者已经持锁"?
Go 团队的观点:需要可重入锁的地方,往往说明"锁的粒度设计有问题",
应该重新设计,而不是用可重入锁掩盖。
原因三:可重入锁和”条件变量”语义冲突。
可重入锁会让”wait/notify”的语义变得复杂(wait 时释放几次?唤醒后恢复几次?)。Java 的 ReentrantLock 处理了这些,但复杂度明显上升。
第三问:怎么实现可重入锁?
type ReentrantMutex struct {
mu sync.Mutex
owner int64 // 持有者的 goroutine ID
count int // 重入次数
}
func (m *ReentrantMutex) Lock() {
gid := getGoroutineID() // 获取当前 goroutine ID
if atomic.LoadInt64(&m.owner) == gid {
// 自己是持有者,重入
m.count++
return
}
m.mu.Lock()
atomic.StoreInt64(&m.owner, gid)
m.count = 1
}
func (m *ReentrantMutex) Unlock() {
gid := getGoroutineID()
if atomic.LoadInt64(&m.owner) != gid {
panic("unlock of mutex not held by this goroutine")
}
m.count--
if m.count == 0 {
atomic.StoreInt64(&m.owner, 0)
m.mu.Unlock()
}
}关键难点:怎么获取 goroutine ID?
Go 故意不暴露 goroutine ID(runtime.GoID() 在标准库是私有的),官方态度是”你不应该依赖 goroutine ID”。
原因:
- goroutine ID 会让程序员写出”依赖特定 goroutine”的脆弱代码
- goroutine 可能被迁移、复用,ID 不是稳定的身份
所以”正规”的可重入锁实现,要么用 hack(unsafe 拿 g 结构),要么用 token 代替 gid:
// 用 token 代替 goroutine ID(更干净)
type ReentrantMutex struct {
mu sync.Mutex
token int64
count int
}
func (m *ReentrantMutex) Lock(token int64) {
if atomic.LoadInt64(&m.token) == token {
m.count++
return
}
m.mu.Lock()
atomic.StoreInt64(&m.token, token)
m.count = 1
}更重要的追问:Go 为什么不提供可重入锁?这反映了什么设计哲学?
1. Go 追求"简单",可重入锁增加了复杂度和开销
2. Go 相信"需要可重入 = 设计有问题",应该重构而不是补丁
3. Go 的并发哲学是"通过通信共享内存",而不是"精心管理锁"
→ 与其纠结锁的重入,不如用 channel 重构成消息传递
这道题考察什么:
- 知不知道 mutex 不可重入(基础知识)
- 知不知道为什么(不是”忘了实现”,是设计选择)
- 知不知道 goroutine ID 的问题(Go 的隐藏陷阱)
- 能不能说出”可重入锁掩盖设计问题”这个更深层的观点
思考题 2
你的服务有”扣款”操作,需要同时操作”用户余额表”和”订单表”(两张表都可能被多个服务同时操作)。你担心死锁。你会怎么设计来避免?如果数据库本身会检测死锁并回滚,你的设计会简化吗?
参考答案
这是”死锁预防”在数据库层的典型应用。
核心问题:两个事务同时操作”余额表”和”订单表”,顺序不一致就死锁。
事务 A:UPDATE 余额表 → UPDATE 订单表
事务 B:UPDATE 订单表 → UPDATE 余额表
→ A 锁余额等订单,B 锁订单等余额 → 死锁
方案一:锁排序(统一操作顺序)。
规定:所有涉及”余额 + 订单”的操作,一律先操作余额,再操作订单。
-- 所有扣款事务,统一顺序
BEGIN;
UPDATE balance SET amount = amount - 100 WHERE user_id = 123; -- 先余额
UPDATE orders SET status = 'paid' WHERE id = 456; -- 后订单
COMMIT;只要所有代码都遵守这个顺序,就不会死锁。
问题:跨团队、跨服务时,很难保证”所有人都遵守同一顺序”。这是锁排序的固有弱点——依赖”约定”。
方案二:一次性锁定(减少持有并等待的窗口)。
BEGIN;
-- 用 SELECT ... FOR UPDATE 一次性锁定需要的行
SELECT * FROM balance WHERE user_id = 123 FOR UPDATE;
SELECT * FROM orders WHERE id = 456 FOR UPDATE;
-- 锁定后再做更新
UPDATE balance SET ... WHERE user_id = 123;
UPDATE orders SET ... WHERE id = 456;
COMMIT;把所有”要锁的行”在事务开头一次性锁住,减少”边做边锁”的窗口。
方案三:缩小事务范围(减少锁持有时间)。
死锁概率 ∝ 锁持有时间 × 锁数量
所以:
1. 事务尽量短(不要在一个事务里做慢操作、调外部服务)
2. 锁尽量少(只锁必要的行)
3. 锁尽量晚获取(要用的时候才锁)
方案四:依赖数据库的死锁检测 + 重试(工程上最常用)。
// 数据库检测到死锁,回滚一个事务,应用层捕获并重试
func deduct(userID int, orderID int, amount int) error {
for i := 0; i < 3; i++ {
err := tx.Deduct(userID, orderID, amount)
if err == nil {
return nil
}
if isDeadlockErr(err) {
// 死锁,回滚后带退避重试
time.Sleep(time.Duration(1<<i) * time.Millisecond)
continue
}
return err // 其他错误,直接返回
}
return errors.New("deadlock retry exhausted")
}回答”如果数据库会检测死锁并回滚,设计会简化吗”:
会,但不会完全简化。
简化了的部分:
- 你不需要"完美地"预防死锁(排序、一次性锁定可以放宽)
- 死锁发生了,数据库帮你回滚,你重试即可
不能简化的部分:
- 重试逻辑必须有(而且要幂等,否则重试会重复扣款)
- 死锁重试会增加延迟和数据库负载,不能完全不管
- 死锁频繁发生,说明设计有问题(锁顺序乱、事务太长),还是要修
我的推荐(综合方案):
1. 尽量统一锁顺序(能约束团队就约束)
2. 事务尽量短、锁尽量少(减少死锁概率)
3. 依赖数据库死锁检测 + 应用层带退避的重试(兜底)
4. 重试必须幂等(这是最关键的一条,否则重试 = 重复扣款)
5. 监控死锁率(死锁频繁 = 设计有问题,要修,不能永远靠重试)
这道题的元教训:
“死锁预防”(事前)和”死锁恢复”(事后)不是二选一,而是配合使用。
- 预防(排序、短事务):把死锁概率降到很低
- 恢复(检测 + 回滚 + 重试):处理那”很低但非零”的残余概率
只预防不恢复:残余的死锁会卡死服务 只恢复不预防:死锁频繁,重试风暴拖垮数据库
而重试的前提是幂等——这又把我们带回了第 14 章”日志与幂等”的主题。死锁重试、网络重试、分布式事务重试,所有”重试”都逃不开幂等。
思考题 3
“活锁和死锁,哪个更危险?“为什么?在监控上,你怎么区分一个系统是”死锁了”还是”活锁了”还是”正常的过载”?
参考答案
结论:活锁通常更危险,因为它更隐蔽。
为什么活锁更危险:
死锁:
- 症状明显:系统"卡住",CPU 空闲,请求全部超时
- 监控能发现:CPU 掉到接近 0,但请求延迟飙到超时
- 容易判断:"这不是过载,是卡死了"
活锁:
- 症状隐蔽:系统"很忙",CPU 100%,但请求全部失败
- 容易被误判:"这是流量太大了吧"或者"性能瓶颈"
- 危害:它会持续消耗资源(CPU、连接、带宽),
而且和"过载"的监控指标很像,难以区分
在监控上怎么区分三种状态:
关键看三个指标的组合,而不是单看一个:
指标组合:
1. CPU 使用率
2. 请求吞吐(QPS / 成功请求数)
3. 请求延迟
4. 队列长度 / 阻塞线程数
5. 错误率
三种状态的画像:
死锁:
CPU ↓(几乎 0,因为都阻塞了)
吞吐 ↓↓(几乎 0)
延迟 ↑↑(全部超时)
队列 ↑↑(堆积)
特征:CPU 低 + 吞吐低 → "有资源但没干活"
活锁:
CPU ↑↑(100%,都在重试/自旋)
吞吐 ↓↓(几乎 0,重试都失败)
延迟 ↑↑(超时)
错误率 ↑↑(重试失败)
特征:CPU 高 + 吞吐低 → "很忙但没产出"
正常过载:
CPU ↑(高,但合理)
吞吐 →(接近瓶颈,但还在产出)
延迟 ↑(升高但没到超时)
特征:CPU 高 + 吞吐也高 → "忙,但有产出"
核心区分标准:CPU 使用率和”有效吞吐”的比值。
死锁:CPU 低 / 吞吐低 → 资源闲置,没干活
活锁:CPU 高 / 吞吐低 → 资源被"无用功"占满
过载:CPU 高 / 吞吐高 → 资源被"有用功"占满
具体到监控指标:
1. 看"成功请求数"和"总请求数"的比值(成功率)
活锁:成功率骤降,但 CPU 还是 100%
2. 看"有效工作的比例"
比如:数据库的 QPS、CPU 的 user% vs sys%
活锁/自旋:sys%(内核态)或某个线程的 busy% 异常高
3. 看 goroutine / 线程状态分布
死锁:大量 goroutine 在 waiting(等锁/等 channel)
活锁:大量 goroutine 在 running(重试循环)
过载:goroutine 在 running 但确实在干活
4. Go 里可以用 pprof 看:
- goroutine 的调用栈,看它们卡在哪
- 死锁:卡在 Lock()/<-ch
- 活锁:卡在 for 循环重试
一个诊断思路:
1. 先看 CPU:高还是低?
- 低 + 请求超时 → 怀疑死锁
- 高 + 请求失败 → 怀疑活锁或过载
2. 再看吞吐:有没有产出?
- 无产出 + CPU 高 → 活锁
- 有产出 + CPU 高 → 过载
3. 最后看 goroutine/线程栈:卡在哪?
- 卡在 Lock/Wait → 死锁
- 卡在 for 重试 → 活锁
- 在正常干活 → 过载
这道题想让你记住的:
判断系统状态,要看”指标的组合”,不是单个指标。
CPU 100% 可能是”忙”(过载),也可能是”瞎忙”(活锁)。 CPU 0% 可能是”闲”(没流量),也可能是”死”(死锁)。
关键指标是**“有效吞吐”**——系统到底完成了多少”有用的工作”。
这和第 20 章”可观测性”是呼应的:好的监控,不是堆指标,而是能回答”系统在正常工作吗”这个核心问题。
思考题 4(开放题)
“不要通过共享内存来通信,而要通过通信来共享内存”(Go 的哲学)。这句话和”锁/死锁”有什么关系?channel 真的能完全避免死锁吗?如果不能,channel 相比锁的优势到底在哪?
参考答案
这是对 Go 并发哲学的一次深度审视,也是个开放题。
先理解这句话和锁的关系:
"通过共享内存来通信" = 用锁保护共享变量
- 多个 goroutine 共享一个变量,用锁保证一致性
- 通信 = 读写共享变量(通过锁协调)
- 问题:锁、死锁、竞态、内存模型
"通过通信来共享内存" = 用 channel 传递数据
- goroutine 之间不共享变量,而是通过 channel 传递数据
- 共享 = 把数据的所有权通过 channel"转移"过去
- 好处:数据在任一时刻只属于一个 goroutine,无需锁
这句话的精髓:用”所有权转移”代替”共享 + 锁”。
共享内存模型:
数据 X 被多个 goroutine 访问 → 需要锁保护 → 有死锁风险
channel 模型:
数据 X 从 goroutine A 通过 channel 发给 goroutine B
→ 发送后,X 的"所有权"从 A 转移到 B
→ 任一时刻只有一个 goroutine 拥有 X,不需要锁
那 channel 能完全避免死锁吗?不能。
第 Q6 已经讲过,channel 有自己的死锁方式:
1. 循环等待(两个 goroutine 互发互等)
2. 没人发送/接收(等一个永远不会来的信号)
3. 无缓冲 channel 的同步陷阱
4. channel 没关闭,range 永远等
所以 channel 不是”没有死锁”,而是”死锁的形态变了”。
锁的死锁:资源竞争的死锁(你锁 A 等 B,我锁 B 等 A)
channel 的死锁:通信协调的死锁(你等我发,我等你发)
本质都是"执行流在等一个不会发生的事件",
只是"事件"从"锁释放"变成了"channel 有数据"。
那 channel 相比锁的优势到底在哪?
优势一:把”隐式的协调”变成”显式的通信”。
锁:协调是"隐式"的(你 Lock,我 Unlock,谁知道你们在同步什么?)
channel:通信是"显式"的(ch <- data,明确表达了"我把数据交给你")
显式的好处:
- 代码意图更清晰(一看 channel 就知道 goroutine 之间怎么协作)
- 更容易推理(数据流是看得见的:谁发谁收)
优势二:channel 天然支持”选择性等待”(select)。
select {
case v := <-ch1:
// ch1 有数据
case v := <-ch2:
// ch2 有数据
case <-ctx.Done():
// 取消/超时
}select 让你能”同时等多个事件,哪个先来响应哪个”,还能处理超时和取消。这是锁很难优雅表达的。
优势三:channel 能表达”有界的流”,天然支持背压。
ch := make(chan Job, 100) // 有界缓冲
// 生产者发送,缓冲区满了就阻塞(背压)
// 消费者处理,忙不过来生产者自然慢下来有界 channel 天然实现了”背压”(第 16 章),而用锁实现背压要写不少代码。
优势四:channel 的”所有权转移”语义,从根源上减少竞态。
锁模型:你拿到了锁,但你不知道"别人会不会也拿到锁改数据"
channel 模型:数据传过去后,你就"不再拥有"它,别人改它不关你事
但 channel 也有劣势(要客观):
1. 性能:channel 的发送接收有开销(涉及运行时调度、内存)
高频的计数器操作,atomic 比 channel 快得多
2. 不适合"共享状态"本身
如果一个计数器天然要被多个 goroutine 读,
用 channel 反而别扭(要建一个专门的 goroutine 管理它)
3. 过度使用会导致"goroutine 满天飞"
为了通信建一堆 goroutine,管理它们的生命周期也是负担
4. channel 不是"没有坑",它有死锁、泄漏、close 的 panic
我的结论(平衡的观点):
“通过通信共享内存”是好的默认选择,但不是教条。
- 数据需要”转移所有权” → channel(优雅)
- 数据需要”共享读取” → 锁/atomic 更合适
- 简单的计数器、标志位 → atomic(最快)
- 复杂的协作流程 → channel + select(最清晰)
真正的关键是:让”数据的所有权”清晰。
锁的问题是”所有权模糊”(谁都能改,靠锁协调)。 channel 的价值是”所有权明确”(一次只有一个 goroutine 拥有)。
所以,不管用锁还是 channel,核心原则是:明确”这块数据现在归谁管”。 一旦所有权清晰了,竞态和死锁的概率就大幅下降。
一个更深的洞察:
“共享内存 vs 消息传递”,是并发编程的两大范式之争(这也是 Akka/Erlang 的 actor 模型 vs Java 的线程模型之争)。
- 共享内存(线程 + 锁):性能高,但心智负担重(竞态、死锁、内存模型)
- 消息传递(actor/channel):心智清晰,但有通信开销
没有绝对的赢家。现代的趋势是:
- 单机内:共享内存为主(性能),但用 channel/actor 组织复杂协作
- 跨机器:消息传递为主(网络天然是消息传递)
而”通过通信共享内存”这句话的价值,在于提醒你:先考虑”能不能把共享变成传递”,而不是一上来就上锁。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 06 章 · 死锁、活锁与饥饿:秩序的三种崩塌方式 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【三种崩塌】 │
│ 死锁 = 永久阻塞(谁都不动) │
│ 活锁 = 永久空转(都在动,没进展) │
│ 饥饿 = 永久得不到(别人动,轮不到我) │
│ 共同根源:竞争资源时,缺少全局、公平、有界的协调 │
│ │
│ 【死锁四条件 + 破解】 │
│ 互斥 → 共享化(MVCC/不可变/COW) │
│ 持有并等待 → 一次性拿全 / 超时放弃 │
│ 不可剥夺 → 抢占 / tryLock 超时 │
│ 循环等待 → 锁排序(最实用) │
│ │
│ 【活锁的解药:退避】 │
│ 本质是"步调一致地撞车",退避(指数 + 随机抖动)打破同步 │
│ 典型:重试风暴、CAS 同步失败、事务反复回滚 │
│ │
│ 【饥饿的解药:公平 + 优先级继承】 │
│ Go mutex 饥饿模式、RWMutex 阻塞新读者给写者让路 │
│ Mars Pathfinder:优先级继承 = "谁在等我,我就有多重要" │
│ │
│ 【监控画像(区分三态)】 │
│ 死锁:CPU 低 + 吞吐低(有资源没干活) │
│ 活锁:CPU 高 + 吞吐低(很忙没产出) │
│ 过载:CPU 高 + 吞吐高(忙但有产出) │
│ 核心:看"有效吞吐",不是单看 CPU │
│ │
│ 【工程防线(三层)】 │
│ 结构层:消除共享 / 减少锁 / 一次性拿全 │
│ 约定层:锁排序 / 短临界区 / 明确所有权 │
│ 兜底层:超时 / 死锁检测+恢复 / 监控告警 │
│ │
│ 【一句话】 │
│ 死锁缺排序,活锁缺退避,饥饿缺公平; │
│ 最高明的做法是让"协调"这个需求本身消失。 │
│ │
│ 【下一章预告】 │
│ 锁和死锁都建立在"内存操作是有序的"这个假设上—— │
│ 但编译器和 CPU 会重排你的代码,下一章讲内存模型与屏障, │
│ 以及为什么 x86 上测不出的 bug,在 ARM 上会爆炸。 │
└──────────────────────────────────────────────────────────────┘