你的 Go 服务正在偷偷漏 goroutine:context 取消、channel 关闭与那些没人告诉你的并发真相

2026-11-12 10 0

Go 的并发好上手,这是共识。goroutine 加个 go 关键字,channel 一收一发,几十行代码就能撑起一个扛得住压测的服务。

但真正在生产环境跑久了,你会慢慢发现一个事实:写 Go 很简单,写对 Go 很难。难在哪?难在那些不会立刻报错、不会 panic、只会安静地堆积内存和文件句柄的慢性问题。等到 CPU 曲线像心电图一样抽搐,报警把你从被窝里叫起来,你面对的往往是一句轻描淡写的「内存涨了,重启一下就好了」。

这篇文章不聊语法糖,只聊三件真正会杀死服务的事:context 取消不彻底导致的 goroutine 泄漏、channel 关闭的所有权混乱、以及 sync.Map 被滥用。每一个都有真实的坑位。


一、goroutine 泄漏:最安静的那种自杀

先说一个反直觉的事实:Go 里 goroutine 泄漏不会有任何报错。没有 panic,没有日志,没有堆栈。它只是安静地占着内存不放手,直到你的服务 OOM。

泄漏的标准成因几乎只有一个:goroutine 阻塞在一个永远不返回的 channel 接收上。而让它永远不返回的原因,通常是上游的 context 取消了,但下游没人理会。

看这段代码,它看起来人畜无害:

func handler(ctx context.Context, q *Queue) {
    for {
        select {
        case <-ctx.Done():
            return
        case job := <-q.ch:
            process(job)
        }
    }
}

func run(ctx context.Context) {
    for i := 0; i <100; i++ {
        go worker(ctx, q)   // 100 个 worker
    }
    <-ctx.Done()
}

问题出在 process(job) 这一行。如果它内部又发起了一个 HTTP 请求,而这个请求没有被传入 ctx,那当外层 ctx 取消时,worker 仍然会卡在那个请求上,永远回不到 select。

更隐蔽的是:即使 worker 泄漏了,主协程依然返回了。你以为函数结束了,实际上那 100 个 goroutine 全都还挂在底层连接上,持有的连接池、定时器、内存,一个都没释放。

怎么防?我的经验是三条铁律:

第一,凡是能拿到 ctx 的地方,一律往下传。不接受"这个函数太底层了不用 ctx"这种说法——恰恰是底层最容易出事。

// ❌ 超时形同虚设
resp, _ := http.Get(url)

// ✅ 超时真的生效
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)

第二,goroutine 的生命周期必须和某个 signal 绑定。没有明确归属的 goroutine,就是一颗定时炸弹。

func (s *Service) Shutdown(ctx context.Context) {
    var wg sync.WaitGroup
    for _, w := range s.workers {
        wg.Add(1)
        go func(w *Worker) {
            defer wg.Done()
            w.loop(ctx)      // ctx 取消时 loop 必须自己退出
        }(w)
    }
    wg.Wait()
}

第三,用 pprof 定期看 goroutine 数量。别等出事才看。goroutine profile 里那些数量异常但栈顶相同的条目,就是泄漏点。

curl http://localhost:6060/debug/pprof/goroutine?debug=1

一个实用的小技巧:在测试里加一个 goroutine 数量断言,泄漏问题在 CI 阶段就暴露,而不是等运维半夜叫你。

func TestNoLeak(t *testing.T) {
    before := runtime.NumGoroutine()
    run(ctx)
    runtime.Gosched()
    time.Sleep(100 * time.Millisecond)
    after := runtime.NumGoroutine()
    if after > before+2 {
        t.Fatalf("goroutine leak: before=%d after=%d", before, after)
    }
}

二、channel 关闭:只有一个原则,剩下全是玄学

关于 channel 关闭,Go 官方推荐的口诀是:不要在多个 goroutine 里关闭同一个 channel,也不要向已关闭的 channel 发送。

但这句话没说清楚"那到底谁关"。于是每个团队都发明了自己的土办法:有的用 sync.Once,有的用一个 done channel 让消费者自己退出,有的干脆不关,让它和 goroutine 一起被 GC 回收。

我的做法是引入一个明确的所有权概念:

谁创建 channel,谁负责关闭;接收方只管接收,绝不关闭。

如果生产者可能有多个,或者需要提前终止,那就不该让生产者自己去关,而是引入一个独立的 context 作为唯一的终止信号源:

func producer(ctx context.Context, out chan<- Item) {
    defer close(out)          // 唯一关的人在唯一的生产者里
    for {
        select {
        case <-ctx.Done():
            return
        case out <- item:
        }
    }
}

func consumer(ctx context.Context, in <-chan Item) {
    for {
        select {
        case <-ctx.Done():
            return
        case item, ok := <-in:   // ok 才是判断通道关闭的正确方式
            if !ok {
                return
            }
            handle(item)
        }
    }
}

