難受,生產 Go timer.After 內存泄露之痛!

微信搜索【 腦子進煎魚了】關注這一隻爆肝煎魚。本文 GitHub github.com/eddycjy/blog 已收錄,有個人系列文章、資料和開源 Go 圖書。

你們好,我是煎魚。git

前幾天分享了一篇 Go timer 源碼解析的文章《難以駕馭的 Go timer,一文帶你參透計時器的奧祕》。github

在評論區有小夥伴提到了經典的 timer.After 泄露問題,但願我能聊聊,這是一個不能不知的一個大 「坑」。golang

今天這篇文章煎魚就帶你們來研討一下這個問題。面試

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 組合的下意識寫法所致使的泄露。

忽然想起我有一個朋友在公司裏有看到過相似的代碼,在生產踩過這個坑,半夜被告警抓起來...

不知道你在平常工做中有沒有遇到過類似的問題呢,歡迎留言區評論和交流。

文章持續更新,能夠微信搜【腦子進煎魚了】閱讀,回覆【 000】有我準備的一線大廠面試算法題解和資料;本文 GitHub github.com/eddycjy/blog 已收錄,歡迎 Star 催更。
相關文章
相關標籤/搜索