Go 記憶體洩漏

什麼是記憶體洩露

記憶體洩露指的是程式執行過程中已不再使用的記憶體,沒有被釋放掉,導致這些記憶體無法被使用,直到程式結束這些記憶體才被釋放的問題。

Go雖然有GC來回收不再使用的堆記憶體,減輕了開發人員對記憶體的管理負擔,但這並不意味著Go程式不再有記憶體洩露問題。在Go程式中,如果沒有Go語言的程式設計思維,也不遵守良好的程式設計實踐,就可能埋下隱患,造成記憶體洩露問題。

怎麼發現記憶體洩露

在Go中發現記憶體洩露有2種方法,一個是通用的監控工具,另一個是go pprof:

監控工具

:固定週期對程序的記憶體佔用情況進行取樣,資料視覺化後,根據記憶體佔用走勢(持續上升),很容易發現是否發生記憶體洩露。

go pprof

:適合沒有監控工具的情況,使用Go提供的pprof工具判斷是否發生記憶體洩露。

這2種方式分別介紹一下。

監控工具檢視程序內在佔用情況

如果使用雲平臺部署Go程式

,雲平臺都提供了記憶體檢視的工具,可以檢視OS的記憶體佔用情況和某個程序的記憶體佔用情況,比如阿里雲,我們在1個雲主機上只部署了1個Go服務,所以OS的記憶體佔用情況,基本是也反映了程序記憶體佔用情況,OS記憶體佔用情況如下,可以看到

隨著時間的推進,記憶體的佔用率在不斷的提高,這是記憶體洩露的最明顯現象

Go 記憶體洩漏

如果沒有云平臺這種記憶體監控工具,可以製作一個簡單的記憶體記錄工具。

1、建立一個指令碼

prog_mem。sh

,獲取程序佔用的物理記憶體情況,指令碼內容如下:

#!/bin/bashprog_name=“your_programe_name”prog_mem=$(pidstat -r -u -h -C $prog_name |awk ‘NR==4{print $12}’)time=$(date “+%Y-%m-%d %H:%M:%S”)echo $time“\tmemory(Byte)\t”$prog_mem >>~/record/prog_mem。log

2、然後使用

crontab

建立定時任務,每分鐘記錄1次。使用

crontab -e

編輯crontab配置,在最後增加1行:

*/1 * * * * ~/record/prog_mem。sh

指令碼輸出的內容儲存在

prog_mem。log

,只要大體瀏覽一下就可以發現記憶體的增長情況,判斷是否存在記憶體洩露。如果需要視覺化,可以直接黏貼

prog_mem。log

內容到Excel等表格工具,繪製記憶體佔用圖。

Go 記憶體洩漏

go pprof發現存在記憶體問題

基於目前對heap的認知,我有2個觀點:

heap能幫助我們發現記憶體問題,但不一定能發現記憶體洩露問題

,這個看法與Dave是類似的。heap記錄了記憶體分配的情況,我們能透過heap觀察記憶體的變化,增長與減少,記憶體主要被哪些程式碼佔用了,程式存在記憶體問題,這隻能說明記憶體有使用不合理的地方,但並不能說明這是記憶體洩露。

heap在幫助定位記憶體洩露原因上貢獻的力量微乎其微

。如第一條所言,能透過heap找到佔用記憶體多的位置,但這個位置通常不一定是記憶體洩露,就算是記憶體洩露,也只是記憶體洩露的結果,並不是真正導致記憶體洩露的根源。

接下來,我介紹怎麼用heap發現問題,然後再解釋為什麼heap幾乎不能定位記憶體洩露的根因。

heap“不能”定位記憶體洩露

heap能顯示記憶體的分配情況,以及哪行程式碼佔用了多少記憶體,我們能輕易的找到佔用記憶體最多的地方,如果這個地方的數值還在不斷怎大,基本可以認定這裡就是記憶體洩露的位置。

曾想按圖索驥,從記憶體洩露的位置,根據呼叫棧向上查詢,總能找到記憶體洩露的原因,這種方案看起來是不錯的,但實施起來卻找不到記憶體洩露的原因。

任何一個從g101到g111的呼叫路徑都可能造成了g111的記憶體洩露,有2類可能:

該goroutine只調用了少數幾次,但消耗了大量的記憶體,說明每個goroutine呼叫都消耗了不少記憶體,

記憶體洩露的原因基本就在該協程內部

