慎用time.After會形成內存泄漏(golang)

前言

嗨,你們好,我是asong,我今天又來了。昨天發表了一篇文章: 手把手教姐姐寫消息隊列,其中一段代碼被細心的讀者發現了有內存泄漏的危險,確實是這樣,本身沒有注意到這方面,追求完美的我,立刻進行了排查並更改了這個 bug。如今我就把這個 bug分享一下,避免小夥伴們後續踩坑。

測試代碼已經放到了github:https://github.com/asong2020/...git

歡迎star~~~github

背景

我先貼一下會發生內存泄漏的代碼段,根據代碼能夠更好的進行講解:golang

func (b *BrokerImpl) broadcast(msg interface{}, subscribers []chan interface{}) {
    count := len(subscribers)
    concurrency := 1

    switch {
    case count > 1000:
        concurrency = 3
    case count > 100:
        concurrency = 2
    default:
        concurrency = 1
    }

    pub := func(start int) {
        for j := start; j < count; j += concurrency {
            select {
            case subscribers[j] <- msg:
        case <-time.After(time.Millisecond * 5):
            case <-b.exit:
                return
            }
        }
    }
    for i := 0; i < concurrency; i++ {
        go pub(i)
    }
}

看了這段代碼,你知道是哪裏發生內存泄漏了嘛?我先來告訴你們,這裏time.After(time.Millisecond * 5)會發生內存泄漏,具體緣由嘛彆着急,咱們一步步分析。面試

驗證

咱們來寫一段代碼進行驗證,先看代碼吧:shell

package main

import (
    "fmt"
    "net/http"
    _ "net/http/pprof"
    "time"
)

/**
    time.After oom 驗證demo
 */
func main()  {
    ch := make(chan string,100)

    go func() {
        for  {
            ch <- "asong"
        }
    }()
    go func() {
        // 開啓pprof,監聽請求
        ip := "127.0.0.1:6060"
        if err := http.ListenAndServe(ip, nil); err != nil {
            fmt.Printf("start pprof failed on %s\n", ip)
        }
    }()

    for  {
        select {
        case <-ch:
        case <- time.After(time.Minute * 3):
        }
    }
}

這段代碼咱們該怎麼驗證呢?看代碼估計大家也猜到了,沒錯就是go tool pprof,可能有些小夥伴不知道這個工具,那我簡單介紹一下基本使用,不作詳細介紹,更多功能可自行學習。設計模式

再介紹pprof以前,咱們其實還有一種方法,能夠測試此段代碼是否發生了內存泄漏,就是使用top命令查看該進程佔用cpu狀況,輸入top命令,咱們會看到cpu一直在飆升,這種方法能夠肯定發生內存泄漏,可是不能肯定發生問題的代碼在哪部分,因此最好仍是使用pprof工具進行分析,他能夠肯定具體出現問題的代碼。瀏覽器

proof 介紹

定位goroutine泄露會使用到pprof,pprof是Go的性能工具,在程序運行過程當中,能夠記錄程序的運行信息,能夠是CPU使用狀況、內存使用狀況、goroutine運行狀況等,當須要性能調優或者定位Bug時候,這些記錄的信息是至關重要。使用pprof有多種方式,Go已經現成封裝好了1個:net/http/pprof,使用簡單的幾行命令,就能夠開啓pprof,記錄運行信息,而且提供了Web服務,可以經過瀏覽器和命令行2種方式獲取運行數據。架構

基本使用也很簡單,看這段代碼:框架

package main

import (
    "fmt"
    "net/http"
    _ "net/http/pprof"
)

func main() {
    // 開啓pprof,監聽請求
    ip := "127.0.0.1:6060"
    if err := http.ListenAndServe(ip, nil); err != nil {
        fmt.Printf("start pprof failed on %s\n", ip)
    }
}

使用仍是很簡單的吧,這樣咱們就開啓了go tool pprof。下面咱們開始實踐來講明pprof的使用。函數

驗證流程

首先咱們先運行個人測試代碼,而後打開咱們的終端輸入以下命令:

$ go tool pprof http://127.0.0.1:6060/debug/pprof/profile -seconds 60

這裏的做用是使用go tool pprof命令獲取指定的profile文件,採集60s的CPU使用狀況,會將採集的數據下載到本地,以後進入交互模式,可使用命令行查看運行信息。

進入命令行交互模式後,咱們輸入top命令查看內存佔用狀況。

<img src="./images/top.png" style="zoom:50%;" />

第一次接觸的不知道這些參數的意思,咱們先來解釋一下各個參數吧,top會列出5個統計數據:

  • flat: 本函數佔用的內存量。
  • flat%: 本函數內存佔使用中內存總量的百分比。
  • sum%: 前面每一行flat百分比的和,好比第2行雖然的100% 是 100% + 0%。
  • cum: 是累計量,加入main函數調用了函數f,函數f佔用的內存量,也會記進來。
  • cum%: 是累計量佔總量的百分比。

這個咱們能夠看出time.NewTimer佔用內存很高,這麼看也不是很直觀,咱們可使用火焰圖來查看,打開終端輸入以下命令便可:

# pprof.samples.cpu.001.pb.gz     這個要看大家輸入上面命令生成的文件名
$ go tool pprof -http=:8081 ~/pprof/pprof.samples.cpu.001.pb.gz

瀏覽器會自動彈出,看下圖:

<img src="./images/pprof.png" style="zoom:50%;" />

