一次 goroutine 泄漏:pprof 说有 10 万个 goroutine,但问题不在 channel

goroutine 泄漏不是'忘记关 channel'——一次生产排查揭示了 HTTP 无超时、上游慢响应、无 context cancel 的三重组合根因,附最小复现代码与三层防护框架。

封面

pprof 的 goroutine profile 页面上,数字是 103,247。

我刷新了一下——103,891。还在涨。

这是一个跑了三年没出过事的服务。周五下班前一切正常,周一上班就收到内存告警。打开 Grafana,go_goroutines 指标画出了一条完美的斜线——从正常运行时的几百,涨到了十万量级,没有回落的迹象。

内存也在涨,但比 goroutine 数量慢。RSS 从平时的 200MB 爬到了 1.2GB,还在缓慢上升。按这个速度,再过几个小时就会触发 OOM Killer。

Grafana goroutine 数量趋势图

我的第一反应和你大概一样:goroutine 泄漏

但接下来的排查,让我重新理解了 goroutine 泄漏在生产环境中的真实面貌。

一、第一个怀疑:channel 没关?

如果你搜"Go goroutine 泄漏",前十个结果有八个会告诉你同一件事:

最常见的原因是 channel 没关闭,goroutine 在 <-ch 上永远阻塞。

合理。我也是这么想的。

