案例 pprof 显示 98,237 个 goroutine 卡在 http.Client.Get:零值 Timeout 与没传 r.Context()

2026-09-23 21:02:57

pprof 显示 98,237 个 goroutine 卡在 http.Client.Get:零值 Timeout 与没传 r.Context()

现象:95% 的 goroutine 停在同一个调用栈上

Go 的 pprof 能导出所有 goroutine 的完整调用栈。goroutine 数量到十万级时,要做的是找大量重复的调用栈——如果上万个 goroutine 的栈完全相同,那个栈就是泄漏点。

一次 goroutine dump 的总数是 103,891 个,其中 98,237 个(95%)全部阻塞在同一个位置:

net/http.(*Client).Get

不是 channel,不是 WaitGroup,是 HTTP 请求。这些 goroutine 都在等一个 HTTP 响应,响应迟迟没来,它们就这么挂着,既不回收也不退出。

根因:Client{} 的零值 Timeout 等于永不超时

出问题的客户端是一份零值构造:

var httpClient = &http.Client{}

Timeout 字段为 0。在 Go 的 HTTP 实现里,0 不是「超时为零秒」,而是「没有超时」。上游服务只要在任何阶段卡住——DNS 解析慢、TCP 握手没完成、迟迟不发响应头、body 传输中途丢包重传——这边的请求就会一直等下去。不报错,不超时,安静地挂着。

http.Client.Timeout 覆盖的是整个请求生命周期,包括连接建立、重定向、读取 body。它和 Transport 上那些分阶段超时(DialContextResponseHeaderTimeoutIdleConnTimeout)不是一回事:后者管某一段,前者管全程。生产用的客户端至少要先把整体 Timeout 设上。

泄漏链条

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

fd 耗尽后 net 包返回 too many open files,不只是 HTTP 请求发不出去,监听新连接也做不到;负载均衡器的健康检查失败,节点会被从服务列表里摘掉。

两层都要加超时

第一反应是给 client 加 Timeout,再给请求挂上 context:

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)

看起来完整,但常见的 http.Get 不接受 context——它用的是 DefaultClient,完全不知道你创建的 context 存在,那个 WithTimeout 白设。正确写法是显式构造请求、用自定义 client:

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
}

client.Timeout 和 ctx 超时同时存在时,谁先到谁生效:client 超时会直接中断请求,ctx 超时则通过取消请求的 context 触发同样的中断路径。两者都设不算冗余,client.Timeout 兜底,ctx 负责级联取消。

更隐蔽的变体:context 传了,但永远不会被 cancel

还有一种写法——请求确实绑定了 context,但上层传进来的是 context.Background(),它永远不会被 cancel。

在 HTTP handler 里应该传 r.Context(),它会在客户端断开连接时自动取消。用 context.Background() 在功能测试里不会报错,但放弃了 context 的级联取消能力:调用方自己已经超时返回,下游的 callUpstream 感知不到,还在等上游响应。

func handler(w http.ResponseWriter, r *http.Request) {
    // 用 r.Context(),不要用 context.Background()
    resp, err := callUpstream(r.Context(), upstreamURL)
    // ...
}

context 取消是协作式的

context.WithCancel 返回的 cancel func 不会强制杀掉 goroutine,它只做一件事:关闭 ctx.Done() 这个 channel。正在跑的 goroutine 必须主动监听 ctx.Done(),每一个可能长时间等待的操作——channel 接收、定时等待、网络读写、数据库调用——都要有退出路径,否则照样泄漏。

这也是「加了 context 就安全」这个判断的漏洞所在:context 只是通知机制,真正退出还得靠等待方自己检查退出条件。

忘记 defer cancel() 同样会泄漏:子节点会一直挂在父 context 的 children 列表里不释放,直到父 context 被取消。函数里创建了带 cancel 的 context,就必须 defer cancel(),哪怕请求已经成功返回。

排查顺序

三个问题定位:

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

常用命令:

go tool pprof http://localhost:6060/debug/pprof/goroutine?seconds=10
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2 | grep -E "^goroutine [0-9]+" | sort | uniq -c

第二条命令对 goroutine 头部行做去重计数,数量最多的那组栈就是重点。debug=1 输出按栈聚合后的计数,适合先看分布;debug=2 是每个 goroutine 的完整栈,适合确认具体路径和参数。

单元测试里用 uber 的 goleak 兜底:

func TestNoGoroutineLeaks(t *testing.T) {
    defer goleak.VerifyNone(t)
}

生产上监控 runtime.NumGoroutine()。稳定流量下这个值持续上涨且不回落,基本就是泄漏;如果同时伴随 fd 数量上涨,优先检查 HTTP 客户端的 Timeout 和 context 传递。

复制全文 生成海报 Go 并发 pprof 排障

推荐文章

程序员茄子在线接单