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 一把换掉整个指针。读路径完全无锁,永远看到一致快照,写路径慢一点但很少。对于路由表、配置这类读远多于写的数据,这个方案往往比前两个都优雅。
四、实战检查清单
把这些整理成一个可以贴到代码评审模板里的清单:
- 每一个
go关键字旁边,都能指出它的终止条件。 - 每一个可能阻塞的调用(HTTP、DB、channel、锁),都能接收到 ctx 或有明确的超时。
- 每个 channel 都只有一个 owner 负责关闭,接收方只判断
ok。 - select 里的 channel 接收永远带
ok判断,或者用带缓冲的 queue 配合 context。 - map 的选择有明确理由,而不是"听说 sync.Map 更快"。
- 监控里有 goroutine 数量、GC 停顿、连接池等待时长这几个指标。
最后补一句真心话:Go 的并发难,不是因为语言设计得差,恰恰是因为它给了你太多自由。没有魔法会替你兜底,所以你必须自己想清楚。而一旦你想清楚了,goroutine + channel 这套模型简单得令人发指——那种「写完就能睡个好觉」的踏实感,是别的手写锁语言给不了的。
代码是把架构写出来的,架构是把问题想明白之后的自然产物。并发也是如此:你对生命周期的理解有多深,你的 goroutine 就有多干净。