你的 Go 服务在偷偷漏 goroutine:context 取消、channel 阻塞,以及那些看不见的泄漏

2026-11-05 8 0

你有没有遇到过这种情况:服务上线后内存曲线像股票 K 线一样只涨不跌,重启一下立刻回落到水线上;监控上的 goroutine 数量从几千慢慢爬到几十万;机器还没崩,但你的进程已经开始"喘"。

恭喜你,你大概率遇到了 Go 里的 goroutine 泄漏。

很多人第一次听到这个词,觉得是"并发写代码没加锁"那种玄学。但说实话,goroutine 泄漏跟锁没关系,它是 Go 最容易被忽视、也最难在线上排查的坑之一。今天我就把它拆开揉碎了讲,顺带把 context 这个被 90% 的人用成摆设的东西讲明白。


一、先说结论:goroutine 为什么会被"漏掉"

Go 的 runtime 里,每个 goroutine 初始只占 2KB 栈。听起来很便宜对吧?但它便宜的前提是它会结束。

goroutine 只有在两种情况下真正退出:

  • 函数 return,goroutine 正常结束;
  • runtime.Goexit()(比如 t.Fatal() 内部就是这个)。

如果一个 goroutine 卡在某个永远不返回的地方——比如一个没人关闭的 channel 读、一个写满的 channel 写、一个永远不会完成的 HTTP 请求、一个没人调用的 sync.WaitGroup.Wait()——它就会一直活着,占着栈、占着它引用的所有内存,而且永远不会被 GC 回收。goroutine 是 GC 的根,根活着,它抓着的整条引用链就都活着。

这就是为什么 goroutine 泄漏会连带造成内存泄漏。你以为漏的是一个 goroutine,实际漏的是它闭包里捕获的一整个对象图。有人在 handler 里把 *sql.DB 或者整个请求体捕获进去,那泄露的就不是 2KB 栈那么简单了。

二、四大经典泄漏姿势,逐个拆

姿势一:往一个没人消费的 channel 里写

func startConsumer() {
    ch := make(chan int)
    // 忘了 go func(){ for v := range ch { ... } }()
}

func handler() {
    ch := make(chan int) // 每次请求新建一个,无人接收
    ch <- 1
    // 永久阻塞在这里,goroutine 活着
}

这是最经典的。写阻塞在无缓冲 channel 上,goroutine 永远出不来。修法也很直接:

ch := make(chan int, 1)      // 给缓冲区
go func() { for v := range ch {} }()  // 保证有消费者
select {
case ch <- 1:
case <-ctx.Done():
    return
}

姿势二:往一个永远不会关闭的 channel 里写

// 生产者
for {
    select {
    case requests <- job:
    default:
        // 满了就丢弃
    }
}

这个还好。真正致命的是"发送方比接收方多"或者"接收方比发送方多"但关闭的责任没定清楚。Go 的规矩是:永远由发送方 close(ch),接收方只负责 range。如果你在多个 goroutine 里 close 同一个 channel,程序会 panic(close of closed channel);如果接收方先跑掉了,发送方就永远阻塞在 send 上。

更狠的坑:for range ch 依赖 close 才结束。有人图省事,在 channel 用完后不 close,于是消费方 goroutine 永远挂着。代码看起来"没问题",但进程里多了一把永远不落的锁。

姿势三:HTTP 请求没有超时

// 千万别这么写
resp, err := http.Get("http://slow-service/api")

http.Get 用的是 http.DefaultClient,没有超时。如果对端服务挂住(不是拒绝连接,是接受连接后不返回),你的 goroutine 就跟着它一起挂住。流量一上来,几千个 goroutine 就这么堆在那儿了。

正确姿势永远是显式 timeout + 显式 context:

ctx, cancel := context.WithTimeout(parentCtx, 3*time.Second)
defer cancel()  // 忘了这行 = 泄漏

req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)

注意那句 defer cancel()。context.WithTimeout 会在超时定时器触发时自动 cancel,但如果提前 return(比如请求成功了就直接返回),定时器还在那儿挂着,等着那个时间点才释放资源。在高 QPS 下,这就是"看不见的定时器堆积"。所以官方文档第一行就写着:Always call cancel。

姿势四:WaitGroup 的 Add 和 Done 不配对

var wg sync.WaitGroup
for _, item := range items {
    wg.Add(1)
    go func() {
        defer wg.Done()
        process(item)
    }()
}
wg.Wait()  // 如果某个 goroutine 提前 return 忘了 Done,这里就永远卡住

还有一个更隐蔽的 Go 版本坑:在 wg.Wait() 之后调用 wg.Add()。如果 Add 发生在 Wait 已经把计数器归零之后,计数器会"复活",而 Wait 已经返回了,后续的 Done 反而会让计数器变负数,触发 panic。这事儿的诡异程度,见过的人都懂。


三、context 到底该怎么用

现在重点来了。context.Context 在 Go 1.7 进入标准库,本意是"跨调用边界的取消信号 + 截止时间 + 请求域数据"。但在实际项目里,它经常被写成下面这样:

// 反面教材:context 只用来传了个 ID
ctx := context.WithValue(ctx, "userID", userID)

然后调用链里压根没人检查 ctx.Done()。这样一来,客户端断开了、请求超时了,服务端还在闷头干活,把结果写进一个没人读的地方。

正确的用法有三条铁律:

一、ctx 是第一个参数,且不存进结构体。

func Query(ctx context.Context, id int64) (*User, error)

存进结构体意味着你之后没法换、没法组合、没法统一取消。Google 的官方 code review 指南明确写着:Do not store Contexts inside a struct type。原因很简单——context 是请求域的,一旦你的结构体活过请求,这个 context 就会成为悬挂引用。

二、取消信号必须往下传。

你在 handler 里接了 r.Context(),就必须把它传给你的 DB 查询、HTTP 调用、消息消费,而不是在中间层"造一个新的"。派生用 WithCancel / WithTimeout,永远从父 context 派生:

func Handle(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    // 这个请求取消时,下面所有下游都会被取消
    result, err := service.Query(ctx, id)
}

三、Values 用自定义类型,别用 string 键。

type ctxKey string
const UserIDKey ctxKey = "user_id"

ctx = context.WithValue(ctx, UserIDKey, userID)

用裸 string 键的唯一好处是"方便",唯一坏处是"会撞车"。定义 type ctxKey struct{} 是更好的做法,因为这种零大小类型永远不会和别人的键相等。

顺便说一句:context.WithValue 应该只装请求域的元数据(trace ID、用户身份、租户 ID),不要往里塞业务参数。你如果开始用 context 传 orderId,那不如直接加个函数参数——参数会出现在函数签名里,而 context 里的东西会静悄悄地消失。

四、怎么在生产环境把它抓出来

光靠读代码不够,得有工具。Go 自带的东西已经够用了:

// 1. pprof 看 goroutine 快照
curl http://localhost:6060/debug/pprof/goroutine?debug=2

// 2. 把它接到 Prometheus 上
import "github.com/prometheus/client_golang/prometheus/promhttp"
_ = promhttp.Handler()

我推荐的组合是:

  • goroutine 数量监控:超过基线(比如启动时的 10 倍)就告警。这是最廉价也最有效的泄漏探针。
  • 周期性 goroutine profile diff:定期抓两份快照,做差集,看哪些 goroutine 在"只增不减"。这比看总数精准得多。
  • 代码里加栈采样:在可疑的 goroutine 入口塞个标记,泄漏时直接从 profile 里看出是谁的锅。

我用过一个土但极其好用的技巧:给每个后台 goroutine 挂一个唯一的名字,泄漏了直接搜名字,一目了然:

func goWithName(name string, fn func()) {
    go func() {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("[%s] panic: %v", name, err)
            }
        }()
        fn()
    }()
}

goWithName("sync-user-cache", func() { ... })

五、我的几条实战判断

写了这么多,最后给几条带观点的总结:

第一,goroutine 不是线程,别拿"线程很贵所以要复用"那套思路。Go 的 goroutine 便宜,但"每个请求起一个 goroutine"这种写法在有阻塞点的地方依然是隐患。便宜的 goroutine 加上永不释放的阻塞点,等于慢性自杀。

第二,defer cancel() 这行代码的性价比高到离谱。一行代码,消灭一整类泄漏。我见过至少三个项目的 context 泄漏事故,根因全是忘了这行。代码审查时看到 context.With 没跟 cancel,直接当 bug 打回。

第三,任何可能阻塞的第三方调用,默认超时。不要相信"对方服务很快"。MySQL 有 wait_timeout,Redis 有 timeout,HTTP 有 Client.Timeout。没有超时的调用,就是一颗定时炸弹,炸的时候还炸在最不该炸的路径上。

第四,别用 time.Sleep 做重试退避,用 timer + select ctx.Done()。这样上游一取消,你的 goroutine 立刻退出,而不是在那儿老老实实睡满 30 秒。这在流量突增时是救命的。

好的并发代码,是让人看不出来有并发的代码。而 goroutine 泄漏恰恰相反:它会让你在半夜被一条告警短信叫醒,然后盯着一个 pprof 快照发呆四小时。

六、一句话收尾

goroutine 泄漏不是玄学,它是"本该结束的 goroutine 被某个阻塞点卡住了"。你只要记住三句话:

  1. 每个 context.With* 后面都要跟一个 defer cancel();
  2. 每个阻塞调用都要有超时;
  3. 每个 channel 都要想清楚谁 close、谁消费。

把这三条当成肌肉记忆写进你的 code review checklist,goroutine 泄漏这种"只涨不跌"的慢性病,基本就跟你绝缘了。

至于剩下的复杂场景——worker pool、优雅退出、context 树形编排——那就是另一个故事了。先把基础打好,别一上来就想着造一个花里胡哨的调度框架,绝大多数场景根本不需要。

代码写多了你会发现,绝大部分"高级架构"问题,最后都会退化成同一个问题:这个东西什么时候能结束?

相关文章

凌晨三点,所有副本同时醒来:定时任务惊群实战解剖
别再 read 一遍 write 一遍了:文件传输零拷贝,Linux 帮你省掉的那几个 GB
你的 API 一升级,老版本 App 就地瘫痪:接口向后兼容的六条军规
你的 3 秒超时,实际让用户等了 27 秒:重试放大、预算丢失与超时黑洞
HTTP/3 上线三个月后我才敢说:QUIC 快在哪、慢在哪,以及五个没人提前告诉你的坑
你的服务在裸奔还是在裸泳?服务网格 Istio 实战:四个没人提前告诉你的成本

发布评论