打开代码库,全局搜 make(chan<-。这个服务不大,总共也就十几处用了 channel。逐个排查——每个 channel 都有对应的 close(),每个 select 都带着 case <-ctx.Done() 的退出分支。写代码的同事是个靠谱的人,channel 的生命周期管理没毛病。

不是 channel。

那 WaitGroup?全局搜 sync.WaitGroup,检查每个 Add 是否有配对的 Done。三处 WaitGroup 使用,全部配对正确。

也不是 WaitGroup。

我又看了 select 里有没有没带 default 的空 select{},有没有往 nil channel 发送的代码——这两个是教程里常提到的另外两种泄漏模式。扫了一遍,没有。

这两三种"常见原因"在 10 分钟内被全部否定了。我开始意识到一个问题:教程里讲的 goroutine 泄漏,和生产环境里真正致命的 goroutine 泄漏,可能不是一回事。

教程教你的是这种经典场景:

// 教科书式泄漏:channel 没关
ch := make(chan int)
go func() {
    val := <-ch // 永远阻塞,因为没人往 ch 发数据
    fmt.Println(val)
}()
// 忘记 close(ch) 或忘记往 ch 发数据
// 这个 goroutine 永远不会退出

这种泄漏容易写出来,也容易排查——搜 channel 就能找到。但生产环境里最危险的泄漏,往往不长这样。它们藏在更隐蔽的地方。

教科书泄漏 vs 生产泄漏

二、调用栈里的真相

排查 channel 和 WaitGroup 都无果,我决定换一个策略:不再猜原因,直接看调用栈。

Go 的 pprof 提供了一个非常强大的能力——导出所有 goroutine 的完整调用栈。当你有 10 万个 goroutine 时,你不需要逐个看。你要找的是大量重复的调用栈——如果上万个 goroutine 的调用栈完全相同,那个调用栈就是泄漏点。

curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | head -50

debug=1 模式会把相同调用栈的 goroutine 聚合显示,最前面是数量。输出的前几行就锁定了问题:

goroutine profile: total 103891
98237 @ 0x43e30e 0x44b6d2 0x6e2f10 0x6e1a82 ...
#	0x6e2f0f	net/http.(*Transport).roundTrip+0x...
#	0x6e1a81	net/http.(*Client).do+0x...
#	0x6e1d42	net/http.(*Client).Get+0x...
#	0x7a2e81	main.callUpstream+0x...

103,891 个 goroutine 总数。其中 98,237 个——95%——全部阻塞在同一个位置:net/http.(*Client).Get

不是 channel。不是 WaitGroup。是 HTTP 请求。

这些 goroutine 全都在等一个 HTTP 响应。响应迟迟没来,它们就这样挂着,谁也不回收,谁也不退出。每一次新的请求进来,如果上游恰好在慢,就多一个 goroutine 永远留在那里。

我翻到了出问题的代码。它简单得让人心凉:

func callUpstream(url string) (*http.Response, error) {
    return http.Get(url)  // 就这一行
}

http.Get 是个便利函数,底层用的是 http.DefaultClient

说一下 debug=1debug=2 的区别。debug=1 是聚合视图——相同调用栈的 goroutine 合并显示,前面是数量,适合快速定位"哪类 goroutine 最多"。debug=2 是全量展开——每个 goroutine 的完整调用栈单独列出,附带 goroutine ID、运行时长、当前状态(running/waiting/syscall)。当你需要看某个具体 goroutine 的 waiting 时长(“它已经等了多久?")时,用 debug=2

在这次排查中,debug=2 还告诉了我一个有意思的信息:那些阻塞的 goroutine,最老的已经等了 48 小时——说明泄漏从周六就开始了,只是当时流量低,泄漏速度慢,没触发告警。

三、DefaultClient 的沉默陷阱

Go 的 http.DefaultClient 有一个你可能知道但几乎可以确定没放在心上的事实:

// Go 标准库源码,net/http/client.go
var DefaultClient = &Client{}

Client{} 的零值——Timeout 字段为 0。在 Go 的 HTTP 实现中,0 不是"超时为零秒”,而是"没有超时"。

这意味着什么?如果上游服务在任何阶段卡住了——DNS 解析慢、TCP 握手超时、迟迟不发响应头、或者 body 传输中途网络丢包重传——你这边的 HTTP 请求会永远等下去。不报错,不超时,就这么安静地挂着。

更微妙的是,这个问题在正常情况下完全不可见。上游响应快的时候(比如 50ms),goroutine 来了就走,你根本感知不到有超时风险。只有当上游偶发变慢时——从 50ms 变成 30 秒甚至更久——这些 goroutine 才会堆积。

而"偶发变慢"在生产环境中是确定会发生的事。上游服务做 GC、数据库慢查询、网络抖动、流量高峰——任何一个原因都可能让某些请求的响应时间从毫秒级飙到秒级。

DefaultClient 零值陷阱

http.DefaultClient 没有超时,只是问题的三分之一。

四、三个坑叠在一起

让我把这次事故的因果链拆干净。

单独来看,三个条件中的任何一个都不会导致 goroutine 泄漏:

条件 单独存在时 为什么不泄漏
HTTP client 没设超时 上游永远 50ms 内响应 goroutine 来了就走
上游偶发慢响应 client 设了 5 秒超时 5 秒后请求放弃,goroutine 正常退出
没有 context cancel client 自己有超时兜底 超时机制保证 goroutine 最终退出

但三个条件同时存在时,就是灾难——两层防御同时缺失,偏偏遇上了触发条件。

上游服务偶发慢响应(从 50ms 变成 30s+)
HTTP client 没设 Timeout(默认 0 = 无超时)
goroutine 发起请求后进入无限等待
没有 context.WithTimeout 可以叫停这个等待
goroutine 永远挂着,无人回收
每次上游变慢,就多一批 goroutine 泄漏
goroutine 数量线性增长
fd 耗尽 → 新请求 "too many open files"
或:内存耗尽 → OOM Killer 杀进程

三重组合因果链

用三句话概括这个叠加效应:

  • 没有超时 → 无限等待成为可能
  • 上游变慢 → 无限等待成为现实
  • 而如果连 context cancel 都没有?这个等待就永远不会被终止。

三者缺一,问题都不会发生。但凑齐三个,goroutine 就会像滴水一样持续泄漏——你不会立刻注意到,但水费单迟早会来。

你可能会问:上游慢了,不应该修上游吗?当然应该。但上游是另一个团队的服务,你能控制的只有自己这端。而且"偶发慢"的根因可能是 GC、网络抖动、流量尖峰——你没法消灭它,只能防御它。

goroutine 泄漏像慢性病——它不是爆管让你立刻注意到的那种故障。它像水龙头滴水,一滴一滴地累积。等你发现的时候,水已经漫到地板上了。

你可能会想:10 万个 goroutine 本身就够让程序崩溃了吧?

其实不一定。Go goroutine 初始栈只有 2KB(Go 1.4+ 起固定为 2KB,运行时按需扩展)。一个阻塞在 HTTP 调用链上的 goroutine 实际栈占用通常在 4-8KB,10 万个约 400-800MB——对一台 8GB 内存的机器来说,还不至于 OOM。真正致命的不是 goroutine 本身,而是它背后占用的资源。

每个阻塞的 HTTP 请求都占着一个 TCP 连接和一个 file descriptor。我在出事的机器上查了一下 fd 使用情况:

ls /proc/$(pidof myservice)/fd | wc -l

将近 10 万个 fd,绝大部分是 TCP socket。而这台机器的 ulimit -n 设的是 100,000。也就是说,fd 马上就要耗尽了。

fd 耗尽后会发生什么?net.Dial 会返回 socket: too many open files——不仅你的 HTTP 请求发不出去,连新进来的客户端连接都无法接受。负载均衡器发现你的健康检查失败,把你从服务列表里踢出去。如果所有实例都在同时泄漏(因为上游是同一个慢服务),整个服务就彻底不可用了。

除了 fd,内存也在缓慢增长。粗略拆解:栈空间约 400-800MB,加上每个请求在堆上的 Request/Response/TLS 状态等对象(约 1-5KB/个,共 100-500MB),总计约 500-1.3GB——这也解释了为什么 Grafana 上的 RSS 从 200MB 涨到了 1.2GB。

五、眼见为实:复现它

排查清楚原因后,我做了一件事来验证结论:写一个最小复现。

不是为了"证明给别人看",而是为了确认自己的分析没有遗漏。排查过程中最危险的事不是"找不到原因",而是"找到了一个看起来合理但其实不完整的原因"。

慢 server 这边很简单——每个请求阻塞 30 秒:

http.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
    time.Sleep(30 * time.Second)
    w.Write([]byte("ok"))
})

