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