Go: 如何寫出內存泄露的程序

不管使用什麼語言,內存泄露是經常遇到的一類問題,然而使用 Go 語言編寫內存泄露的代碼卻不容易,本文將列舉幾個可能出現內存泄露的場景,從反例中學習如何避免內存泄露。

資源泄露

不關閉打開的文件

當你不再需要一個打開的文件, 正常你需要調用它的 Close 方法, 如果你不調用 Close,就有可能文件描述符達到最大限制,無法再打開新的文件或者連接, 程序會報too many open files的錯誤。比如下面的例子:
Code 1: 文件沒關閉導致 耗盡文件描述符。

func main() {  
    files := make([]*os.File, 0)  
for i := 0; ; i++ {  
       file, err := os.OpenFile("test.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)  
if err != nil {  
          fmt.Printf("Error at file %d: %v\n", i, err)  
break
       } else {  
          _, _ = file.Write([]byte("Hello, World!"))  
          files = append(files, file)  
       }  
    }  
}
----
➜  memory_leak git:(main) ✗ go run close_file.go
Error at file 61437: open test.log: too many open files

在我的 Mac 電腦上一個進程能打開的文件句柄數量最大是 61440,也可以手動設置這個數值。go 程序會默認打開 stderrstdoutstdin 三個文件句柄,一個進程最多能夠打開 61437 文件,多了就會報錯。

http.Response.Body.Close()

Go 語言有一個 比較 “知名” 的 bug,相信您一定看到過:如果我們忘記關閉 http 請求的 body 的話,會導致內存泄露,比如下面的代碼。
https://gist.github.com/hxzhouh/1e63ef82a1088ac378384e30651b20c9
{{<gist hxzhouh 1e63ef82a1088ac378384e30651b20c9>}}
code 2: http body 沒有關閉導致內存泄露。

func makeRequest() {  
    client := &http.Client{}  
    req, err := http.NewRequest(http.MethodGet, "http://localhost:8081", nil)  
    res, err := client.Do(req)  
if err != nil {  
       fmt.Println(err)  
    }  
    _, err = ioutil.ReadAll(res.Body)  
// defer res.Body.Close()  
if err != nil {  
       fmt.Println(err)  
    }  
}

關於這個問題您可以參考下面的文章瞭解更多信息,後面如果有時間的話,我們從頭梳理一下 net/http 

字符串 / slice 導致內存泄露

雖然 Go spec 並沒有說明一個字符串表達式的結果(子)字符串和原來字符串是否共享一個內存塊 但編譯器確實讓它們共享一個內存塊,而且很多標準庫包的函數原型設計也默認了這一點。這是一個好的設計,它不僅節省內存,而且還減少了 CPU 消耗。 但是有時候它會造成暫時性的內存泄露。
Code 3: 字符串導致內存泄露。
https://gist.github.com/hxzhouh/e09587195e2d7aa2d5f6676777c6cb16
{{}}

![[Pasted image 20240925174837.png]]
爲防止createStringWithLengthOnHeap 臨時性內存泄露,我們可以使用strings.Clone()
Code 4 : 使用strings.Clone() 避免臨時內存泄露。

func Demo1() {  
    for i := 0; i < 10; i++ {  
       s := createStringWithLengthOnHeap(1 << 20) //1M  
       packageStr1 = append(packageStr1, strings.Clone(s[:50]))  
    }  
}

這樣就不會導致臨時性內存泄露了。

goroutine leak

goroutine handler

絕大部分內存泄露的原因是因爲 goroutine 泄露,比如下面的例子,很快將會內存耗盡 而導致OOM
Code 5:  goroutine handler

for {  
       go func() {  
          time.Sleep(1 * time.Hour)  
       }()  
    }  
}

channel

channel 的使用錯誤也很容易導致 goroutine 泄露,
對於無緩衝的 channel,必須要等到生產者和消費者全部就緒後,才能往 channel 寫數據,否則將會阻塞。下面的例子因爲Example 提前退出導致協程泄露。
Code 6: 不合理使用無緩衝 channel 導致goroutine泄露

func Example() {
    a := 1
    c := make(chanerror)
gofunc() {
        c <- err
return
    }()
// Example 在這裏退出,導致協程泄露。
if a > 0 {
return
    }
    err := <-c
}

只需要改成有緩衝的 channel 就能解決這個問題 c:= make(chan error,1)
還有一個典型的例子就是 channel range 誤用。
Code 7: 不合理使用range 導致goroutine泄露

func main() {  
    wg := &sync.WaitGroup{}  
    c := make(chan any, 1)  
    items := []int{1, 2, 3, 4, 5}  
for _, i := range items {  
       wg.Add(1)  
gofunc() {  
          c <- i  
       }()  
    }  
gofunc() {  
for data := range c {  
          fmt.Println(data)  
          wg.Done()  
       }  
       fmt.Println("close")  
    }()  
    wg.Wait()  
    time.Sleep(1 * time.Second)  
}

channel 可以使用 range 迭代 . 但是一旦讀取不到內容,range 就會等待 channel 的寫入,而 range 如果正好在 goroutine 內部,這個 goroutine 就會被阻塞,造成泄露。正確的做法是: 在wg.Wait() 後面 close channel.

runtime.SetFinalizer 誤用

如果兩個對象都設置了 runtime.SetFinalizer 並且他們之間存在 "循環引⽤" ,那麼這兩個對象將會泄露,即時他們不再使用,GC 也不會回收他們。
關於 runtime.SetFinalizer 的更多內容,可以參考我的另外一篇文章

time.Ticker

這是 go 1.23 版本之前的問題了, 如果我們不調用ticker.Stop().go 1.23 已經不會造成泄露了 https://go.dev/doc/go1.23#timer-changes

defer

我們一般習慣在defer 中釋放資源 defer 函數本身不會導致內存泄露。但是它的兩個機制可能會導致內存臨時性泄露。

  1. 執行時間,defer 總是在函數結束的運行。如果您的函數運行時間過長,或者永遠不會結束,那麼您在 defer 中釋放的資源可能,很久都不會被釋放,或者永遠都不被釋放。

  2. defer

     本身也需要佔用內存,每個 defer 都會在內存中添加一個調用點。如果您在循環中使用 defer,有可能會導致臨時性的內存泄露。
    Code 8: defer 導致 內存臨時泄露

func ReadFile(files []string) {  
for _, file := range files {  
      f, err := os.Open(file)  
if err != nil {  
         fmt.Println(err)  
return
      }  
// do something  
defer f.Close()  
    }  
}

比如上面的代碼,不僅僅可能會導致 defer 臨時內存泄露,還可能會導致too many open files
不要癡迷於使用defer 除非你覺得代碼你的代碼可能會panic ,否則及時關閉文件是一個更好的選擇。

總結

本文列舉了幾種可能會導致 go 內存泄露的行爲,同時 Goroutine 內存泄漏是 Go 語言最容易發生的內存泄漏情況,它通常伴隨着錯誤地使用 goroutine 和 channel 等。而 channel 的特殊用法如 select 和 range 又讓 channel 阻塞變得更加隱蔽不易發現,進而增加排查內存泄漏的難度。
遇到內存泄露問題,我們可以通過 pprof 幫助我們快速的定位問題,希望我們每個人都能寫出健壯的代碼。

參考資料

https://go101.org/article/memory-leaking.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/TAd18N5NkRIbp87erohteQ