client 端模拟问题代码——无超时、无 context:

client := &http.Client{}  // Timeout: 0,无超时,无 context
for i := 0; i < 1000; i++ {
    go func() {
        resp, err := client.Get("http://localhost:18080/slow")
        if err != nil { return }
        defer resp.Body.Close()
    }()
}

运行后持续监控 goroutine 数量:

初始 goroutine 数: 4
[100 请求已发]  goroutine 数: 105
[500 请求已发]  goroutine 数: 505
[1000 请求已发] goroutine 数: 1005
[等待 5s]  goroutine 数: 1005
[等待 10s] goroutine 数: 1005  ← 没有任何回落
[等待 30s] goroutine 数: 1005  ← 仍然没有回落

1000 个 goroutine,全部阻塞在 http.Get,零回收。只有等 30 秒后慢 server 返回响应,goroutine 才会退出。但在生产环境中,如果上游是真的卡死(而不是"慢 30 秒"),这些 goroutine 会永远挂着。

这就是线上那 10 万个 goroutine 的微缩版。

复现实验 goroutine 数量阶梯图

我先试了最简单的——给 client 加个 3 秒超时:

client := &http.Client{Timeout: 3 * time.Second}

同样 1000 个请求发向慢 server。3 秒后所有请求超时返回 context deadline exceeded,goroutine 正常退出。3 秒后 goroutine 数回到 5——和初始时一样。

那如果不改 client,只加 context 呢?

client := &http.Client{} // 仍然无超时
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
client.Do(req)

效果同样有效——3 秒后 context 到期,请求被取消,goroutine 退出。

但问题是:这要求每个调用点都手动加 context.WithTimeout。只要有一个地方忘了——比如那个三年前写的 callUpstream 函数——泄漏就会重现。

生产环境该怎么配?两层都加:

client := &http.Client{Timeout: 5 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
client.Do(req)

两者不冲突,谁先到谁生效。

这里有一个容易踩的坑值得单独说:你以为你加了 context,但其实没有生效。

看这段代码,看起来有 context,对吧?

func callUpstream(ctx context.Context, url string) (*http.Response, error) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    resp, err := http.Get(url)  // ← 问题在这行
    return resp, err
}

