你的 goroutine 明明很轻,为什么服务还是被压垮了?

2026-10-25 10 0

上周有个读者私信我,说他一个 Go 服务,QPS 才两百,机器八核十六G,内存飘红,CPU 却不上去,GC 跑得欢快得像在蹦迪。他说:「我 goroutine 又不多啊,每秒就几千个。」

我回他:你确定每秒几千个 还活着的 goroutine?

这就是 Go 后端最经典的新手幻觉:goroutine 创建成本几乎为零,所以随便创建;反正它自己会结束。然后生产环境里,goroutine 安静地堆积成了山,栈内存一格一格往上爬,最后你看着 pprof 里那一片密密麻麻的 goroutine 状态图,开始怀疑人生。

这期咱们把 Go 并发里那些「文档里写了但没人看」的细节掰开揉碎。放心,不灌鸡汤,只讲实测会翻车的地方。


一、先撕掉一个误区:goroutine 不是线程,但也不是空气

很多人说「goroutine 比线程轻,所以不要用线程池」——这话对了一半,也错了一半。

轻是真的轻。创建一个 goroutine 大概几百纳秒,初始栈只有 2KB。而一个 OS 线程默认 8MB 栈,还得付内核调度和上下文切换的代价。这两个数字放一起比,好像 goroutine 就是不要钱的。

但问题在于:便宜的东西你不会省。

线程池存在的唯一理由是「创建贵、复用划算」。当创建成本降到几百纳秒,你就失去了节流的动机。于是代码里自然长成这样:

for _, item := range items {
    go handle(item)   // 一次 10 万条,瞬间 10 万 goroutine
}

而每个 goroutine 背后可能是:一次 HTTP 调用、一次数据库查询、一次 TLS 握手。CPU 看着不高?因为它们全卡在网络 I/O 上等着呢。内存飘红?因为 10 万个栈 + 10 万个 socket buffer + 10 万个等待中的连接对象。

所以我的观点很直接:goroutine 便宜,但一次远程调用的成本和你用什么并发原语无关。你的瓶颈在下游服务,不在 goroutine 本身。真正该限流的是「同时打向下游的请求数」,不是「goroutine 数量」——虽然这两个数字往往线性相关,所以限流 goroutine 是个粗糙但有效的近似。

很多团队做限流只盯着入口 QPS,却忘了出口并发。入口 500 QPS,每个请求打 3 个下游,其中一个下游平均 200ms——你算算,这个服务同时压着多少并发在跑?


二、GOMAXPROCS 与容器:最容易翻车又最难查的一个坑

经典事故:本地压测 16 核,goroutine 调度丝滑,P99 漂亮得像教科书。上线到 8 核机器上,性能断崖式下跌,火焰图里全是 runtime.futex 和 runtime.morestack。

原因分两层。

第一层:Go 1.25 之前的容器 CPU 感知问题

老版本 Go 读的是 runtime.NumCPU(),在容器里它看到的是宿主机的核数,不是 cgroup 限制。于是你给了容器 2 核,Go 起了 64 个 P,Go 运行时以为自己有 64 个 CPU 可以用,疯狂预取、疯狂做后台 GC 辅助标记,内存和调度开销全白烧。

Go 1.25 起这个问题基本被解决了——运行时会读取 cgroup 的 CPU 限额。但你要确认你的 Go 版本真的够新,很多生产镜像还是 go1.21 打出来的,躺着中枪。

第二层:GOMAXPROCS 显式设置依然值得

在容器里我几乎总会显式设置:

# 你的服务实际分到几个核就设几,不要盲目等于核数
ENV GOMAXPROCS=2

# 关闭异步抢占,可以减少一部分调度抖动(Go 1.14+ 默认开启,一般不需要动)
# ENV GODEBUG=asyncpreemptoff=1

为什么要设?因为 Go 的调度器有一个「多核存在时优先保证 50% CPU 给系统调用」的设计。当你的服务 80% 时间阻塞在网络 I/O 上,M 会大量被 netpoller 唤醒去抢 P,这种抢占很凶,会造成 P99 抖动。调低 GOMAXPROCS 会牺牲一点峰值吞吐,换来尾延迟更稳。

具体调多少?没有银弹。我的经验公式是:如果 I/O 密集,把 GOMAXPROCS 设成 limit+1 左右试试;如果 CPU 密集,设成 limit 就好。然后用压测数据说话,别信直觉。


三、channel 不是银弹,select 也不是

Go 社区一直有种「channel 是 Go 的灵魂」的宗教氛围,于是你会在代码里看到这样的东西:

// 看起来很优雅,其实可能更慢
ch := make(chan int, 100)
for i := 0; i < 1000; i++ {
    ch <- i
}
close(ch)
for v := range ch {
    process(v)
}

问题在哪?如果只有一个 goroutine 在消费,这个 buffer channel 白白引入了两次拷贝(一次进 channel buffer,一次出 buffer)。在高频路径上,编译器能优化掉一部分,但一旦 channel 逃逸或者跨 goroutine,就变成实打实的内存搬运。

我的经验判断很简单:

  • 纯 CPU 密集、只在一个 goroutine 内部流转的中间数据 → 用 slice,别用 channel。
  • 需要跨 goroutine 传递所有权 → channel。
  • 只是保护一份共享状态 → sync.Mutex,别用 channel 模拟信号量。

