http.Get 默认无超时,协程会卡死在 ReadHeader 阶段;必须使用自定义 http.Client 并配置 Timeout 及 Transport 各阶段超时(如 DialContext、ResponseHeaderTimeout),同时结合 context.WithTimeout 和正确关闭 resp.Body 以避免 goroutine 泄漏。

http.Get 默认没有超时,协程会卡死在 ReadHeader
Go 的 http.Get 底层调用的是 http.DefaultClient.Get,而 http.DefaultClient 的 Timeout 字段为 0,意味着它**不会主动中断底层连接读写**。一旦目标服务响应缓慢、网络抖动或直接 hang 住(比如 TCP 握手成功但 HTTP 响应头迟迟不发),http.Get 就会无限等待,goroutine 卡在 ReadHeader 阶段——这正是协程堆积的根源。
现象典型:压测时 goroutine 数量持续上涨,runtime.NumGoroutine() 指标飙升,pprof 查看 stack trace 多数停在 net/http.(*conn).readLoop 或 net/http.readResponse。
必须用自定义 http.Client 设置 Timeout / Transport 超时
不能依赖 http.Get,必须显式构造带超时控制的 *http.Client。关键不是只设 Client.Timeout,而是要理解它的作用范围:
-
Client.Timeout仅覆盖整个请求生命周期(从 Dial 到读完 body),但**不保证底层连接能及时中断**,尤其在 TLS 握手或 header 未返回阶段可能失效 - 更可靠的做法是同时配置
Transport的各阶段超时:DialContext、TLSHandshakeTimeout、ResponseHeaderTimeout、IdleConnTimeout
示例代码片段:
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
IdleConnTimeout: 30 * time.Second,
// 不建议盲目设大 MaxIdleConns,需结合 QPS 和下游承载力评估
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
},
}别忽略 context.WithTimeout 对 long-running 请求的兜底价值
即使设置了 Client.Timeout 和 Transport 各项超时,某些极端情况(如 body 流式读取中对方突然断连)仍可能让 goroutine 滞留。此时应叠加 context.WithTimeout,把请求包装进可取消的 context:
- 用
client.Do(req.WithContext(ctx))替代client.Get(url) - 确保
req.Body在任何路径下都被关闭(defer req.Body.Close() 不够,需在 error 分支也 close) - 如果业务允许提前终止(比如等 3 秒没结果就放弃),用
context.WithTimeout比改 client 更灵活
错误示范:ctx, _ := context.WithTimeout(context.Background(), 3*time.Second); client.Get(url) —— 这里 Get 完全忽略 ctx,毫无意义。
协程堆积后排查和验证要点
上线后仍发现 goroutine 上涨,优先检查这些点:
- 是否所有 HTTP 调用都经过了自定义 client?第三方库(如
github.com/go-resty/resty)默认也可能用http.DefaultClient - 是否漏关
resp.Body?未关闭会导致底层连接无法复用,间接加剧新建连接和超时风险 - 是否在循环中高频创建新
*http.Client?Client 应复用,否则 Transport 内部连接池失效,每次都是新连接 + 新超时逻辑 - pprof 的
/debug/pprof/goroutine?debug=2输出里,确认阻塞点是否真在readLoop或select等待 channel,而非业务锁或 channel send
真正棘手的不是设超时本身,而是超时参数和实际网络行为之间的 gap——比如 ResponseHeaderTimeout 设 2 秒,但对方在 TLS 握手后花了 2.1 秒才发第一个字节,这个请求就会被丢弃,而你可能误以为是 DNS 或路由问题。


