context 创建了,cancel 也 defer 了,看起来很完美。但 http.Get 不接受 context——它用的是 DefaultClient,完全不知道你的 context 存在。你的 WithTimeout 白设了。

正确写法是用 http.NewRequestWithContext 把 context 绑定到 request 上:

func callUpstream(ctx context.Context, url string) (*http.Response, error) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }
    return httpClient.Do(req)  // 用自定义 client,不用 DefaultClient
}

还有一种更隐蔽的变体——context 绑定了,但上层传进来的是 context.Background(),永远不会被 cancel:

// 调用方
resp, err := callUpstream(context.Background(), url)
// context.Background() 永不过期、永不被 cancel
// 即使 callUpstream 里加了 WithTimeout,上层链路中断时也无法级联取消

在 HTTP handler 中,应该传 r.Context()——它会在客户端断开连接时自动取消:

resp, err := callUpstream(r.Context(), url)

这种写法在功能测试中不会报错,但它放弃了 context 的级联取消能力。如果调用方自身已经超时(比如 HTTP handler 的请求已经被客户端取消),下游的 callUpstream 不会感知到——它还在傻等上游响应。

修复前后 goroutine 数量对比

六、三重防护:一个可复用的框架

这次事故修完之后,我把修复方案抽象成了一个通用的防护框架。不是只修一个 bug,而是建立一套机制,让同类问题不再发生。

最低成本的防御是给 HTTP client 设全局超时——改一处,全局生效。

var httpClient = &http.Client{
    // 全局兜底超时
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        DialContext: (&net.Dialer{
            // TCP 连接建立超时
            Timeout:   5 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
        // TLS 握手超时
        TLSHandshakeTimeout: 5 * time.Second,
        // 空闲连接回收
        IdleConnTimeout:     90 * time.Second,
        MaxIdleConnsPerHost: 100,
        // 对单 host 最大并发连接数
        MaxConnsPerHost:     200,
        ExpectContinueTimeout: 1 * time.Second,
    },
}

三层超时各管一段:DialContext.Timeout 管 TCP 连接建立,TLSHandshakeTimeout 管 TLS 握手,client.Timeout 管整个请求的完整生命周期。任何一个阶段超时,请求就会被放弃。MaxConnsPerHost 限制对同一个上游 host 的最大并发连接——即使超时配置有遗漏,fd 也不会无限增长。

光有兜底还不够。不同业务场景需要不同的超时精度,这就是 context 的用处。封装 HTTP 调用函数,强制要求传入 context:

func callUpstream(ctx context.Context, url string) (*http.Response, error) {
    // 在上层 context 的基础上加业务级超时
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }
    return httpClient.Do(req)
}

关键改动:函数签名里有 ctx context.Context。这不是风格偏好,这是强制调用方思考超时。如果一个函数不接受 context,它就永远不会被 cancel——而"永远不会被 cancel"正是泄漏的根源。

还有一个实践经验:超时值怎么选?太短会导致正常请求被误杀,太长又起不到保护作用。我的经验法则是:看你的上游 P99 延迟,乘以 2-3 倍。如果上游 P99 是 500ms,设 1-2 秒是合理的。如果你不知道上游的延迟分布——说明你需要先加监控,再定超时。

client.Timeoutcontext.WithTimeout 的区别在于粒度和级联能力。client.Timeout 是从请求发起到完整读取响应 body 的总时间,是"无论如何不允许超过 30 秒"的兜底。context.WithTimeout 可以被上层 context 级联取消——如果 HTTP handler 自身的 context 先到期,下游请求也会被取消。这种级联取消能力是 client.Timeout 做不到的。

最后一道防线不是代码层面的,是监控。在 Prometheus 上对 go_goroutines 指标设告警规则:

