给大家丢脸了,用了三年golang,我还是没答对这道内存泄漏题

问题

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
    "runtime"
)

func main() {
    num := 6
    for index := 0; index < num; index++ {
        resp, _ := http.Get("https://www.baidu.com")
        _, _ = ioutil.ReadAll(resp.Body)
    }
    fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
}

上面这道题在不执行resp.Body.Close()的情况下,泄漏了吗?如果泄漏,泄漏了多少个goroutine?

怎么答

  • 不进行resp.Body.Close(),泄漏是一定的。但是泄漏的goroutine个数就让我迷糊了。由于执行了6遍,每次泄漏一个读和写goroutine,就是12个goroutine,加上main函数本身也是一个goroutine,所以答案是13.
  • 然而执行程序,发现答案是3,出入有点大,为什么呢?

解释

  • 我们直接看源码。golanghttp 包。
http.Get()
-- DefaultClient.Get
----func (c *Client) do(req *Request)
------func send(ireq *Request, rt RoundTripper, deadline time.Time)
-------- resp, didTimeout, err = send(req, c.transport(), deadline) 
// 以上代码在 go/1.12.7/libexec/src/net/http/client:174 

func (c *Client) transport() RoundTripper {
    if c.Transport != nil {
        return c.Transport
    }
    return DefaultTransport
}
  • 说明 http.Get 默认使用 DefaultTransport 管理连接。
    DefaultTransport 是干嘛的呢?
    // It establishes network connections as needed
    // and caches them for reuse by subsequent calls.
  • DefaultTransport 的作用是根据需要建立网络连接并缓存它们以供后续调用重用。
    那么 DefaultTransport 什么时候会建立连接呢?
    接着上面的代码堆栈往下翻
func send(ireq *Request, rt RoundTripper, deadline time.Time) 
--resp, err = rt.RoundTrip(req) // 以上代码在 go/1.12.7/libexec/src/net/http/client:250
func (t *Transport) RoundTrip(req *http.Request)
func (t *Transport) roundTrip(req *Request)
func (t *Transport) getConn(treq *transportRequest, cm connectMethod)
func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (*persistConn, error) {
    ...
    go pconn.readLoop()  // 启动一个读goroutine
    go pconn.writeLoop() // 启动一个写goroutine
    return pconn, nil
}
  • 一次建立连接,就会启动一个读goroutine写goroutine。这就是为什么一次http.Get()会泄漏两个goroutine的来源。
  • 泄漏的来源知道了,也知道是因为没有执行close
那为什么不执行 close 会泄漏呢?
  • 回到刚刚启动的读goroutinereadLoop() 代码里

    func (pc *persistConn) readLoop() {
      alive := true
      for alive {
          ...
          // Before looping back to the top of this function and peeking on
          // the bufio.Reader, wait for the caller goroutine to finish
          // reading the response body. (or for cancelation or death)
          select {
          case bodyEOF := <-waitForBodyRead:
              pc.t.setReqCanceler(rc.req, nil) // before pc might return to idle pool
              alive = alive &&
                  bodyEOF &&
                  !pc.sawEOF &&
                  pc.wroteRequest() &&
                  tryPutIdleConn(trace)
              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.req, rc.req.Context().Err())
          case <-pc.closech:
              alive = false
          }
          ...
      }
    }
  • 简单来说readLoop就是一个死循环,只要alivetruegoroutine就会一直存在

  • select 里是 goroutine 有可能退出的场景:

    • body 被读取完毕或body关闭
    • request 主动 cancel
    • requestcontext Done 状态 true
    • 当前的 persistConn 关闭
  • 其中第一个 body 被读取完或关闭这个 case:

    alive = alive &&
     bodyEOF &&
     !pc.sawEOF &&
     pc.wroteRequest() &&
     tryPutIdleConn(trace)

    bodyEOF 来源于到一个通道 waitForBodyRead,这个字段的 truefalse 直接决定了 alive 变量的值(alive=true读goroutine继续活着,循环,否则退出goroutine)。

那么这个通道的值是从哪里过来的呢?
// go/1.12.7/libexec/src/net/http/transport.go: 1758
        body := &bodyEOFSignal{
            body: resp.Body,
            earlyCloseFn: func() error {
                waitForBodyRead <- false
                <-eofc // will be closed by deferred call at the end of the function
                return nil

            },
            fn: func(err error) error {
                isEOF := err == io.EOF
                waitForBodyRead <- isEOF
                if isEOF {
                    <-eofc // see comment above eofc declaration
                } else if err != nil {
                    if cerr := pc.canceled(); cerr != nil {
                        return cerr
                    }
                }
                return err
            },
        }
  • 如果执行 earlyCloseFnwaitForBodyRead 通道输入的是 falsealive 也会是 false,那 readLoop() 这个 goroutine 就会退出。
  • 如果执行 fn ,其中包括正常情况下 body 读完数据抛出 io.EOF 时的 casewaitForBodyRead 通道输入的是 true,那 alive 会是 true,那么 readLoop() 这个 goroutine 就不会退出,同时还顺便执行了 tryPutIdleConn(trace)