該goroutine的呼叫次數非常多,雖然每個協程呼叫過程中消耗的記憶體不多,但該呼叫路徑上,協程數量巨大,造成消耗大量的記憶體,並且這些goroutine由於某種原因無法退出,佔用的記憶體不會釋放,

記憶體洩露的原因在到g111呼叫路徑上某段程式碼實現有問題,造成建立了大量的g111

第2種情況,就是goroutine洩露,這是透過heap無法發現的,所以heap在定位記憶體洩露這件事上,發揮的作用不大

Go 記憶體洩漏

goroutine洩露怎麼導致記憶體洩露

什麼是goroutine洩露

如果你啟動了1個goroutine,但並沒有符合預期的退出,直到程式結束,此goroutine才退出,這種情況就是goroutine洩露。

提前思考:什麼會導致goroutine無法退出/阻塞?

goroutine洩露怎麼導致記憶體洩露

每個goroutine佔用2KB記憶體,洩露1百萬goroutine至少洩露

2KB * 1000000 = 2GB

記憶體,為什麼說至少呢?

goroutine執行過程中還存在一些變數,如果這些變數指向堆記憶體中的記憶體,GC會認為這些記憶體仍在使用,不會對其進行回收,這些記憶體誰都無法使用,造成了記憶體洩露。

所以goroutine洩露有2種方式造成記憶體洩露:

goroutine本身的棧所佔用的空間造成記憶體洩露。

goroutine中的變數所佔用的堆記憶體導致堆記憶體洩露,這一部分是能透過heap profile體現出來的。

如果不知道何時停止一個goroutine,這個goroutine就是潛在的記憶體洩露

怎麼確定是goroutine洩露引發的記憶體洩露

判斷依據:在節點正常執行的情況下,隔一段時間獲取goroutine的數量,如果後面獲取的那次,某些goroutine比前一次多,如果多獲取幾次,是持續增長的,就極有可能是goroutine洩露

檔案:golang_step_by_step/pprof/goroutine/leak_demo1。go

// goroutine洩露導致記憶體洩露package mainimport ( “fmt” “net/http” _ “net/http/pprof” “os” “time”)func main() { // 開啟pprof go func() { ip := “0。0。0。0:6060” if err := http。ListenAndServe(ip, nil); err != nil { fmt。Printf(“start pprof failed on %s\n”, ip) os。Exit(1) } }() outCh := make(chan int) // 死程式碼,永不讀取 go func() { if false { <-outCh } select {} }() // 每s起100個goroutine,goroutine會阻塞,不釋放記憶體 tick := time。Tick(time。Second / 100) i := 0 for range tick { i++ fmt。Println(i) alloc1(outCh) }}func alloc1(outCh chan<- int) { go alloc2(outCh)}func alloc2(outCh chan<- int) { func() { defer fmt。Println(“alloc-fm exit”) // 分配記憶體,假用一下 buf := make([]byte, 1024*1024*10) _ = len(buf) fmt。Println(“alloc done”) outCh <- 0 // 53行 }()}

編譯並執行以上程式碼,然後使用

go tool pprof

獲取gorourine的profile檔案。

go tool pprof http://localhost:6060/debug/pprof/goroutine

已經透過pprof命令獲取了2個goroutine的profile檔案:

$ ls/home/ubuntu/pprof/pprof。leak_demo。goroutine。001。pb。gz/home/ubuntu/pprof/pprof。leak_demo。goroutine。002。pb。gz

同heap一樣,我們可以使用

base

對比2個goroutine profile檔案:

$go tool pprof -base pprof。leak_demo。goroutine。001。pb。gz pprof。leak_demo。goroutine。002。pb。gzFile: leak_demoType: goroutineTime: May 16, 2019 at 2:44pm (CST)Entering interactive mode (type “help” for commands, “o” for options)(pprof)(pprof) topShowing nodes accounting for 20312, 100% of 20312 total flat flat% sum% cum cum% 20312 100% 100% 20312 100% runtime。gopark 0 0% 100% 20312 100% main。alloc2 0 0% 100% 20312 100% main。alloc2。func1 0 0% 100% 20312 100% runtime。chansend 0 0% 100% 20312 100% runtime。chansend1 0 0% 100% 20312 100% runtime。goparkunlock(pprof)

可以看到執行到

runtime。gopark

的goroutine數量增加了20312個。再透過002檔案,看一眼執行到

gopark

的goroutine數量,即掛起的goroutine數量:

