Jason Pan

为什么必须要调用io.ReadAll(resp.Body)

潘忠显 / 2025-05-26


前面发了个小文《固定频率压测工具》,文中有提到「使用直接调用resp.Body.Close(),而前边没有调用io.ReadAll(resp.Body),造成了TCP连接被关闭而没有被复用,进而引起大量 TIME_WAIT,进而引起端口不够无法建立连接」的问题。

今天分别从代码层面、逻辑层面简单解释一下为什么。

代码层面

今天大概看了下 http 包中 client.go 和 transport.go 的代码,传输相关的主要在后者。

http.Client

我们创建一个HTTP Client 通常的方法是下边这样:

	client := &http.Client{
		Timeout: 10 * time.Second,
		Transport: &http.Transport{
			MaxIdleConns:        1000,
			MaxIdleConnsPerHost: 1000,
		},
	}

我们简单看一下 Client 接口,可以看到 Transport 是 RoundTripper 接口,只要求实现一个函数:

RoundTrip(*Request) (*Response, error)

http.Transport

上边代码创建了一个 http.Transport 变量,这个 Transpor 结构就是 http 包实现的一个 RoundTripper,他支持 HTTP、HTTPS 等传输。

在 transport.go 中并没有定义的 TransportRoundTrip(),而是被放在了 roundtrip.go 文件中,其封装的是 transport.go 中 Transport 的私有函数 roundTrip()

func (t *Transport) RoundTrip(req *Request) (*Response, error) {
	return t.roundTrip(req)
}

我们看一下 Transport 结构中的一些内容,可以很清晰的看见到,空闲连接管理是在 Transport 中处理的

type Transport struct {
	idleMu       sync.Mutex
	closeIdle    bool                                // user has requested to close all idle conns
	idleConn     map[connectMethodKey][]*persistConn // most recently used at end
	idleConnWait map[connectMethodKey]wantConnQueue  // waiting getConns

Client 和 RoundTrip 的关系

我们使用 http.Client 的典型用法是创建请求、依次调用 client.Doio.ReadAllbody.Close()

req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte("{\"delay\": 1}")))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
body, err = io.ReadAll(resp.Body)
resp.Body.Close()

而在 Client.Do() 函数中,在简单处理完请求结构之后,就会调用 send() 函数,进而调用 client.Transport (如果没有设置,http包中有一个默认的DefaultTransport)

Transport.dialConn()

HTTP 连接管理有一个关键的函数,他就是 dialConn()

为了清晰点,我这里列一下调用 dialConn() 函数点外部调用栈信息:

http.(*Transport).dialConn()
http.(*Transport).dialConnFor()
http.(*Transport).startDialConnForLocked.func1()
http.(*Transport).queueForDial()
http.(*Transport).getConn()
http.(*Transport).roundTrip()
http.(*Transport).RoundTrip()

这个函数的定义如下:

func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error)

他会返回一个 persistConn 指针,其中包含 net.Conn 是一个真实网络连接,还有一个 t 用于指向该持久连接所属的 Transport

type persistConn struct {
	t         *Transport
	conn      net.Conn
	tlsState  *tls.ConnectionState
	br        *bufio.Reader       // from conn
	bw        *bufio.Writer       // to conn
  // ...
}

persistConn.readLoop()

再深入到 dialConn() 中,我们能找到被调用 persistConn.readLoop() 函数,该函数中有调用 pc.t.tryPutIdleConn(pc),会将创建的连接放到 Transport 的空闲连接池中。

而我们前边**是否调用io.ReadAll(resp.Body)造成的区别在于,从 waitForBodyRead channel 中的返回的结果是 true 还是 false **:

body-eof

bodyEOFSignal.Close()

哪里触发的这个通道返回 true 还是 false?正是我们调用的 resp.Body.Close()。可以参考后边我附的代码看出来:

early-close

小结

通过以上的代码阅读和分析,我们能够清晰的看出,为什么不调用 io.ReadAll() 会造成连接没有被复用——其实也就是没有被放到空闲连接池中。

协议逻辑层面

我们知道 HTTP 是一种基于 TCP的一问一答应用层协议,即:一个 TCP 连接上,发出请求之后,必须受到完整的回答才能发送下一个请求。

考虑有些场景不需要知道返回内容,因此不调用 io.ReadAll() 直接调用 resp.Body.Close(),如果此时直接将 TCP 连接放回空闲连接池,那该连接如果被别逻辑使用,读取 Body 的时候,将会读到前一个请求的残留数据

基于此,没有被读取完 Body 数据的 TCP 连接是不能作为空闲 HTTP 连接复用的。