跳转至

Go 并发资源关闭类缺陷排查实战 —— 从一个 SDK 死锁案例说起

给谁看:Go 开发者、做并发模块/客户端 SDK 的同学 看完能干嘛:拿到一份可直接套用的排查清单,写出 3 次重复调用都不会死锁的 Close/Stop 方法

一、起因:一个被埋藏的死锁

长安链 SDK-Go 的连接池 ClientConnectionPool.Close() 在自动化测试中被发现:重复调用 3 次以上必然死锁

源码非常短:

func (pool *ClientConnectionPool) Close() error {
    pool.mut.Lock()
    defer pool.mut.Unlock()

    for _, c := range pool.connections {
        if c.conn == nil {
            continue
        }
        if err := c.conn.Close(); err != nil {
            pool.logger.Errorf("stop %s connection failed, %s", c.nodeAddr, err.Error())
            continue
        }
    }
    pool.stopped = true
    pool.stopC <- struct{}{}   // ← 元凶
    return nil
}

// stopC 的定义
stopC: make(chan struct{}, 1)   // buffer = 1

第一眼看代码很正常——发送停止信号、关闭连接,每一步都无可挑剔。但合在一起就是死锁。

二、死锁是怎么发生的

startOptimizeDetection 在后台跑一个 goroutine,select 监听 4 个分支:

select {
case <-firstOdC:
    if pool.stopped { return }     // 分支1:检测到 stopped → 退出
case <-ticker.C:
    if pool.stopped { return }     // 分支2:检测到 stopped → 退出
case <-ctx.Done():
    return                          // 分支3:外部 ctx 取消 → 退出
case <-pool.stopC:
    return                          // 分支4:stopC 收到信号 → 退出
}

关键观察:协程退出路径有 4 条,但 stopC 只有 1 条。一旦协程从其他 3 条退出,stopC 就再也没有消费者。

推演 3 次 Close

第 1 次 Close:
  stopC <- struct{}{}     ✅ buffer 0→1,发送成功
  协程消费 stopC,退出   buffer 1→0
  (此后 stopC 永远没有消费者)

第 2 次 Close:
  stopC <- struct{}{}     ✅ buffer 0→1(被 buffer 兜住)

第 3 次 Close:
  pool.mut.Lock()         ✅ 拿到锁
  stopC <- struct{}{}     ❌ buffer 已满 + 无消费者
                          → 永久阻塞
                          ⛔️ 持锁状态下死锁
                          ⛔️ 其他 getClient 调用全部阻塞

三、问题的本质:三件套同时存在

死锁三件套
═══════════════════════════════════════════
1. 持锁路径上做"可能阻塞"的事     ← pool.mut + stopC<-x
2. 用"发送信号"语义而非"广播"语义   ← ch<-x 依赖消费者存活
3. 方法本身没有幂等保护             ← 重复进入执行体
═══════════════════════════════════════════
任意两件事单独存在不足以致命,三件套同时出现 = 必然死锁

死锁公式

持有锁 L
  └─→ 阻塞操作 B
       └─→ B 的解除依赖 L 之外的外部资源 R
            └─→ R 因某种原因消失/未到位
                 └─→ B 永久阻塞 → L 永不释放 → 死锁向外扩散

本案例对应: - L = pool.mut - B = stopC <- struct{}{}(buffer 满后阻塞) - R = optimizeDetection goroutine(消费者) - 消失原因 = goroutine 已通过 stopped 检查或 ctx.Done() 退出

四、Channel 通知语义对照表(必记)

这是整篇文章最值得记下来的一张表:

操作 语义 适用场景 风险
ch <- x 单点送达 1 对 1 通知 buffer 满 / 无消费者 → 阻塞
close(ch) 广播 + 永久通知 1 对 N、生命周期结束 多次 close 会 panic(用 sync.Once 解决)
select default 非阻塞发送 不关心是否送达 信号可能丢
context.Cancel 树状广播 层级取消 需要传 ctx,侵入性大

口诀

关闭场景永远用 close,不用 <-

<-closedChan 永远立即返回零值,不依赖任何消费者存活,这正是关闭场景需要的语义。

五、修复范式(4 种通用范式)

范式 A:close + sync.Once(最推荐)

type Pool struct {
    stopC    chan struct{}
    stopOnce sync.Once
}

func (p *Pool) Close() {
    p.stopOnce.Do(func() {
        close(p.stopC)        // 广播,不依赖消费者
        /* ...其他清理... */
    })
}

优点:sync.Once 保证清理代码只执行一次,天然幂等;close 是广播语义,不会阻塞。

