什麼是記憶體洩露
記憶體洩露指的是程式執行過程中已不再使用的記憶體,沒有被釋放掉,導致這些記憶體無法被使用,直到程式結束這些記憶體才被釋放的問題。
Go雖然有GC來回收不再使用的堆記憶體,減輕了開發人員對記憶體的管理負擔,但這並不意味著Go程式不再有記憶體洩露問題。在Go程式中,如果沒有Go語言的程式設計思維,也不遵守良好的程式設計實踐,就可能埋下隱患,造成記憶體洩露問題。
怎麼發現記憶體洩露
在Go中發現記憶體洩露有2種方法,一個是通用的監控工具,另一個是go pprof:
監控工具
:固定週期對程序的記憶體佔用情況進行取樣,資料視覺化後,根據記憶體佔用走勢(持續上升),很容易發現是否發生記憶體洩露。
go pprof
:適合沒有監控工具的情況,使用Go提供的pprof工具判斷是否發生記憶體洩露。
這2種方式分別介紹一下。
監控工具檢視程序內在佔用情況
如果使用雲平臺部署Go程式
,雲平臺都提供了記憶體檢視的工具,可以檢視OS的記憶體佔用情況和某個程序的記憶體佔用情況,比如阿里雲,我們在1個雲主機上只部署了1個Go服務,所以OS的記憶體佔用情況,基本是也反映了程序記憶體佔用情況,OS記憶體佔用情況如下,可以看到
隨著時間的推進,記憶體的佔用率在不斷的提高,這是記憶體洩露的最明顯現象
:
如果沒有云平臺這種記憶體監控工具,可以製作一個簡單的記憶體記錄工具。
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 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在定位記憶體洩露這件事上,發揮的作用不大
。
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
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
第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