Go 內存泄露之痛,這篇把 Go timer-After 問題根因講透了!
大家好,我是煎魚。
在評論區有小夥伴提到了經典的 timer.After 泄露問題,希望我能聊聊,這是一個不能不知的一個大 “坑”。
今天煎魚就帶大家來研討一下這個問題。
timer.After
今天是男主角是 Go 標準庫 time 所提供的 After 方法。函數簽名如下:
func After(d Duration) <-chan Time
該方法可以在一定時間(根據所傳入的 Duration)後主動返回 time.Time 類型的 channel 消息。
在常見的場景下,我們會基於此方法做一些計時器相關的功能開發,例子如下:
func main() {
ch := make(chan string)
go func() {
time.Sleep(time.Second * 3)
ch <- "腦子進煎魚了"
}()
select {
case _ = <-ch:
case <-time.After(time.Second * 1):
fmt.Println("煎魚出去了,超時了!!!")
}
}
在運行 1 秒鐘後,輸出結果:
煎魚出去了,超時了!!!
上述程序在在運行 1 秒鐘後將觸發 time.After 方法的定時消息返回,輸出了超時的結果。
坑在哪裏
從例子來看似乎非常正常,也沒什麼 “坑” 的樣子。難道是 timer.After 方法的虛晃一槍?
我們再看一個不像是有問題例子,這在 Go 工程中經常能看見,只是大家都沒怎麼關注。
代碼如下:
func main() {
ch := make(chan int, 10)
go func() {
in := 1
for {
in++
ch <- in
}
}()
for {
select {
case _ = <-ch:
// do something...
continue
case <-time.After(3 * time.Minute):
fmt.Printf("現在是:%d,我腦子進煎魚了!", time.Now().Unix())
}
}
}
在上述代碼中,我們構造了一個 for+select+channel 的一個經典的處理模式。
同時在 select+case 中調用了 time.After 方法做超時控制,避免在 channel 等待時阻塞過久,引發其他問題。
看上去都沒什麼問題,但是細心一看。在運行了一段時間後,粗暴的利用 top 命令一看:
運行了一會後,10+GB
我的 Go 工程的內存佔用竟然已經達到了 10+GB 之高,並且還在持續增長,非常可怕。
在所設置的超時時間到達後,Go 工程的內存佔用似乎一時半會也沒有要回退下去的樣子,這,到底發生了什麼事?
爲什麼
抱着一臉懵逼的煎魚,我默默的掏出我早已埋好的 PProf,這是 Go 語言中最強的性能分析剖析工具,在我出版的 《Go 語言編程之旅》特意有花大量的篇幅大面積將講解過。
在 Go 語言中,PProf 是用於可視化和分析性能分析數據的工具,PProf 以 profile.proto 讀取分析樣本的集合,並生成報告以可視化並幫助分析數據(支持文本和圖形報告)。
我們直接用 go tool pprof 分析 Go 工程中函數內存申請情況,如下圖:
PProf
從圖來分析,可以發現是不斷地在調用 time.After,從而導致計時器 time.NerTimer 的不斷創建和內存申請。
這就非常奇怪了,因爲我們的 Go 工程裏只有幾行代碼與 time 相關聯:
func main() {
...
for {
select {
...
case <-time.After(3 * time.Minute):
fmt.Printf("現在是:%d,我腦子進煎魚了!", time.Now().Unix())
}
}
}
由於 Demo 足夠的小,我們相信這就是問題代碼,但原因是什麼呢?
原因在於 for+select,再加上 time.After 的組合會導致內存泄露。因爲 for在循環時,就會調用都 select 語句,因此在每次進行 select 時,都會重新初始化一個全新的計時器(Timer)。
我們這個計時器,是在 3 分鐘後纔會被觸發去執行某些事,但重點在於計時器激活後,卻又發現和 select 之間沒有引用關係了,因此很合理的也就被 GC 給清理掉了,因爲沒有人需要 “我” 了。
要命的還在後頭,被拋棄的 time.After 的定時任務還是在時間堆中等待觸發,在定時任務未到期之前,是不會被 GC 清除的。
但很可惜,他 “永遠” 不會到期了,也就是爲什麼我們的 Go 工程內存會不斷飆高,其實是 time.After 產生的內存孤兒們導致了泄露。
解決辦法
既然我們知道了問題的根因代碼是不斷的重複創建 time.After,又沒法完整的走完釋放的閉環,那解決辦法也就有了。
改進後的代碼如下:
func main() {
timer := time.NewTimer(3 * time.Minute)
defer timer.Stop()
...
for {
select {
...
case <-timer.C:
fmt.Printf("現在是:%d,我腦子進煎魚了!", time.Now().Unix())
}
}
}
經過一段時間的摸魚後,再使用 PProf 進行採集和查看:
PProf
Go 進程的各項指標正常,完好的解決了這個內存泄露的問題。
總結
在今天這篇文章中,我們介紹了標準庫 time 的基本常規使用,同時針對 Go 小夥伴所提出的 time.After 方法的使用不當,所導致的內存泄露進行了重現和問題解析。
其根因就在於 Go 語言時間堆的處理機制和常規 for+select+time.After 組合的下意識寫法所導致的泄露。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/KSBdPkkvonSES9Z9iggElg