給大家丟臉了,用了三年golang,我還是沒答對這道記憶體洩漏題

xiaobai9 發表於 2021-04-07
Go

給大家丟臉了,用了三年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`就是一個死迴圈,只要`alive``true``goroutine`就會一直存在
- `select` 裡是 `goroutine` **有可能**退出的場景:
  - `body` 被讀取完畢或`body`關閉
  - `request` 主動 `cancel`
  - `request``context Done` 狀態 `true`
  - 當前的 `persistConn` 關閉

-  其中第一個 `body` 被讀取完或關閉這個 `case`:
```go
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 協議》,轉載必須註明作者和本文連結