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 上那些分阶段超时(DialContext、ResponseHeaderTimeout、IdleConnTimeout)不是一回事:后者管某一段,前者管全程。生产用的客户端至少要先把整体 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 传递。