注意那个 ok。很多人在 select 里写 case item := <-in:,一旦 channel 被关闭,item 会拿到零值并立刻触发这个 case——于是你的消费者在空转,一秒执行几百万次,CPU 直接烧穿。这是我见过最经典的「一闭眼就 100% CPU」的 bug。

还有一个坑:向已关闭的 channel 发送会 panic。如果生产者比消费者慢,生产者发完数据 close 之后,还有别的生产者路径再发一次,就 boom。所以如果你有多个生产者,共同关闭是错的——要么用 sync.WaitGroup 计数后由一个协程统一关,要么就换成 context 信号加中间层。


三、sync.Map:不是「更快」,是「换了场景」

关于 sync.Map,网上流传最广的一句鬼话是「读多写少就用 sync.Map,比 mutex 快」。

这话对了一半,但它掩盖了一个更重要的区别:sync.Map 和 RWMutex 解决的不是同一个问题。

RWMutex 保护的是一个「明确的 map 值」。你永远知道 key 是什么、value 是什么类型,map 生命周期由你掌控。

sync.Map 保护的是「一个 key 集合本身可能会被整体替换」的场景。它的设计初衷是:key 的集合是只增不改的,value 可能被频繁覆盖。典型用途是缓存、路由表这类「读极多、几乎不新增 key」的场合。

一旦你用它来存「key 会频繁增删」的数据,就会掉进一个性能陷阱:

// ❌ key 会增删:每次新增都可能触发 dirty map 的整体晋升
var m sync.Map
for i := 0; i <10000; i++ {
    m.Store(fmt.Sprintf("key-%d", i), i)   // 一直走 dirty 路径
}

你会发现吞吐量明显不如一把 sync.RWMutex 加普通 map。原因很简单:sync.Map 的 Store 在 key 不在 read map 里时,会加锁写进 dirty map,而 dirty map 的晋升需要经过 misses 计数累积,读操作 miss 够多次才会触发一次全量拷贝。这个拷贝在 key 集合剧烈变化时会反复发生。

更重要的是 API 心智负担。sync.Map 没有 len、没有 range 的快照语义、没有 Delete 之后的原子视图。你要遍历就得用 Range,它不保证是一致快照;你要是需要「先遍历再批量删」,中间可能被其他 goroutine 改掉。

我的判断标准很简单:

  • key 集合基本不变、value 频繁覆盖 → sync.Map
  • key 会频繁增删、需要遍历、需要长度 → sync.RWMutex + 普通 map
  • 需要一致性快照或复合操作 → 别用任何 map 缓存,改用不可变数据结构 + 原子指针替换

最后这一条是很多人忽略的高阶解法:把 map 构造成不可变对象,写的时候复制一份新 map,然后用 atomic.Value 一把换掉整个指针。读路径完全无锁,永远看到一致快照,写路径慢一点但很少。对于路由表、配置这类读远多于写的数据,这个方案往往比前两个都优雅。


四、实战检查清单

把这些整理成一个可以贴到代码评审模板里的清单:

  1. 每一个 go 关键字旁边,都能指出它的终止条件。
  2. 每一个可能阻塞的调用(HTTP、DB、channel、锁),都能接收到 ctx 或有明确的超时。
  3. 每个 channel 都只有一个 owner 负责关闭,接收方只判断 ok。
  4. select 里的 channel 接收永远带 ok 判断,或者用带缓冲的 queue 配合 context。
  5. map 的选择有明确理由,而不是"听说 sync.Map 更快"。
  6. 监控里有 goroutine 数量、GC 停顿、连接池等待时长这几个指标。

最后补一句真心话:Go 的并发难,不是因为语言设计得差,恰恰是因为它给了你太多自由。没有魔法会替你兜底,所以你必须自己想清楚。而一旦你想清楚了,goroutine + channel 这套模型简单得令人发指——那种「写完就能睡个好觉」的踏实感,是别的手写锁语言给不了的。

代码是把架构写出来的,架构是把问题想明白之后的自然产物。并发也是如此:你对生命周期的理解有多深,你的 goroutine 就有多干净。

相关文章

你的定时任务跑了三遍,还把数据库打挂了:分布式调度里六个没人愿意告诉你的真相
为什么你的 Rust 团队,三个月后又写回了 Go?—— 所有权、生命周期与「编译期错误」的谎言
你的 SQL 为什么这么慢:一次把「索引自杀现场」挖了个底朝天
Pod 被 OOMKilled,代码里一行 OOM 都没有:Kubernetes 内存账本的四笔糊涂账
扩容一次,数据搬家一半:一致性哈希、虚拟节点,以及我踩过的那个三倍流量坑
「数据过千万就得分表」——这句话害了多少人?分库分表的四个坑与正解

发布评论