Go 并发编程:锁与同步的 15 个反模式及正确做法
并发 bug 是所有 bug 中最难复现、最难定位的一类。它们往往在代码 review 时看不出来、单测跑不出来,直到生产环境高并发场景才爆发。
本文按「反模式 → 正确模式」成对罗列,结合项目中真实处理过的案例来说明——每一条都是踩过坑或差点踩坑的教训。
一句话总结(先记住这个)
锁的核心准则就一条: 临界区越短越好,里面只碰内存、不碰世界 (不做 I/O、不阻塞、不回调外部、不嵌套加锁)。Channel 侧的准则是: 退出用 close 广播、关闭要幂等、谁生产谁关闭 。Goroutine 侧: 每个都要有退出信号,ctx 要级联 。
一、临界区相关
1. 持锁做阻塞操作
| 说明 |
| 反模式 | 锁内做 I/O、网络调用、Recv()、time.Sleep、channel 收发 |
| 正确模式 | 锁只保护 共享内存的读写 (极短临界区),阻塞操作移到锁外 |
| 项目案例 | 原 Stop() 持 cc.mu 做 pool.Close()(gRPC 网络)→ 已改为锁外清理 |
2. 持锁回调外部代码
| 说明 |
| 反模式 | 持锁时调用「调用方传入的函数 / 回调 / 接口方法」——你不知道它内部会不会反向拿你的锁 |
| 正确模式 | 复制需要的数据, 释放锁后再回调 |
| 风险 | 回调里又调你的 Lock() → 自死锁(Go 的 sync.Mutex 不可重入) |
3. 锁粒度过大
| 说明 |
| 反模式 | 一把大锁保护所有字段,高频读也要排队 |
| 正确模式 | 拆分锁(按字段分组)、或读写锁 sync.RWMutex(读多写少)、或 sync/atomic(单值) |
二、加锁顺序相关
4. 锁顺序不一致(经典 AB-BA 死锁)
| 说明 |
| 反模式 | 线程1 Lock(A)→Lock(B),线程2 Lock(B)→Lock(A) → 互相等 |
| 正确模式 | 全局统一加锁顺序 (如按地址/ID 排序后依次加) |
5. 嵌套/重入加锁
| 说明 |
| 反模式 | 持锁方法 A 调用同样要加同一把锁的方法 B |
| 正确模式 | 拆出「不加锁的内部版」 xxxLocked() 供持锁路径调用,公开方法负责加锁 |
三、Channel / 关闭相关
6. 用「发送」表达「广播退出」
| 说明 |
| 反模式 | stopC <- struct{}{} 通知 N 个消费者——只送达 1 个,且消费者已退出时发送方阻塞 |
| 正确模式 | close(stopC)—— 广播语义 ,所有监听者同时收到,不依赖消费者存活 |
| 项目案例 | Close()/Stop() 已从发送语义改为 close(stopC) |
7. 关闭操作无幂等保护
| 说明 |
| 反模式 | close(ch) 不判断是否已关 → 重复 close panic |
| 正确模式 | sync.Once / mu + 标志位 / select-default 检测 |
| 项目案例 | Stop() 用 mu + select 检测 保证只 close 一次 |
8. 向已关闭的 channel 发送
| 说明 |
| 反模式 | 生产者不知道 channel 已关,继续 ch <- x → panic |
| 正确模式 | 关闭权归生产者;或用 running 标志 + 延时 守卫 |
9. 谁创建谁关闭原则被打破
| 说明 |
| 反模式 | 消费者去 close 生产者的 channel |
| 正确模式 | channel 由发送方关闭 ,接收方只读 |
四、Goroutine 生命周期相关
10. goroutine 泄漏(启动了没退出路径)
| 说明 |
| 反模式 | go func(){ for { <-ch } }() 没有退出分支,外部无法停止 |
| 正确模式 | 每个后台 goroutine 必须有明确退出信号(ctx.Done() / close(stopC)) |
| 项目案例 | 订阅协程补 case <-cc.stopC 退出分支 |
11. 不等 goroutine 真正退出就返回
| 说明 |
| 反模式 | 发了停止信号就 return,goroutine 可能还在跑 |
| 正确模式 | sync.WaitGroup.Wait() 等待真正退出(需要确定性收尾时) |
12. context 不级联 / 不传递
| 说明 |
| 反模式 | 内部 context.Background() 新建,外部 cancel 传不进来 → 子任务无法被取消 |
| 正确模式 | 从传入 ctx 派生 WithCancel(ctx),级联取消 |
| 项目案例 | actualSubscribe 已从 Background() 改为派生外部 ctx(顺手修了泄漏) |
五、同步原语误用
13. 双重检查锁定(DCL)不用 atomic
| 说明 |
| 反模式 | if x==nil { lock; if x==nil { x=new } },但 x 普通字段 → 有数据竞争 |
| 正确模式 | 用 sync.Once(Go 里单例初始化的标准答案) |
14. WaitGroup.Add 放进 goroutine 内部
| 说明 |
| 反模式 | go func(){ wg.Add(1); ... }() → Add 与 Wait 竞争 |
| 正确模式 | Add 在 启动 goroutine 之前 调用 |
15. 复制带锁的结构体
| 说明 |
| 反模式 | 值传递含 sync.Mutex 的 struct → 锁被复制,保护失效(go vet 会报) |
| 正确模式 | 用指针接收者/指针传递 |
死锁公式(贯穿上面多条)
持锁 L → 做阻塞操作 B → B 依赖外部资源 → 外部消失 → B 永不返回 → L 永不释放 → 全员卡死
项目中的两步改造正好拆了这条链的两环:
close(stopC) 广播替代发送 → 阻塞操作 B 不再依赖消费者存活 - 清理移出锁外 → 持锁 L 期间不再有阻塞操作 B
改造前:
Stop() { mu.Lock(); pool.Close(); close(stopC); mu.Unlock() }
↑ 阻塞操作在锁内,可能永不返回
改造后:
Stop() { mu.Lock(); close(stopC); mu.Unlock(); pool.Close() }
↑ 阻塞操作在锁外,不影响其他人拿锁
速查表
| 场景 | 准则 |
| 锁内能做什么 | 只碰内存,不碰世界(不 I/O、不阻塞、不回调) |
| 多把锁怎么加 | 全局统一顺序,或只用一把 |
| 退出信号怎么发 | close(ch) 广播,不发送 |
| 关闭操作怎么保护 | sync.Once 或 mu + 标志位 |
| channel 谁关 | 发送方关,接收方只读 |
| goroutine 怎么停 | 必须有 ctx.Done() 或 stopC 退出分支 |
| goroutine 怎么等 | sync.WaitGroup,Add 在 go 之前 |
| context 怎么传 | 从外部派生,不用 Background() |
| 单例怎么初始化 | sync.Once,不用 DCL |
| 带锁 struct 怎么传 | 指针,不值传递 |
自检清单
对照你的模块做一次排查:
如果以上任何一条答案是"有问题"——恭喜,你可能刚阻止了一个生产事故。