咱們能夠看到time.NewTimer這個方法致使調用鏈佔了很長時間,佔用CPU很長時間,這種方法能夠幫我定位到出現問題的代碼,仍是很方便的。知道了什麼問題,接下來咱們就來分析一下緣由吧。

緣由分析

分析具體緣由以前,咱們先來了解一下go中兩個定時器tickertimer,由於不知道這兩個的使用,確實不知道具體緣由。

ticker和timer

Golang中time包有兩個定時器,分別爲ticker 和 timer。二者均可以實現定時功能,但各自都有本身的使用場景。

咱們來看一下他們的區別:

  • ticker定時器表示每隔一段時間就執行一次,通常可執行屢次。
  • timer定時器表示在一段時間後執行,默認狀況下只執行一次,若是想再次執行的話,每次都須要調用 time.Reset()方法,此時效果相似ticker定時器。同時也能夠調用stop()方法取消定時器
  • timer定時器比ticker定時器多一個Reset()方法,二者都有Stop()方法,表示中止定時器,底層都調用了stopTimer()函數。

緣由

上面咱們了介紹go的兩個定時器,如今咱們回到咱們的問題,咱們的代碼使用time.After來作超時控制,time.After其實內部調用的就是timer定時器,根據timer定時器的特色,具體緣由就很明顯了。

這裏咱們的定時時間設置的是3分鐘, 在for循環每次select的時候,都會實例化一個一個新的定時器。該定時器在3分鐘後,纔會被激活,可是激活後已經跟select無引用關係,被gc給清理掉。這裏最關鍵的一點是在計時器觸發以前,垃圾收集器不會回收 Timer,換句話說,被遺棄的time.After定時任務仍是在時間堆裏面,定時任務未到期以前,是不會被gc清理的,因此這就是會形成內存泄漏的緣由。每次循環實例化的新定時器對象須要3分鐘纔會可能被GC清理掉,若是咱們把上面代碼中的3分鐘改小點,會有所改善,可是仍存在風險,下面咱們就使用正確的方法來修復這個bug。

修復bug

方法一:使用timer定時器

time.After雖然調用的是timer定時器,可是他沒有使用time.Reset() 方法再次激活定時器,因此每一次都是新建立的實例,纔會形成的內存泄漏,咱們添加上time.Reset每次從新激活定時器,便可完成解決問題。

func (b *BrokerImpl) broadcast(msg interface{}, subscribers []chan interface{}) {
    count := len(subscribers)
    concurrency := 1

    switch {
    case count > 1000:
        concurrency = 3
    case count > 100:
        concurrency = 2
    default:
        concurrency = 1
    }

    //採用Timer 而不是使用time.After 緣由:time.After會產生內存泄漏 在計時器觸發以前,垃圾回收器不會回收Timer
    idleDuration := 5 * time.Millisecond
    idleTimeout := time.NewTimer(idleDuration)
    defer idleTimeout.Stop()
    pub := func(start int) {
        for j := start; j < count; j += concurrency {
            idleTimeout.Reset(idleDuration)
            select {
            case subscribers[j] <- msg:
            case <-idleTimeout.C:
            case <-b.exit:
                return
            }
        }
    }
    for i := 0; i < concurrency; i++ {
        go pub(i)
    }
}

方法二:ticker定時器

直接使用ticker定時器就好啦,由於ticker每隔一段時間就執行一次,通常可執行屢次,至關於timer定時器調用了time.Reset

func (b *BrokerImpl) broadcast(msg interface{}, subscribers []chan interface{}) {
    count := len(subscribers)
    concurrency := 1

    switch {
    case count > 1000:
        concurrency = 3
    case count > 100:
        concurrency = 2
    default:
        concurrency = 1
    }

    //採用Timer 而不是使用time.After 緣由:time.After會產生內存泄漏 在計時器觸發以前,垃圾回收器不會回收Timer
    idleTimeout := time.time.NewTicker(5 * time.Millisecond)
    defer idleTimeout.Stop()
    pub := func(start int) {
        for j := start; j < count; j += concurrency {
            select {
            case subscribers[j] <- msg:
            case <-idleTimeout.C:
            case <-b.exit:
                return
            }
        }
    }
    for i := 0; i < concurrency; i++ {
        go pub(i)
    }
}

總結

不知道這篇文章大家看懂了嗎?沒看懂的能夠下載測試代碼,本身測試一下,更能加深印象的呦~~~

這篇文章主要介紹了排查問題的思路,go tool pprof這個工具很重要,遇到性能和內存gc問題,均可以使用golang tool pprof來排查分析問題。不會的小夥伴仍是要學起來的呀~~~

最後感謝指出問題的那位網友,讓我又有所收穫,很是感謝,因此說嘛,仍是要共同進步的呀,你不會的,並不表明別人不會,虛心令人進步嘛,加油各位小夥伴們~~~

結尾給你們發一個小福利吧,最近我在看[微服務架構設計模式]這一本書,講的很好,本身也收集了一本PDF,有須要的小夥能夠到自行下載。獲取方式:關注公衆號:[Golang夢工廠],後臺回覆:[微服務],便可獲取。

我翻譯了一份GIN中文文檔,會按期進行維護,有須要的小夥伴後臺回覆[gin]便可下載。

我是asong,一名普普統統的程序猿,讓我一塊兒慢慢變強吧。我本身建了一個golang交流羣,有須要的小夥伴加我vx,我拉你入羣。歡迎各位的關注,咱們下期見~~~

推薦往期文章:

相關文章
相關標籤/搜索