// tryPutIdleConn adds pconn to the list of idle persistent connections awaiting
// a new request.
// If pconn is no longer needed or not in a good state, tryPutIdleConn returns
// an error explaining why it wasn't registered.
// tryPutIdleConn does not close pconn. Use putOrCloseIdleConn instead for that.
func (t *Transport) tryPutIdleConn(pconn *persistConn) error
  • tryPutIdleConnpconn 添加到等待新请求的空闲持久连接列表中,也就是之前说的连接会复用。
那么问题又来了,什么时候会执行这个 fnearlyCloseFn 呢?
func (es *bodyEOFSignal) Close() error {
    es.mu.Lock()
    defer es.mu.Unlock()
    if es.closed {
        return nil
    }
    es.closed = true
    if es.earlyCloseFn != nil && es.rerr != io.EOF {
        return es.earlyCloseFn() // 关闭时执行 earlyCloseFn
    }
    err := es.body.Close()
    return es.condfn(err)
}
  • 上面这个其实就是我们比较收悉的 resp.Body.Close() ,在里面会执行 earlyCloseFn,也就是此时 readLoop() 里的 waitForBodyRead 通道输入的是 falsealive 也会是 false,那 readLoop() 这个 goroutine 就会退出,goroutine 不会泄露。
b, err = ioutil.ReadAll(resp.Body)
--func ReadAll(r io.Reader) 
----func readAll(r io.Reader, capacity int64) 
------func (b *Buffer) ReadFrom(r io.Reader)


// go/1.12.7/libexec/src/bytes/buffer.go:207
func (b *Buffer) ReadFrom(r io.Reader) (n int64, err error) {
    for {
        ...
        m, e := r.Read(b.buf[i:cap(b.buf)])  // 看这里,是body在执行read方法
        ...
    }
}
  • 这个read,其实就是 bodyEOFSignal 里的
    func (es *bodyEOFSignal) Read(p []byte) (n int, err error) {
      ...
      n, err = es.body.Read(p)
      if err != nil {
          ... 
      // 这里会有一个io.EOF的报错,意思是读完了
          err = es.condfn(err)
      }
      return
    }
    func (es *bodyEOFSignal) condfn(err error) error {
      if es.fn == nil {
          return err
      }
      err = es.fn(err)  // 这了执行了 fn
      es.fn = nil
      return err
    }
  • 上面这个其实就是我们比较收悉的读取 body 里的内容。 ioutil.ReadAll() ,在读完 body 的内容时会执行 fn,也就是此时 readLoop() 里的 waitForBodyRead 通道输入的是 truealive 也会是 true,那 readLoop() 这个 goroutine 就不会退出,goroutine 会泄露,然后执行 tryPutIdleConn(trace) 把连接放回池子里复用。

    总结

  • 所以结论呼之欲出了,虽然执行了 6 次循环,而且每次都没有执行 Body.Close() ,就是因为执行了ioutil.ReadAll()把内容都读出来了,连接得以复用,因此只泄漏了一个读goroutine和一个写goroutine,最后加上main goroutine,所以答案就是3个goroutine
  • 从另外一个角度说,正常情况下我们的代码都会执行 ioutil.ReadAll(),但如果此时忘了 resp.Body.Close(),确实会导致泄漏。但如果你调用的域名一直是同一个的话,那么只会泄漏一个 读goroutine 和一个写goroutine这就是为什么代码明明不规范但却看不到明显内存泄漏的原因
  • 那么问题又来了,为什么上面要特意强调是同一个域名呢?改天,回头,以后有空再说吧。

文章推荐:

如果你想每天学习一个知识点,关注我的【公】【众】【号】【golang小白成长记】。
本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 3年前 自动加精
讨论数量: 13

file 明明是5

3年前 评论
一念沧海一念桑田 2年前

@蔡文姬 你这个是什么环境?如果是网络上提供的沙箱测试平台,是有可能走一遍代理产生其他 goroutine 的。试试在本地环境运行?

3年前 评论

怎么规避这种内存泄露呢?

2年前 评论

@VictorWang 每次调用完 http 之后记得执行 resp.Body.Close () 就好

2年前 评论

@xiaobai9 resp.Body.Close () 关闭放到获取数据之后,貌似不起作用哦,还是3
file

2年前 评论

@VictorWang 每次调用完 http 之后记得执行 resp.Body.Close () ,可以确保不会有因为误操作导致的内存泄漏。你截图里还是3,是因为连接还在复用中,连接还没断,那读写协程就会一直存在。

2年前 评论

楼主能否代码演示一下,在哪里关闭连接才能不泄露内存?

2年前 评论

@VictorWang 作为码农基本常识

  • 任何资源使用完毕记得关闭!
2年前 评论

@crackfan 我想知道的是楼主上面的例子在哪里关闭连接,不内存泄露?也就是 runtime.NumGoroutine () 是 1?

file

2年前 评论

file

file

2年前 评论
VictorWang 2年前
for {
    fmt.Println(runtime.NumGoroutine())
}

等一段时间确实会为1

2年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!