net/http的Client端resp.Body.Close问题

未匹配的标注

参考资料

前言

Go http.Client 发起请求后是否需要调用resp.Body.Close?这其实可以细分为两个场景:

  1. 是否需要读完resp.body
  2. 是否需要调用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。

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
讨论数量: 0
发起讨论 只看当前版本


暂无话题~