说到 Mutex,这里必须提一个被低估的角色:sync.RWMutex 在读多写少场景确实好用,但它的写者饥饿问题(Go 1.18 前尤其明显)会带来长尾延迟。Go 1.18 之后 runtime 会给等待超过 1ms 的读者切换成写者优先策略,缓解了不少。但如果你的读锁临界区很短(纳秒级),RWMutex 相对 Mutex 的开销优势就基本被锁本身的成本吃掉了,换过去只是给自己找 bug。

另一个被严重低估的:sync.Pool。它用来复用对象,减少 GC 压力。但有两个坑:

var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

// 坑1:取出来的对象可能是 nil(Pool 被 GC 清理过)
b := bufPool.Get().(*bytes.Buffer)  // ✅ 实际用 New 保证非 nil

// 坑2:忘记 Put 就等于没用
b.Reset()
bufPool.Put(b)

最经典的翻车是「Put 之前没 Reset」,导致下一个使用者读到了脏数据。这个 bug 排查起来极其恶心,因为它和业务无关,只在特定路径出现。记住:Put 之前,状态必须是干净的。


四、context 取消传播:最安静的 goroutine 泄漏源

先说一个数据:我见过的 Go 服务 goroutine 泄漏,八成来自 context。

场景是这样的:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), time.Second)
    defer cancel()   // ✅ 正确示范
    doWork(ctx)
}

看起来没问题。但如果你把 ctx 传给下游,而下游在超时后没有继续往里传,或者某个 goroutine 里用了 context.Background() 而不是传进来的 ctx,取消信号就在某一层断了。断点之后的 goroutine 会一直跑,直到它自己超时或者永远。

更隐蔽的一种:把 ctx 存进结构体字段,但这个结构体被长期持有(比如放进一个全局注册表、放进缓存)。cancel 发生了,但因为引用还在,ctx 的 Done channel 不会被 GC,内存跟着一起泄漏。

排查方法其实很直接,看 pprof 的 goroutine profile,按状态聚类:

go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=1

如果看到成千上万个 goroutine 卡在同一个函数上,基本可以确诊是泄漏,而不是「业务正常在跑」。

经验法则:一个函数如果接收了 ctx 参数却在内部某处用了 context.Background(),基本可以判定为 bug。唯一合理的例外是明确要脱离调用方生命周期的场景(比如后台任务),但那种场景应该在函数名或注释里写明,否则下一个维护的人一定会踩。


五、一个可以直接抄的 goroutine 泄漏自查清单

线上出问题时,我会按这个顺序查:

  1. 看 goroutine 数量趋势。不涨=没问题;缓慢上涨=有慢性泄漏;阶梯式上涨=有定时任务没停。
  2. 看 pprof goroutine profile,按 stack 聚类。找到数量最多的那组,看它的阻塞点。
  3. 检查所有 goroutine 启动点是否有对应的退出路径。特别是 go func() 里调用的第三方 SDK——它内部的 goroutine 你控制不了,只能通过 ctx 取消或关闭连接。
  4. 检查 timer 和 ticker。time.After 在循环里用是最经典的泄漏元凶,它会等到超时才释放。循环里请用 time.NewTimer + Stop(),或者 context.WithTimeout。
  5. 检查 HTTP response body 有没有 Close。这是 Go 里最容易犯也最难发现的资源泄漏:不 Close 对应的连接会一直挂在连接池里,直到超时。
// ✅ 永远 defer Close,且要检查 error
resp, err := http.Get(url)
if err != nil {
    return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)  // 读完,否则连接无法复用

最后那行 io.Copy(io.Discard, resp.Body) 经常被省略。不读 body 直接 Close,Go 会在后台把剩下的数据丢弃并尝试复用连接,但如果你不读、连接又被复用……行为在 Go 版本间变过。稳妥起见,读干净再 Close。


六、写在最后

Go 的并发设计是语言里最优雅的部分之一,这点没得黑。但优雅的抽象会掩盖真实的成本,这是所有抽象的通病。

所以我现在的习惯是:任何一段用了 goroutine、channel、select、context 的代码,都要在脑子里过一遍「谁负责结束它、什么时候结束、结束后资源去哪了」这三个问题。

答不上来,就别写。答上来了,你的服务就比 90% 的 Go 服务更稳。

就这样。下一篇我会聊聊为什么你的 time.After 正在偷偷吃掉你的内存——如果你想看的话。

评论区聊聊:你手上那个项目,goroutine 数量峰值是多少?如果超过一万,建议现在就去看看 pprof。


本文为个人技术笔记,非公众号原创首发。看不懂的地方直接留言,别私信问,私信我不一定看得到(可能是消息太多被埋了,我会尽量看)。

相关文章

你的服务雪崩,不是因为流量大:超时与重试写成的死亡循环
别再用 REST 硬扛了:gRPC 与 Protobuf 的六个真实翻车现场
你的 Redis 分布式锁,可能压根没锁住:一个靠运气续命的并发原语
你的服务半夜自己崩了,但你什么都没改:TCP 长连接的生命周期管理
你的支付接口被扣了两次钱:一次幂等性设计翻车实录
数据库没慢,你的服务却卡死了:连接池里那把椅子,藏着 P99 崩塌的真凶

发布评论