锁外回调、队列锁、分片锁:一句追问引出的死锁预防链
上一篇《不用堆的延迟队列》结尾我甩了这么一句:
mu的临界区里只有下标与段操作——Push/Pop全是纯内存操作,零回调;所有 store 回调都在mu之外。这不是巧合:回调若进mu,形成「队列锁 → 分片锁」嵌套,与业务侧任何反向持锁顺序冲突就是死锁套餐。
有读者追问:队列锁和分片锁分别是什么?「嵌套」为什么就是死锁?Cleanup 不持锁又会引出什么?那篇是「是什么」,这篇补上「为什么」和「怎么防」——先给两个词显影,再把死锁的时间线摊开,最后给一张按场景取用的手段表。
一、那句话到底在说什么
先把场景摆清楚,术语才有落点。一个延迟队列,外加它背后的存储 store,组成两个角色:
- 延迟队列有一把锁
q.mu,保护内部的[][]string分段切片、headSeg下标和段计数。Push/Pop的全部临界区就在这里,而且全是纯内存操作——挪下标、置空槽位、偶尔收缩外层数组,没有任何一次调用会跑到队列外面去。 - 存储不归队列管。队列只认识一个
DelayQueueStore接口,上面两个方法:GetItem(id)和RemoveItem(id)。具体实现是 tracker,它把记录按 ID hash 到 N 个分片、每片一把锁——这就是分片锁。GetItem/RemoveItem只拿命中的那一把,不碰其他分片。
关键在于这个接口是一层抽象边界:队列拿着 store 调用回调时,它不知道对面有几把锁、更不知道那些锁内部还会去碰什么。接口把「谁保护什么」这件事对队列完全屏蔽了。
于是「锁外回调」这句话的完整含义是:队列在持有 q.mu 期间,绝不调用 store 的任何方法。 回调要么在加锁之前调、要么在解锁之后调,反正不能在 q.mu 的怀里调。
为什么要把这条当纪律?因为一旦回调进了 mu,同一时刻这个 goroutine 就同时握着两把锁的入口——而业务侧完全可能反着来。两条相反方向的持锁路径,就是下面第三节的死锁。
二、名词显影:两种锁的粒度取向
队列锁:一把粗锁守住一个不变量
q.mu 是典型的粗粒度锁:一把锁罩住整个数据结构,一个临界区,一条不变量。写起来的好处是不用动脑子——进临界区就把世界冻住,出来再解冻,中间怎么折腾都安全。
代价是串行:所有 Push / Pop 排成一条队。但这个代价这里付得起,因为临界区里只有内存操作,纳秒级。粗锁的唯一判据是临界区长度,不是锁的数量——临界区短,粗锁就是最优解;临界区长到包含 I/O、回调、分配,粗锁才变成灾难。选一把大锁还是切小,永远先看临界区里有什么,再看锁。
func (q *DelayQueue) Pop() (string, bool) {
q.mu.Lock()
defer q.mu.Unlock()
// 到这里为止:只碰 segments / headSeg / 段计数
// 没有 store 调用、没有 channel、没有分配(收缩那行除外)
if q.isEmpty() {
return "", false
}
id := q.segments[q.headSeg][q.headOffset]
q.segments[q.headSeg][q.headOffset] = "" // 断引用,字节可回收
q.advance()
return id, true
}
分片锁:用空间换并行度
分片锁的方向正好相反:把一把大锁拆成 N 把,让 key 不相交的操作真正并行。tracker 的做法是按 ID 哈希取模落到分片,GetItem(id) 只碰那一片。
type txShard struct {
mu sync.RWMutex
records map[string]*TracterRecord
}
func (t *CrossTxTracker) getTxShard(txID string) *txShard {
hash := hashString(txID) // DJB 变体,h*33+c 写成移位加法
return t.txShards[hash%uint32(t.shardCount)]
}
分片锁的选型、分片数为什么取 CPU 核数的 2-4 倍、为什么不用 sync.Map、预分配为什么按 500000/shardCount——都在《从全量遍历到 O(1)》里拆过,本篇只借它的结构,不重复。
这里只强调一件事:分片锁引入了一个粗锁世界里不存在的问题——锁序。 一把锁的世界里,不存在「先拿谁后拿谁」;而一旦项目里出现两把以上的锁,尤其是锁还会跨越抽象边界(这里就是那个 store 接口),「按什么顺序拿」就从一个隐含事实变成一个必须显式回答的协议。回答得好,天下太平;回答漏了,就是 ABBA。
粗锁 vs 分片:不是好坏,是取向
粗锁赌「临界区短」,用串行换简单;分片锁赌「key 不相交」,用管理成本换并行。前者怕的是临界区里长出回调,后者怕的是锁与锁之间长出顺序依赖。延迟队列选了粗锁,tracker 选了分片锁——同一个系统里两种取向并存,而死锁恰恰长在两者的交界处。
三、ABBA 死锁:两条边互等
死锁有四个必要条件:互斥、持有并等待、不可抢占、循环等待。前三个在 Go 里几乎是白送的——sync.Mutex 天然互斥、天然持有并等待、天然不可抢占。唯一能被我们主动打破的就是「循环等待」。 ABBA 死锁是循环等待最简的形态:两个 goroutine、两把锁、相反顺序。
先做一个反事实假设:假设有人图省事,把 store 回调塞进了 q.mu 的临界区。于是两条持锁路径同时存在:
- 清理路径:
Cleanup拿q.mu→ 在临界区里调store.GetItem(id)→ 想拿分片锁shard[S].mu。顺序是「队列锁 → 分片锁」。 - 业务路径:绑定/回填事务在
shard[S].mu内完成 → 顺手调q.Push(txID)入队 → 想拿q.mu。顺序是「分片锁 → 队列锁」。
两条边方向相反,一旦撞上同一个分片 S,就闭环了:
sequenceDiagram
autonumber
participant C as Cleanup 协程
participant M as q.mu
participant S as shard[S].mu
participant B as 业务协程
B->>S: Lock() 绑定回填,持有分片锁
C->>M: Lock() 进入清理,持有队列锁
rect rgb(255, 235, 235)
C->>S: GetItem(id) → 想拿分片锁
Note over C,S: C:持有 q.mu,等待 shard[S].mu
B->>M: q.Push(txID) → 想拿队列锁
Note over B,M: B:持有 shard[S].mu,等待 q.mu
end
Note over C,B: C 等 S、B 等 M —— 两条边互等,永久停摆 把等待关系画成图更直观,它就是一个环:
flowchart LR
C["Cleanup 协程"] -- "持有" --> M["q.mu"]
C -- "等待" --> S["shard[S].mu"]
B["业务协程"] -- "持有" --> S
B -- "等待" --> M 两条边一旦成环,没有任何一方会醒。没有超时、没有重试、没有报错——两个 goroutine 各抱着自己的锁,永远 park 在对方的锁上。
比普通卡死更阴的是它不会崩。Go runtime 的全局死锁检测只在「所有 goroutine 都睡着」时才触发 fatal error;只要进程里还有任何一个 timer、netpoll 或别的后台协程活着,检测器就不会报警。于是线上表现是:清理协程不动了、入队开始堆积、内存慢慢涨——一个静默的停摆,而不是一次崩溃。日志里什么都看不到,因为它压根没走到写日志那一步。
四、手段分级:五招,按代价排开
知道死锁长什么样,接下来是防。防的手段不止一种,各有代价,我用「需要多少全局知识」和「改动多大」来排序,从最省事排到最彻底。
① 锁排序:定义全局获取顺序
最正统的一招:给所有锁定一条全序,任何 goroutine 都必须按这个顺序拿锁。只要所有人都遵守,循环等待就不可能成立——因为环要求某条边逆序,而全序禁止逆序。
问题在于谁来保证这条约定。锁排序需要的是全局知识:顺序写在哪、跨模块怎么维护、新来的人怎么知道。在队列和 tracker 之间,隔着一个 store 接口——
- 「队列锁 → 分片锁」这条边的顺序由队列决定;
- 「分片锁 → 队列锁」那条边的顺序由业务决定。
队列作者根本管不到业务怎么写。于是锁排序在「锁全在自己掌控」时是好招;一旦锁跨越抽象边界,它就退化成「祈祷调用方读了文档」。而祈祷不是工程。
反过来看:要让全序成立,业务那条「分片锁 → 队列锁」就必须放弃嵌套——拿分片锁时不许调 q.Push,得先把分片锁放了。你发现问题了吗?这个结论和「别嵌套」是一回事。 在这里,锁排序自然收敛成了下一招。
② 锁外回调 / 临界区瘦身(29-3 的选择)
29-3 选了这条路,它的判据很朴素:
临界区里只做两件事——改自己的状态、读自己的状态。任何「调出去」的动作都往外挪。
func (q *DelayQueue) Cleanup() {
// 先读持有快照:锁外,零锁
if p := q.pending.Load(); p != nil {
// 回调全在 q.mu 之外:这里拿分片锁随便拿,不怕
item := q.store.GetItem(p.id)
if item == nil || item.IsCompleted() {
q.clearPending()
} else if time.Now().After(p.expireAt) {
q.store.RemoveItem(p.id) // 又一个锁外回调
atomic.AddInt64(&q.totalExpired, 1)
q.clearPending()
}
}
q.mu.Lock()
id, ok := q.popLocked() // 只碰下标和段,纯内存
q.mu.Unlock()
}
为什么这招比锁排序更靠谱?因为它不需要全局知识。锁排序要你回答「所有锁的全局顺序是什么」,锁外回调只要回答「这一段代码里,我手里握着谁」——这是局部知识,写这段代码的人自己就能确定,不需要跨模块协商。
更本质地说:锁外回调消掉的是死锁四个必要条件里的「持有并等待」。死锁的本质是「一手握 A、一手等 B」;锁外回调的纪律保证你:永远不会一手握 A 一手等 B——你要么在持锁做纯内存,要么在锁外做任何事。持有并等待不成立,循环等待自然无从谈起。
代价也必须摆上桌:check 和 act 不再原子了。 一旦把 GetItem(check)和 RemoveItem(act)挪到锁外,两步之间就有了窗口——判定「未完成」和「执行删除」之间,一桩绑定操作可能恰好插进来,把一条已经绑定、流程正在跑的记录删掉。这个 TOCTOU 缺口,正是 29-2 里隐患一要补的:清理路径改成条件删除,把复查放回分片锁的临界区内,让「判定 + 删除」重新原子化。
所以 29-3 的取舍链是:用死锁安全换掉跨锁原子性,再把丢掉的原子性在分片锁这一层单独补回来。 拆掉一把锁的嵌套,用另一把锁的临界区收口——代价被转移,而不是凭空消失。
一条可执行的经验判据:临界区里出现一个你一眼看不到实现的函数调用,就把它标红——它就是未来的那条死锁边。 回调、接口方法、deferred 里跑到别人的包,全在这一类。
③ TryLock + 回退释放:拿不到就整体放手
有些时候回调埋在第三方库里,重构不动;这时候还有兜底招——try-lock 回退:加锁用 TryLock,拿不到就把手里已持有的锁全部释放,退回去重试。
for {
shard.mu.Lock()
if q.mu.TryLock() {
// 两把都到手,干活——顺序是 shard 先、q 后,和别人相反也不怕,
// 因为拿不到就整体放手,绝不「持有并等待」
doWork()
q.mu.Unlock()
shard.mu.Unlock()
return
}
// 拿不到 q.mu:先把手里的 shard 放了,退出等待,回头重来
shard.mu.Unlock()
runtime.Gosched()
}
它为什么能破环?因为 ABBA 成环的前提是「持有并等待」——我抱着 shard 死等 q。而 try-lock 回退的策略是要么全都要到,要么一个都不要:
stateDiagram-v2
direction LR
[*] --> 尝试: 先 Lock 再 TryLock
尝试 --> 成功: 两把都拿到 → 干活
尝试 --> 退让: TryLock 失败 → 释放已持有的锁
退让 --> 尝试: 让出 CPU 后重试
成功 --> [*] 代价同样实打实:忙等和活锁风险。两个 goroutine 可能反复同时退让、同时重试,谁也拿不齐;公平性靠 runtime.Gosched() 和退避来缓解,但缓解不等于消除。所以 try-lock 只适合低频、竞争可控的路径——配置热更新、优雅关闭、后台修复任务这类「偶尔跑一次」的地方;绝不能进热路径,每次 Push 都忙等一次,性能账立刻翻车。
顺带说一句:sync.Mutex.TryLock 是 Go 1.18 才有的,老代码里要靠 atomic 手搓。
④ Go 的 Mutex 不可重入:另一个方向的死锁
前面三招防的都是两个 goroutine、两把锁的 ABBA。还有一类更隐蔽的:同一个 goroutine、同一把锁,自己把自己锁死。这个是 Go 特有的坑,因为 sync.Mutex 不可重入。
有人会想:既然 pending 的读需要保护,那我把它塞进 q.mu 一起管不就完了?——于是 Cleanup 持着 mu,内部又调 Pop,而 Pop 第一行就是 q.mu.Lock():
func (q *DelayQueue) Cleanup() {
q.mu.Lock()
defer q.mu.Unlock()
// ...
q.Pop() // Pop 内部会 q.mu.Lock() —— 同一个 goroutine 第二次拿同一把锁
}
func (q *DelayQueue) Pop() (string, bool) {
q.mu.Lock() // 永远阻塞:锁被「自己」持有,而且是同一个人
defer q.mu.Unlock()
// ...
}
sync.Mutex 不记录持有者,它不知道「锁是我自己拿的」,只知道「有人拿着」。所以同 goroutine 二次 Lock 会直接 park 等自己放手——没有报错、没有超时、不 panic,比 ABBA 更难查,因为栈里两个 goroutine 都没有,只有一个卡在自己身上的调用链。
为什么 Go 不提供可重入锁?一是成本:可重入要额外记录持有者 + 重入计数;二是设计取向——可重入会掩盖「临界区边界到底在哪」这个问题,让一个本该被拆开的、跨了好几个方法的大临界区看起来「能跑」。Rust 用类型系统在编译期直接禁掉二次借用,Go 只给你运行时的静默停摆。
通用解法是拆 locked / unlocked 变体——公开方法负责加锁,内部复用无锁版:
func (q *DelayQueue) Pop() (string, bool) {
q.mu.Lock()
defer q.mu.Unlock()
return q.popLocked() // 无锁实现
}
// Cleanup 若必须持锁,就调这个,不再拿锁
func (q *DelayQueue) popLocked() (string, bool) {
// 只碰下标和段
}
29-3 最终的选择更彻底:Cleanup 全程不持锁,pending 已经无锁化,连 popLocked 都不用单独暴露。但「拆变体」这个手法值得单独记住——它是所有重入死锁的通用解。
⑤ 无锁化:让锁从关键路径上消失
到这一层,思路已经反过来了:与其想「怎么把锁拿对」,不如想「这个状态能不能不要锁」。
pending(当前持有的那个元素)就是这种状态:它很小(一个 ID + 一个过期时间)、读多写少、而且被 Cleanup 在不持任何锁的情况下读——因为②那条纪律已经说了,Cleanup 不许持 mu。
第一反应是「那就各字段原子化」:
id := atomic.LoadPointer(&q.pendingID)
// ↑ 这一刻读到的是新 ID
ea := atomic.LoadInt64(&q.pendingExpireAt)
// ↑ 但这一刻 expireAt 可能已经被换成别的值了
// → 新 ID 配旧过期时刻,清理决策直接算错
问题出在撕裂读:两个独立的原子量,各读各的,读的时刻可以交错。你在 Load id 的那一瞬间和 Load expireAt 的那一瞬间,拿到的是两个不同版本的组合。加锁当然能解,但这会撞上④的重入问题——Cleanup 要么持锁调 Pop(重入死锁),要么就是两个使用点都要操心两把锁的顺序。
更漂亮的做法是改数据的形状,而不是加锁:
// 该结构一旦写入便不再修改,通过 atomic.Pointer 整体替换,
// 保证 ID 与过期时间始终成对读取,不会出现撕裂读。
type pendingItem struct {
id string
expireAt time.Time
}
// 写入:整体替换
q.pending.Store(&pendingItem{id: id, expireAt: ea})
// 读取:拿到的永远是成对一致的快照
p := q.pending.Load()
它把「两个需要成对原子读写的量」变成了「一个不可变的量」——原子性问题被降维成了形状问题。这就是 Go 并发里的经典模式 immutable after publication:发布后不可变,读的人不需要任何进一步同步,因为他读到的永远是一个冻住的快照。
这不是「用原子换锁」的等价替换,而是问题本身被改写了。代价有三:每次更新都要分配一个新对象(所以只适合这种低频、小状态的路径);结构必须真的不可变(发布之后再改它的字段,等于绕过 atomic 制造数据竞争);它只解决「小状态成对一致」,状态一大、关系一复杂,还是得回到锁。atomic.Value 存接口值的同类坑(首存定型、panic)在 atomic.Pointer[T] 上天然免疫,细节在《atomic.Value 存接口为什么会 panic》里。
五、复盘:那条连锁约束链
五招摆在桌上,回头看 29-3 的那条链,每一环都是被上一环逼出来的:
锁外回调 → Cleanup 全程不能持锁 → pending 必须无锁 → 两个原子量会撕裂读 → 不可变
pendingItem+atomic.Pointer整体替换
- 锁外回调 ⇒
Cleanup不能持q.mu。Cleanup里要调store.GetItem/RemoveItem(会拿分片锁)。若Cleanup持着q.mu再进回调,「队列锁 → 分片锁」这条边就回来了——直接回到第三节的死锁。所以回调期间,Cleanup手里绝不能有q.mu。 Cleanup不能持锁 ⇒pending必须无锁可读。 既然不持q.mu,读pending就没有任何锁护着。它必须能无锁地拿到一个一致的快照。若pending还是初版那种裸pendingID/pendingExpireAt字段,第一段的竞态就原样复现:Reset能把它清空、Cleanup的 check-then-act 能删错 ID。- 无锁 ⇒ 不能是两个独立原子量。 各字段原子化的第一条路就撞上撕裂读,见⑤。
- 要成对读 ⇒ 改数据结构。 两字段合成一个不可变结构体,整体替换。
再对照那三段演进,就更能看清「约束是逐层显影」这句话:
- 第一段只看到裸字段的竞态——局部症状,就事论事地加保护。
- 第二段用
stateMu硬保——看到了「要保护」,却没看到「这里为什么不能用锁」。它给Cleanup加了一把锁,而Cleanup内部还要调Pop(要mu),于是三个使用点都要操心两把锁的获取顺序,锁的舞蹈跳了起来。正确,但别扭——别扭正是约束没吃透的信号。 - 第三段才把整条链走完——从「不许嵌套」一路传导到「数据必须是不可变的」。中间态
stateMu没落库,某种意义上反而是好事。
「回调在锁外」不是一条孤立的纪律,它是整条链的第一张骨牌。 抽掉任何一环,后面的都站不住。
六、小结:一张按场景取用的速查表
| 你的处境 | 用哪招 | 代价 / 注意 |
|---|---|---|
| 几把锁都在自己模块内,能约定顺序 | ① 锁排序 | 需要全局知识;跨接口/跨模块即失效 |
锁跨越接口或回调边界(如 store) | ② 锁外回调 | check 与 act 不再原子 → TOCTOU,需条件删除或乱序容忍 |
| 回调埋在第三方、改不动,且路径低频 | ③ TryLock + 回退释放 | 忙等 / 活锁;别进热路径 |
| 持锁后又调用了会再拿同一把锁的方法 | ④ 拆 locked / unlocked 变体 | Go 无重入,二次 Lock 静默停摆 |
| 共享状态小、读多写少、需成对一致 | ⑤ atomic + 不可变快照 | 每次更新一次分配;结构必须真不可变 |
取用顺序,我一般这么走:
flowchart LR
A["有共享状态"] --> B{"能不能不加锁?"}
B -- 能 --> E["⑤ atomic + 不可变"]
B -- 不能 --> C{"临界区里有没有回调?"}
C -- 有 --> F["② 挪到锁外"]
C -- 没有 --> D{"锁序能全局约定吗?"}
D -- 能 --> G["① 锁排序"]
D -- 不能 / 改不动 --> H["③ TryLock 回退"] 先问「能不能不要锁」,再问「能不能不嵌套」,再问「能不能定序」,最后才用 try-lock 兜底。 顺序是从「改形状」到「改纪律」再到「改策略」,代价单调上升。
注意事项
- ⚠️ 临界区里出现看不到实现的调用,就标红:回调、接口方法、跨包的 deferred,都是潜在的死锁边
- ⚠️ 锁外回调有账要还:check 与 act 拆开后,必须在一把锁内(这里是分片锁)把复查补回来,否则 TOCTOU 就是下一个隐患
- ⚠️
sync.Mutex不可重入不是 bug 是取向:它逼你显式画出临界区边界;被逼着拆变体,通常比可重入锁更干净 - ⚠️ TryLock 只救低频路径:热路径上的忙等会把「防死锁」换成「性能塌方」
- ✅ 锁排序的前提是「锁都在自己手里」:一旦锁跨越抽象边界,就转投锁外回调——它只要局部知识
- ✅ 改数据的形状,常常比加一把锁更省:把「两个量需要成对原子」改写成「一个不可变量」,问题直接消失
回望
上一篇把「不用堆」的账算到底,这一篇把「不用锁嵌套」的账算到底,其实是同一套方法论在起作用:形态的改善,来自约束链被走完,而不是更多的锁。 死锁预防的道理最后收敛成一句话——
让「持有并等待」不成立,比让「顺序全对」更容易做到、更难写错。
顺着这条线还能往两头读:分片锁的来历、分片数怎么定、双向索引怎么防死锁,在《从全量遍历到 O(1)》;同一天另一篇《atomic.Value 存接口为什么会 panic》拆的是同一套 SDK 里 atomic 的类型陷阱;队列内存结构怎么长成 [][]string,见《队列的内存演进史》;而「锁外读到的一定是最新值吗」这条内存模型主线,在《写了不等于看得见》里连着读。
最后更新:2026-10-01