跳转至

Go 并发编程:锁与同步的 15 个反模式及正确做法

并发 bug 是所有 bug 中最难复现、最难定位的一类。它们往往在代码 review 时看不出来、单测跑不出来,直到生产环境高并发场景才爆发。

本文按「反模式 → 正确模式」成对罗列,结合项目中真实处理过的案例来说明——每一条都是踩过坑或差点踩坑的教训。

一句话总结(先记住这个)

锁的核心准则就一条: 临界区越短越好,里面只碰内存、不碰世界 (不做 I/O、不阻塞、不回调外部、不嵌套加锁)。Channel 侧的准则是: 退出用 close 广播、关闭要幂等、谁生产谁关闭 。Goroutine 侧: 每个都要有退出信号,ctx 要级联


一、临界区相关

1. 持锁做阻塞操作

说明
反模式 锁内做 I/O、网络调用、Recv()time.Sleep、channel 收发
正确模式 锁只保护 共享内存的读写 (极短临界区),阻塞操作移到锁外
项目案例 Stop()cc.mupool.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 永不释放 → 全员卡死

项目中的两步改造正好拆了这条链的两环:

  1. close(stopC) 广播替代发送 → 阻塞操作 B 不再依赖消费者存活
  2. 清理移出锁外 → 持锁 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.Oncemu + 标志位
channel 谁关 发送方关,接收方只读
goroutine 怎么停 必须有 ctx.Done()stopC 退出分支
goroutine 怎么等 sync.WaitGroup,Add 在 go 之前
context 怎么传 从外部派生,不用 Background()
单例怎么初始化 sync.Once,不用 DCL
带锁 struct 怎么传 指针,不值传递

自检清单

对照你的模块做一次排查:

  • 所有 mu.Lock()mu.Unlock() 之间,有没有 I/O / 网络 / channel 操作?
  • 有没有持锁调用外部接口方法(可能反向加锁)?
  • 退出信号是 close(ch) 还是 ch <- signal?后者只能通知 1 个
  • close(ch) 有没有幂等保护?
  • 每个 go func() 是否都有退出分支?
  • context 是从外部派生还是新建 Background()
  • WaitGroup.Add() 是否在 go 语句之前?
  • 有没有值传递含 sync.Mutex 的结构体?

如果以上任何一条答案是"有问题"——恭喜,你可能刚阻止了一个生产事故。

评论