go tool pprof pprof。leak_demo。goroutine。002。pb。gzFile: leak_demoType: goroutineTime: May 16, 2019 at 2:47pm (CST)Entering interactive mode (type “help” for commands, “o” for options)(pprof) topShowing nodes accounting for 24330, 100% of 24331 totalDropped 32 nodes (cum <= 121) flat flat% sum% cum cum% 24330 100% 100% 24330 100% runtime。gopark 0 0% 100% 24326 100% main。alloc2 0 0% 100% 24326 100% main。alloc2。func1 0 0% 100% 24326 100% runtime。chansend 0 0% 100% 24326 100% runtime。chansend1 0 0% 100% 24327 100% runtime。goparkunlock

顯示有24330個goroutine被掛起,這不是goroutine洩露這是啥?已經能確定八九成goroutine洩露了。

是什麼導致如此多的goroutine被掛起而無法退出?接下來就看怎麼定位goroutine洩露。

定位goroutine洩露的2種方法

使用pprof有2種方式,一種是web網頁,一種是

go tool pprof

命令列互動,這兩種方法檢視goroutine都支援,但有輕微不同,也有各自的優缺點。

我們先看Web的方式,再看命令列互動的方式,這兩種都很好使用,結合起來用也不錯。

Web視覺化檢視

Web方式適合web伺服器的埠能訪問的情況,使用起來方便,有2種方式:

檢視某條呼叫路徑上,當前阻塞在此goroutine的數量

檢視所有goroutine的執行棧(呼叫路徑),可以

顯示阻塞在此的時間

方式一

url請求中設定debug=1:

http://ip:port/debug/pprof/goroutine?debug=1

Go 記憶體洩漏

goroutine profile: total 32023

:32023是

goroutine的總數量

32015 @ 0x42e15a 0x42e20e 0x40534b 0x4050e5 …

:32015代表當前有32015個goroutine執行這個呼叫棧,並且停在相同位置,@後面的十六進位制,現在用不到這個資料,所以暫不深究了。

下面是當前goroutine的

呼叫棧

,列出了

函式和所在檔案的行數,這個行數對定位很有幫助

,如下:

32015 @ 0x42e15a 0x42e20e 0x40534b 0x4050e5 0x6d8559 0x6d831b 0x45abe1# 0x6d8558 main。alloc2。func1+0xf8 /home/ubuntu/heap/leak_demo。go:53# 0x6d831a main。alloc2+0x2a /home/ubuntu/heap/leak_demo。go:54

根據上面的提示,就能判斷32015個goroutine執行到

leak_demo。go

的53行:

阻塞的原因是outCh這個寫操作無法完成,outCh是無緩衝的通道,並且由於以下程式碼是死程式碼,所以goroutine始終沒有從outCh讀資料,造成outCh阻塞,進而造成無數個alloc2的goroutine阻塞,形成記憶體洩露

方式二

url請求中設定debug=2:

http://ip:port/debug/pprof/goroutine?debug=2

Go 記憶體洩漏

第2種方式和第1種方式是互補的,它可以看到每個goroutine的資訊:

goroutine 20 [chan send, 2 minutes]

:20是goroutine id,

[]

中是當前goroutine的狀態,阻塞在寫channel,並且阻塞了2分鐘,長時間執行的系統,你能看到阻塞時間更長的情況。

同時,也可以看到呼叫棧,看當前執行停到哪了:

leak_demo。go

的53行。

命令列互動式方法

Web的方法是簡單粗暴,無需登入伺服器,瀏覽器開啟看看就行了。但就像前面提的,沒有瀏覽器可訪問時,命令列互動式才是最佳的方式,並且也是手到擒來,感覺比Web一樣方便。

命令列互動式只有1種獲取goroutine profile的方法,不像Web網頁分

debug=1

debug=2

2中方式,並將profile檔案儲存到本地。

命令列只需要掌握3個命令就好了,上面介紹過了,詳細的倒回去看top, list, traces:

top

:顯示正執行到某個函式goroutine的數量

traces

:顯示所有goroutine的呼叫棧

list

:列出程式碼詳細的資訊。

總結:

10次記憶體洩露,有9次是goroutine洩露。

goroutine洩露的本質

goroutine洩露的本質是channel阻塞,無法繼續向下執行,導致此goroutine關聯的記憶體都無法釋放,進一步造成記憶體洩露。

goroutine洩露的發現和定位

利用好go pprof獲取goroutine profile檔案,然後利用3個命令top、traces、list定位記憶體洩露的原因。

goroutine洩露的場景

洩露的場景不僅限於以下兩類,但因channel相關的洩露是最多的。

