net/http的Client端resp.Body.Close问题
参考资料
前言
Go http.Client 发起请求后是否需要调用resp.Body.Close?这其实可以细分为两个场景:
- 是否需要读完resp.body
- 是否需要调用resp.Body.Close
根据上一篇文章我们不难看出,如果1和2都不做,readLoop和writeLoop协程将一直挂起直到请求超时(没有设置超时时间,则取决于tcp的保活机制),这很容易出现协程泄露。我们把关注点放在12都做、只做1或者只做2上,先写一个demo如下。
func main() {
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := (&net.Dialer{}).DialContext(ctx, network, addr)
fmt.Printf("Local addr: %s\n", conn.LocalAddr())
return conn, err
},
}
client := &http.Client{
Transport: transport,
}
resp, _ := client.Get("http://httpbin.org/get")
if resp != nil {
_, _ = io.ReadAll(resp.Body) // 第26行
_ = resp.Body.Close() // 第27行
}
resp, _ = client.Get("http://httpbin.org/get")
if resp != nil {
_, _ = io.ReadAll(resp.Body)
_ = resp.Body.Close()
}
fmt.Println("Response status:", resp.Status)
}
既Read又Close
demo第26、27行都不注释,获取的调用链路看这里。可以看到两次http请求只建立了一次tcp连接,读写公用了同一个readLoop和writeLoop协程。主要原因是第二次发起请求时net/http.(*Transport).getConn
获取到了第一次请求的tcp连接(请求完放回idle池了),调用链路如下:
|-goroutine-1 created by runtime.main
|-main.main
|-net/http.(*Client).Get
// 省略...
|-net/http.(*Transport).roundTrip
|-net/http.(*Transport).getConn
|-net/http.(*Transport).queueForIdleConn // 第一次请求,没有idle连接
|-net/http.(*Transport).queueForDial // 建立新的tcp连接
|-net/http.(*persistConn).roundTrip // 发起请求,阻塞等待
|-io.ReadAll
|-net/http.(*bodyEOFSignal).Close
|-net/http.(*Client).Get
// 省略...
|-net/http.(*Transport).roundTrip
|-net/http.(*Transport).getConn
|-net/http.(*Transport).queueForIdleConn // 第二次请求,有idle连接,直接使用
|-net/http.(*persistConn).roundTrip // 发起请求,阻塞等待
|-io.ReadAll
|-net/http.(*bodyEOFSignal).Close
只Read不Close
只注释demo第27行,获取的调用链路看这里。好像和上面的情况一样,tcp连接复用了,readLoop和writeLoop协程只有一个。这次我们把关注点放在第一次请求后连接是如何放回idle池的。
|-goroutine-1 created by runtime.main
|-main.main
|-net/http.(*Client).Get
// 省略...
|-net/http.(*Transport).roundTrip
|-net/http.(*Transport).getConn
|-net/http.(*Transport).queueForIdleConn // 第一次请求,没有idle连接
|-net/http.(*Transport).queueForDial // 建立新的tcp连接
|-net/http.(*persistConn).roundTrip // 将请求放入channel,阻塞等待
|-io.ReadAll
|-net/http.(*bodyEOFSignal).Read
|-net/http.(*body).Read
|-net/http.(*bodyEOFSignal).connfn
|-net/http.(*bodyEOFSignal).fn
|-net/http.(*persistConn).readLoop.func4 // 这个方法是在readLoop协程中赋值的
|-waitForBodyRead <- isEOF // 通知已经读完了resp.Body
|-net/http.(*Client).Get
// 省略...
重点在读完之后赋值了waitForBodyRead这个channel,它会通知readLoop协程进行下一步的操作,看代码:
func (pc *persistConn) readLoop() {
// 省略...
alive := true
for alive {
select {
case bodyEOF := <-waitForBodyRead: // 收到waitForBodyRead
replaced := pc.t.replaceReqCanceler(rc.cancelKey, nil)
alive = alive && // 连接ok
bodyEOF && // body读完
!pc.sawEOF && // tcp没有收到EOF
pc.wroteRequest() && // 上一轮写入是ok的
replaced && tryPutIdleConn(trace) // 把连接放回idle池,开启新一轮for循环
if bodyEOF {
eofc <- struct{}{}
}
case <-rc.req.Cancel:
alive = false
pc.t.CancelRequest(rc.req)
case <-rc.req.Context().Done():
alive = false
pc.t.cancelRequest(rc.cancelKey, rc.req.Context().Err())
case <-pc.closech:
alive = false
}
}
}
不Read只Close
只注释demo第26行,获取的调用链路看这里。这次变化很大,每次请求都有6个协程(2个负责建立tcp连接,2个负责dns解析,1个readLoop,1个writeLoop)。主要原因是第二次请求没有复用连接。是因为第一次请求后连接没有放到idle池还是连接被关闭了呢?我们从第一次请求Close后的流程开始看起:
// resp.Body.Close()
func (es *bodyEOFSignal) Close() error {
es.closed = true
if es.earlyCloseFn != nil && es.rerr != io.EOF {
return es.earlyCloseFn() // 因为es.rerr是空,最终执行这里
}
// 省略...
}
// earlyCloseFn的定义在readLoop协程内
body := &bodyEOFSignal{
earlyCloseFn: func() error {
waitForBodyRead <- false // 通知没有读resp.Body
<-eofc
return nil
}
}
// readLoop协程
func (pc *persistConn) readLoop() {
defer func() {
pc.close(closeErr) // 关闭tcp连接,会执行close(pc.closech)
pc.t.removeIdleConn(pc)
}()
eofc := make(chan struct{})
defer close(eofc)
// 省略...
alive := true
for alive {
select {
case bodyEOF := <-waitForBodyRead: // 收到waitForBodyRead
replaced := pc.t.replaceReqCanceler(rc.cancelKey, nil)
alive = alive && // 连接ok
bodyEOF && // 这里是false,导致alive=false退出for循环
!pc.sawEOF &&
pc.wroteRequest() &&
replaced && tryPutIdleConn(trace)
if bodyEOF {
eofc <- struct{}{}
}
}
}
}
// writeLoop协程
func (pc *persistConn) writeLoop() {
defer close(pc.writeLoopDone)
for {
select {
case wr := <-pc.writech:
// 省略...
case <-pc.closech: // 收到连接关闭的通知,退出
return
}
}
}
可以看到,由于没有读完resp.Body导致readLoop和writeLoop都退出了,并且连接也没有放回idle池,而是直接关闭了,导致第二个请求不能复用连接。这里再回顾一下既Read又Close的场景,Read完连接已经放回idle池,Close的时候net/http.(*bodyEOFSignal).Close
不会执行es.earlyCloseFn,所以一切正常。
因此不Read只Close这种场景不会发生协程泄露,只是没能复用连接,这对应追求高性能的场景是不能忍受的。
总结
经常看到项目中在使用net/http的Client不关闭resp.Body的情况,通过分析可以知道,如果项目中每次发起请求后都读完了resp.Body,不关闭resp.Body也是可以的。但是为了安全起见,最好还是在每次请求后都执行resp.Body.Close,同时最好读完resp.Body。