給大家丟臉了,用了三年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()
![](https://imgkr2.cn-bj.ufileos.com/94738734-9402-475a-b41b-cb443f431f2f.html?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=Ceu9w6I4hvRLxVykLhh8IMwBbZ4%253D&Expires=1605828258)

-- 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這就是爲什麼代碼明明不規範但卻看不到明顯內存泄漏的原因
  • 那麼問題又來了,爲什麼上面要特意強調是同一個域名呢?改天,回頭,以後有空再說吧。

文章推薦:

如果你想每天學習一個知識點?

本文分享自微信公衆號 - 編程三分鐘(coding3min)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章