go 使用pprof 排查內存泄露

go 是自帶gc的語言,會自動管理內存,不用像C/C++那樣,需要程序員手動釋放內存,不用手動管理內存,就能少掉很多頭髮

go的GC會自動管理內存,但是這不代表go程序就不會內存泄露了。 go常見產生內存泄露的原因就是goroutine沒有結束,簡單說就是goroutine 被阻塞了,這樣就會導致goroutine引用的內存不被GC回收,也就導致了內存寫了。
當然產生內存泄露的原因還有別的,只是暫時我還沒有遇到。不管什麼原因產生的內存泄露,最終都是因爲異常的引用,導致該被回收的內存沒有被gc 回收掉

起因

說起go內存泄露分析,還要從年前的一次程序壓測說起。我用一個測試程序壓測我們遊戲的一些數據,大約開了3000個tcp連接到遊戲。遊戲數據沒有問題,但是當測試結束後,發現遊戲的Gateway內存佔用一直沒有下降。本能的讓我想起了是不是內存泄露了。馬上用pprof分析了一下內存,發現果然是內存泄露了。

因爲時公司代碼,不方便拿出了分析,大體說一下原因吧

Gateway是一個讀寫分離的tcp服務,也就是每一個連接都要有兩個goroutine,一個讀,一個寫。

但是當tcp連接斷開時,因爲時序問題,導致goroutine阻塞了,一直沒有結束,就是導致了相關聯的內存沒有釋放。

因爲時公司代碼,(入職簽了保密協議,雖然也是一個屎山,但是不能隨便貼出來,避免律師函警告),這次就自己寫個簡單的demo模擬一下吧

因爲go 自帶的pprof 只能展示問文字,不太明顯,所有先安裝一個可視化插件 graphviz 傳送門</br>
linux上可以直接通過 apt 或者 yum 安裝就行了。</br>
windows上去網站下一個就好了,我下載 .msi 格式的安裝後不能用,重新下了一個 壓縮包,解壓後把 bin 目錄配置到環境變量的 path 中就可以使用了

開始

寫一個簡單的demo 模擬一下內存泄露

package main

import (
    "math/rand"
    "net/http"
    _ "net/http/pprof"
    "sync"
)

func main() {
    go func() {
        http.ListenAndServe("0.0.0.0:8090", nil)
    }()

    c := make(chan struct{})
    var wg sync.WaitGroup
    wg.Add(1)
    for i := 0; i < 10000; i++ {
        go one(c)
    }
    wg.Wait()
}

func one(c chan struct{}) {
    var a []int64
    for i := 0; i < 10000; i++ {
        a = append(a, rand.Int63())
    }
    
    <-c
}

程序很簡單,在 for 循環中開啓 1000 個協程,每個協程中往切片中append 1000個數據。</br>
用一個channel模擬協程阻塞,這樣就會導致goroutine不會結束。

使用go再帶的pprof查看

代碼中監聽了 8090 端口,在瀏覽器中輸入 http://127.0.0.1:8090/debug/pprof/

下面都有解釋,就不用詳細介紹了,挑一兩個說一下

  • allocs : 程序運行期間,所有內存分配的樣本
  • heap : 當前活動對象的內存分配採樣
  • guroutine : 當前所有協程的堆棧跟蹤

可以看到,當前的程序總共有10005個協程,21次堆內存分配,說明我們的協程是被阻塞的。

使用graphviz查看

在命令行中,在Windows中可以使用 powerShell </br>
輸入 go tool pprof -http :8081 http://127.0.0.1:8090/debug/pprof/heap

會在瀏覽器中打開

這就是當前 heap 採樣圖</br>
可以在 VIEW 中切換不同的顯示方式

圖中方塊越大便是佔用的內存越多,方塊中連線越粗表示耗時越多

內存泄露誤區

我在排查內存泄露時,當我把goroutine阻塞解決後,通過linux 的 top 命令查看Gateway內存佔用時,發現內存沒有降下來,一時讓我陷入困惑,爲啥goroutine 都結束了,爲啥內存還不釋放呢?直到我在網上找到了這篇文章 傳送門 </br>
這位大神寫的golang 相關的文章,是目前我在網上找到的最牛逼的之一,文章不光有深度,而且通俗易懂。

重新驗證

修改上面的代碼

package main

import (
    "math/rand"
    "net/http"
    _ "net/http/pprof"
    "sync"
    "time"
)

func main() {
    go func() {
        http.ListenAndServe("0.0.0.0:8090", nil)
    }()

    var wg sync.WaitGroup
    wg.Add(1)
    for i := 0; i < 10000; i++ {
        go one()
    }
    wg.Wait()
}

func one() {
    var a []int64
    for i := 0; i < 10000; i++ {
        a = append(a, rand.Int63())
    }
    time.Sleep(time.Second * 1)
}

現在 go 出來的的函數 one 不再一直阻塞,而是隻會阻塞5秒

把程序運行起來看一下

等一段時間後,發現goroutine數量已經下來了,說明阻塞的協程都已經結束了,如圖


但是通過任務管理器中,看到程序還是佔用着大量的內存


這時點擊 heap 查看具體的堆內存情況,拉到最低下,看到一堆參數

參數很多,但是重點關注被框出來的那幾個就好了,詳細的分析在大神的文章中有分析,在go的庫文件中也能找到,裏面有詳細的註釋。</br>
路徑 src->runtime->mstats.go 文件中有 MemStats 的定義和註釋

這些參數的單位都是字節

  • TotalAlloc : 所有對象分配的總和,整個程序運行期間,只增不減
  • HeapAlloc : 分配的堆對象的字節數,包括可訪問的對象和未被GC回收的不可訪問的對象,這個值會動態變化,分配對象時增加,回收對象後減少
  • HeapIdle : 閒置的span中的字節數,這些span中已經沒有對象了(不用了),但是現在還沒有還給操作系統,這些span可以重新用來分配heap和stack。HeapIdle 減去 HeapReleased 的值可以當作 "可以返回到操作系統但由運行時保留的內存量",也就是說,go的runtime可以在不像操作系統申請內存的情況下,也可以分配heap空間,這樣可以提高程序性能
  • HeapInuse : 正在使用的span的字節大小
  • HeapReleased : 是返還給操作系統的物理內存的字節數

回到我們的測試程序中,當所有的goroutine都結束時,GC會開始回收切片,但是被回收的內存不會直接換給操作系統,而是由go的runtime暫時保管(也就是 HeapIdle 值會變大),接下來如果再次需要分配空間,go的runtime可以不向操作系統申請內存,直接從自己保管的閒置內存中分配,這樣可以提高程序性能。至於GO的runtime什麼時候把這部分內存還給操作系統,不同的分配策略和不同的系統不太一樣,這個有點深,我還沒有深入研究這些。不過上面傳送門 的文中有介紹 MADV_FREE 有興趣可以自己學習一下

總結

  • go 雖然有GC,但是使用不當也會導致內存泄露
  • 不同的操作系統和不用策略,會導致go程序的內存已經被回收了,但是沒有及時的歸還給操作系統

由於水平有限,文中如有謬處,還請在評論區不吝賜教

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