范式 B:标志位 + close

func (p *Pool) Close() error {
    p.mut.Lock()
    defer p.mut.Unlock()
    if p.stopped { return nil }   // 入口幂等检查
    p.stopped = true
    close(p.stopC)                 // 广播
    return nil
}

适用:已经有 stopped 字段的现有代码,最小化改动。

范式 C:带等待的优雅退出

type Worker struct {
    stopC chan struct{}
    wg    sync.WaitGroup
}

func (w *Worker) Stop() {
    close(w.stopC)
    w.wg.Wait()        // 等协程真正退出后再返回
}

适用:调用方关心"协程已退出"这个事实(比如要释放协程内的资源)。

范式 D:context 树(适合层级关系)

ctx, cancel := context.WithCancel(parent)
// 协程内: select { case <-ctx.Done(): return }

func (s *Svc) Stop() { s.cancel() }   // cancel 天然幂等

适用:层级化的服务架构,子服务自动随父级 cancel。

六、排查清单(10 条快速自检)

下次写或评审 Close/Stop 类方法时,过一遍这张表:

# 检查项 反模式信号
1 关闭/停止方法**入口**有无幂等检查? if stopped { return }
2 close(ch) 还是 ch <- x 通知退出? 用发送的,必踩坑
3 channel 是否带 buffer?发送方是否处于持锁路径? 持锁 + 无 buffer 或 buffer=1 = 高危
4 后台协程的 select 有几个退出分支? 分支越多,"消费者消失"概率越大
5 Close() 关闭资源后是否将其置 nil 或从容器移除? 重复 Close 同一资源会出错
6 业务层调用方是否可能多次调 Stop? 一定会发生(defer + 手动),按必然处理
7 顶层 Stop() 是否用 sync.Once 兜底? 没有就是裸奔
8 关闭操作有没有最大耗时上限? 没有就可能 hang 住业务
9 后台 goroutine 是否用 sync.WaitGroup 或反馈 channel 等待退出? 否则"已退出"是个谎言
10 是否有单测覆盖**重复 Stop**和**多线程并发 Stop**? 没有就等线上爆

七、单测最低标准(防止回归)

写完 Close/Stop 方法,至少要有这三个单测:

// 必备 1:3 次以上重复关闭不死锁
func TestClose_Idempotent(t *testing.T) {
    p := newPool()
    done := make(chan struct{})
    go func() {
        for i := 0; i < 5; i++ { _ = p.Close() }
        close(done)
    }()
    select {
    case <-done:
        // ✅ 正常完成
    case <-time.After(2 * time.Second):
        t.Fatal("Stop hangs, possibly deadlock")  // ❌ 死锁兜底
    }
}

// 必备 2:并发关闭无 race
func TestClose_Concurrent(t *testing.T) {
    p := newPool()
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() { defer wg.Done(); _ = p.Close() }()
    }
    wg.Wait()
}
// 配合 go test -race 运行

// 必备 3:goroutine 数回落
func TestStop_NoLeak(t *testing.T) {
    base := runtime.NumGoroutine()
    for i := 0; i < 10; i++ {
        c := newClient()
        c.Stop()
    }
    time.Sleep(100 * time.Millisecond)   // 等待协程实际退出
    require.LessOrEqual(t, runtime.NumGoroutine()-base, 2)
}

TestClose_Idempotent 必须**带超时兜底**,否则死锁的单测会让整个测试套件 hang 住,反而看不出问题。

八、共性触发条件回顾

缺陷类型 触发条件
死锁 锁 + 阻塞操作 + 依赖外部消失的条件
goroutine 泄漏 启动协程 + 退出依赖未送达的信号
重复关闭 panic 资源对象未置 nil + 无入口检查

九、一句话总结

任何 Close/Stop 方法,必须同时满足三个条件

  1. 幂等(多次调用安全)
  2. 广播退出信号(不依赖消费者存活)
  3. 不在持锁路径上做可能阻塞的操作

这三条记住了,能避开 Go 并发资源管理 90% 的坑。

十、扩展思考

写完这个总结,回过头看本案例其实有一个更深层的设计教训:

stopC 同时承担了两种角色——既是"通知协程退出"的信号,又被外部当成"通用停止开关"使用。这种**角色含混**会让 channel 的语义难以保持一致:协程内只读、外部只写、buffer 选择困难。

更干净的设计是:协程退出统一靠 close(stopC) 广播,永远不要在外部对 stopC 发送信号。

信号 channel 的最佳实践:只 close,不 send

评论