channel的讀或者寫:

無緩衝channel的阻塞通常是寫操作因為沒有讀而阻塞

有緩衝的channel因為緩衝區滿了,寫操作阻塞

期待從channel讀資料,結果沒有goroutine寫

select操作,select裡也是channel操作,如果所有case上的操作阻塞,goroutine也無法繼續執行。

編碼goroutine洩露的建議

為避免goroutine洩露造成記憶體洩露,啟動goroutine前要思考清楚:

goroutine如何退出?

是否會有阻塞造成無法退出?如果有,那麼這個路徑是否會建立大量的goroutine?

pprof介紹

pprof是一個好工具,只有握好工具的正確用法,才能發揮好工具的威力,不然就算你手裡有屠龍刀,也成不了天下第一,本文就是帶你用pprof定位記憶體洩露問題。

關於Go的記憶體洩露有這麼一句話不知道你聽過沒有:

10次記憶體洩露,有9次是goroutine洩露。

我所解決的問題,也是goroutine洩露導致的記憶體洩露,所以

這篇文章主要介紹Go程式的goroutine洩露,掌握瞭如何定位和解決goroutine洩露,就掌握了記憶體洩露的大部分場景

go pprof基本知識

什麼是pprof

pprof是Go的效能分析工具,在程式執行過程中,可以記錄程式的執行資訊,可以是CPU使用情況、記憶體使用情況、goroutine執行情況等,當需要效能調優或者定位Bug時候,這些記錄的資訊是相當重要。

基本使用

使用pprof有多種方式,Go已經現成封裝好了1個:

net/http/pprof

,使用簡單的幾行命令,就可以開啟pprof,記錄執行資訊,並且提供了Web服務,能夠透過瀏覽器和命令列2種方式獲取執行資料。

看個最簡單的pprof的例子:

檔案:

golang_step_by_step/pprof/pprof/demo。go

package mainimport ( “fmt” “net/http” _ “net/http/pprof”)func main() { // 開啟pprof,監聽請求 ip := “0。0。0。0:6060” if err := http。ListenAndServe(ip, nil); err != nil { fmt。Printf(“start pprof failed on %s\n”, ip) }}

輸入網址ip:port/debug/pprof/

開啟pprof主頁,從上到下依次是

5類profile資訊

block

:goroutine的阻塞資訊,本例就擷取自一個goroutine阻塞的demo,但block為0,沒掌握block的用法

goroutine

:所有goroutine的資訊,下面的

full goroutine stack dump

是輸出所有goroutine的呼叫棧,是goroutine的debug=2,後面會詳細介紹。

heap

:堆記憶體的資訊

mutex

:鎖的資訊

threadcreate

:執行緒資訊

命令列方式

當連線在伺服器終端上的時候,是沒有瀏覽器可以使用的,Go提供了命令列的方式,能夠獲取以上5類資訊,這種方式用起來更方便。

使用命令

go tool pprof url

可以獲取指定的profile檔案,此命令會發起http請求,然後下載資料到本地,之後進入互動式模式,就像gdb一樣,可以使用命令檢視執行資訊,以下是5類請求的方式:

//下載cpu profile,預設從當前開始收集30s的cpu使用情況,需要等待30sgo tool pprof http://localhost:6060/debug/pprof/profile // 30-second CPU profilego tool pprof http://localhost:6060/debug/pprof/profile?seconds=120 // wait 120s# 下載heap profilego tool pprof http://localhost:6060/debug/pprof/heap // heap profile# 下載goroutine profilego tool pprof http://localhost:6060/debug/pprof/goroutine // goroutine profile# 下載block profilego tool pprof http://localhost:6060/debug/pprof/block // goroutine blocking profile# 下載mutex profilego tool pprof http://localhost:6060/debug/pprof/mutex

換一個Demo進行記憶體profile的展示:

檔案:

golang_step_by_step/pprof/heap/demo2。go

// 展示記憶體增長和pprof,並不是洩露package mainimport ( “fmt” “net/http” _ “net/http/pprof” “os” “time”)// 執行一段時間:fatal error: runtime: out of memoryfunc main() { // 開啟pprof go func() { ip := “0。0。0。0:6060” if err := http。ListenAndServe(ip, nil); err != nil { fmt。Printf(“start pprof failed on %s\n”, ip) os。Exit(1) } }() tick := time。Tick(time。Second / 100) var buf []byte for range tick { buf = append(buf, make([]byte, 1024*1024)。。。) }}

參考:

https://studygolang。com/articles/20519