← 中文资源

简体中文版本

Go — 默认 HTTP 客户端无超时:goroutine 永久挂起

修复使用默认客户端导致 goroutine 永久停在 net/http 的问题,用客户端粗粒度超时加 context 细粒度控制,并在负载下验证。

阅读英文原文

症状

  “什么都没失败”本身就是问题。goroutine 剖析里挤满 transport 帧:数千个栈停在 net/http 内部、上面没有任何应用帧——请求卡在传输层,等一个接受了 TCP 连接后就沉默的对端。一个终端对即可复现:nc -l 监听但不应答,应用用 http.Get 打过去,该请求永不返回。乘以流量,幽灵请求同时消耗池槽位和 goroutine,对同一主机的健康调用也被饿死在它们后面。没有失败信号、没有超时、没有返回,正是零超时的三无特征。

诊断思路

  先数 goroutine 剖析里 net/http 帧的数量并确认无应用帧覆盖;再审计客户端构造——http.Get/http.Post 包级函数与 &http.Client{} 零值都意味着无超时,每一处都是悬挂上游下的挂点;最后用 nc 停摆端点做确定性复现,超时修复可以直接对它验证。

根因

  http.Client 零值的 Timeout 为 0,即整体请求截止时间被禁用。对“接受连接但永不响应”的上游发出的请求,其 goroutine 无限期停在传输层,每个悬挂调用消耗一个池槽位加一个 goroutine,且没有任何失败信号。

修复与验证

  双层截止时间:客户端层设 Timeout 作为粗粒度总界(从拨号到读完响应体),Transport 层按相位设 ResponseHeaderTimeout、TLSHandshakeTimeout、IdleConnTimeout;每请求用 context.WithTimeout 挂精确预算(比客户端界更紧),NewRequestWithContext 携带。审计所有 http.Get/http.Post——包级辅助函数用的都是 Timeout 为 0 的 DefaultClient;共享的已配置客户端还附赠连接池,逐调用临时客户端会摧毁它。验证时把服务指向停摆端点,断言每个请求在其截止时间内失败、失败风暴后 goroutine 计数回到基线。单一激进 Timeout 会砍断大下载与流式响应——紧超时按端点用 context 作用域化,客户端层保持宽裕。

代码示例

$ curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | grep -c "net/http"
3184
nc -l 9443        # accepts connections, never answers
# app calls http.Get("http://localhost:9443/") using DefaultClient
var client = &http.Client{
    Timeout: 30 * time.Second, // overall bound: dial to body read
    Transport: &http.Transport{
        ResponseHeaderTimeout: 10 * time.Second,
        TLSHandshakeTimeout:   5 * time.Second,
        IdleConnTimeout:       90 * time.Second,
    },
}

// per-request budget, tighter than the client bound:
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)