groups:
  - name: goroutine-leak
    rules:
      - alert: GoroutineLeakSuspected
        expr: go_goroutines > 5000
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "goroutine 数量异常偏高 ({{ $value }})"
      - alert: GoroutineLeakCritical
        expr: go_goroutines > 20000
        for: 2m
        labels:
          severity: critical

5000 和 20000 这两个阈值因服务而异。核心原则是:goroutine 数量应该在一个稳定范围内波动,不应该出现持续单调递增的趋势。如果你的服务正常运行时 goroutine 数稳定在 100-300,突然涨到 5000 并且还在涨,那几乎可以确定是泄漏了。

三层防护架构

七、为什么你早就知道,却还是会踩

修完这个 bug 之后,复盘时最让我不舒服的不是 bug 本身,而是一个事实:

“不要用 http.DefaultClient"这句话,我见过不下二十次。但还是踩了。

这不是我个人的问题,这是工程上的系统性盲区。复盘下来,原因有几个。

第一,代码不是我写的。那个 callUpstream 函数是三年前另一个同事写的,当时它可能就是个临时调用。后来这个函数被十几个地方复用,每次有人用它的时候都不会想到去检查底层的 HTTP client 配置——因为"它一直能用”。

代码库里最危险的不是写错的代码,而是"写对了但缺少防御"的代码。功能上完全正确,只是没有超时——你用 grep 搜不到任何 bug,用单测也测不出来。

第二,测试环境永远不暴露这个问题。测试环境的上游服务响应快(通常 <50ms),goroutine 来了就走,根本看不出泄漏。只有当生产环境的上游偶发慢响应时——比如对方在做全量 GC、网络路径上有丢包、或者流量高峰导致排队——泄漏才会浮现。而这种"偶发"可能几周才出现一次,之前三年没出过事不代表代码是安全的。

第三,Code Review 不 review 超时配置。回头看当初的 Code Review 记录,reviewer 关注的是逻辑正确性——“这个函数的返回值处理对不对?错误处理全不全?“没有人会问:"http.Client{} 有没有设 Timeout?” 因为这不是逻辑错误,而是配置缺失。代码在功能上是对的,只是缺少一层防御。

goroutine 泄漏不是知识问题,是工程问题。 你"知道"应该设超时,但如果团队里没有 linter 规则、没有 code review 检查清单、没有运行时监控,这种"知道"就只是个人记忆,不是系统性保障。

这次事故之后,我们在团队里做了几件事:

  • 封禁 DefaultClient:CI 加 noctx linter(可集成到 golangci-lint),扫描不传 context 的 HTTP 调用,直接触发 warning。→ 解决旧代码无超时问题。
  • 函数签名强制 context:涉及 I/O 的函数第一个参数必须是 context.Context,否则 CR 不过。→ 解决无法级联取消问题。
  • goroutine 监控常态化:每个服务暴露 go_goroutines,持续上涨 5 分钟就告警。→ 解决泄漏早期发现问题。
  • 定期巡检老代码:每季度用 noctx linter 扫描不传 context 的 HTTP 调用,检查有没有遗漏。→ 三年前的代码不会自己变安全。

其中效果最立竿见影的是第一条。linter 规则上线的第一周,就扫出了另外三处直接使用 http.DefaultClient 的代码——全是早期写的工具脚本,没人注意过。如果不是这次事故触发了全面清查,这些"隐形炸弹"可能还要再沉睡几年。

工程预防 checklist


回到开头,pprof 页面上的那个数字。

排查 goroutine 泄漏,说到底就是在问几个问题:

  • 谁在等? — goroutine dump 告诉你(debug=1 看聚合,debug=2 看细节)
  • 等什么? — 调用栈告诉你(是 channel、是 HTTP、还是 DB 连接)
  • 为什么没人叫停? — 超时和 context 的配置告诉你

如果三个问题的答案分别是"HTTP goroutine”、“慢上游”、“因为没设超时也没 context cancel”——那你遇到的,和我这次一样——三个条件凑齐了。

下次看到 goroutine 泄漏,别急着搜 channel。先看调用栈。

你遇到过哪些"三年没出事但其实一直有隐患"的代码?欢迎留言。

原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →