golang中的定時器由于沒有正確釋放導(dǎo)致內(nèi)存和cpu使用率異常

本文首發(fā)于我的個人網(wǎng)站: https://pengrl.com/p/1785/

前言

本篇文章介紹如何正確釋放golang中的定時器,如果定時器沒有正確釋放,在一些高性能場景可能導(dǎo)致內(nèi)存和cpu占用異常。
這是一個很容易踩中的坑。
涉及到package time中的以下內(nèi)容:

func After(d Duration) <-chan Time
func NewTimer(d Duration) *Timer
func NewTicker(d Duration) *Ticker

先把結(jié)論放前面:
在高性能場景下,不應(yīng)該使用time.After,而應(yīng)該使用New.Timer并在不再使用該Timer后(無論該Timer是否被觸發(fā))調(diào)用Timer的Stop方法來及時釋放資源。 不然內(nèi)存資源可能被延時釋放。
使用time.NewTicker時,在Ticker對象不再使用后(無論該Ticker是否被觸發(fā)過),一定要調(diào)用Stop方法,否則會造成內(nèi)存和cpu泄漏。

注意,本篇文章前后有關(guān)聯(lián),需要順序閱讀。

time.After

官方文檔說明

func After(d Duration) <-chan Time

After waits for the duration to elapse and then sends the current time on the returned channel. It is equivalent to NewTimer(d).C.

由此我們可以知道,After函數(shù)等待參數(shù)duration所指定的時間到達(dá)后才返回,返回的chan中Time的值為當(dāng)前時間。
After函數(shù)等價于time.NewTimer(d).C。

我們再看After函數(shù)的源碼實(shí)現(xiàn):

func After(d Duration) <-chan Time {
    return NewTimer(d).C
}

確實(shí)如上面文檔所描述,在內(nèi)部生成了一個time.Timer的匿名對象,并返回它的chan Time數(shù)據(jù)成員C。

官方文檔中還貼了個小例子:

select {
case m := <-c:
    handle(m)
case <-time.After(5 * time.Minute):
    fmt.Println("timed out")
}

這個例子演示了一小段消費(fèi)者代碼,當(dāng)消費(fèi)協(xié)程在5分鐘內(nèi)從channel c上接收到數(shù)據(jù),則執(zhí)行消費(fèi)業(yè)務(wù)邏輯handle(m),當(dāng)超過5分鐘未接收到數(shù)據(jù),則打印超時。

那么問題來了,如果消費(fèi)協(xié)程在5分鐘內(nèi)從channel c上接收到了數(shù)據(jù),time.After內(nèi)部的time.Timer以及Timer內(nèi)部的channel何時釋放?

我們來看以下這段源碼中的注釋

// The underlying Timer is not recovered by the garbage collector
// until the timer fires. If efficiency is a concern, use NewTimer
// instead and call Timer.Stop if the timer is no longer needed.

意思是,After函數(shù)底層所使用的Timer直到timer被觸發(fā)才會被golang的垃圾回收器處理。如果是高性能場景,應(yīng)該使用time.NewTimer替代After函數(shù),并且在timer不再使用時調(diào)用Timer.Stop。

從程序語意來說,官方文檔所舉例子中的time.After的case對應(yīng)的fmt.Println("timed out")肯定是不會被執(zhí)行了,那么注釋中的the timer fires是什么時候呢?

寫demo程序驗(yàn)證

demo程序邏輯

申請10萬個協(xié)程作為生產(chǎn)者,10萬個協(xié)程作為消費(fèi)者,10萬個channel,每1個消費(fèi)者和唯一1個生成者通過1個channel相互關(guān)聯(lián)。

測試場景一,最普通的生產(chǎn)消費(fèi)模型。消費(fèi)協(xié)程不設(shè)置超時,所有的消費(fèi)協(xié)程都被生產(chǎn)協(xié)程通過channel喚醒。
測試場景二,消費(fèi)協(xié)程設(shè)置超時,但是超時設(shè)置的足夠長,長到所有的消費(fèi)協(xié)程依然都被channel喚醒。

demo程序源碼

package main

import (
    "fmt"
    "log"
    "runtime"
    "sync"
    "sync/atomic"
    "time"
)

var allDone sync.WaitGroup
var consumeCount uint32
var timeoutCount uint32

func nowStats() string {
    var ms runtime.MemStats
    runtime.ReadMemStats(&ms)
    return fmt.Sprintf("Alloc:%d(bytes) HeapIdle:%d(bytes) HeapReleased:%d(bytes) NumGoroutine:%d", ms.Alloc, ms.HeapIdle, ms.HeapReleased, runtime.NumGoroutine())
}

func produce(ch chan int) {
    ch <- 1
}

func consume(ch chan int) {
    //t := time.NewTimer(5 * time.Second)
    select {
    case <-ch:
        atomic.AddUint32(&consumeCount, 1)
    case <-time.After(5 * time.Second):
        //case <- t.C:
        atomic.AddUint32(&timeoutCount, 1)
    }
    //t.Stop()
    allDone.Done()
}

func main() {
    log.Printf("program begin. %s\n", nowStats())

    for i := 0; i < 1000*1000; i++ {
        allDone.Add(1)
        ch := make(chan int, 1)
        go consume(ch)
        go produce(ch)
    }
    allDone.Wait()

    runtime.GC()
    log.Printf("all consumer done. consume count:%d timeoutcount:%d stats:%s\n",
        atomic.LoadUint32(&consumeCount), atomic.LoadUint32(&timeoutCount), nowStats())
    log.Printf("sleep...")
    time.Sleep(10 * time.Second)

    runtime.GC()
    log.Printf("program end. %s\n", nowStats())
}

如何觀察

在兩個場景下,我們都使用golang中的runtime.MemStats(使用方法可參考我之前寫的一篇文章 https://pengrl.com/p/24169/ )來觀察demo程序的內(nèi)存變化。

以下測試數(shù)據(jù)基于的環(huán)境為centos go1.12.1

第一個場景

程序輸出如下:

2019/04/08 11:13:26 program begin. Alloc:44040(bytes) HeapIdle:66666496(bytes) HeapReleased:0(bytes) NumGoroutine:1
2019/04/08 11:13:27 all consumer done. consume count:1000000 timeoutcount:0 stats:Alloc:6110368(bytes) HeapIdle:59203584(bytes) HeapReleased:0(bytes) NumGoroutine:1
2019/04/08 11:13:27 sleep...
2019/04/08 11:13:37 program end. Alloc:6111968(bytes) HeapIdle:59179008(bytes) HeapReleased:0(bytes) NumGoroutine:1

分析:
在所有的消費(fèi)協(xié)程消費(fèi)結(jié)束后(all consumer done.):
Alloc:6110368 表示demo程序申請還未被垃圾回收器回收的內(nèi)存為5967KB
HeapIdle:59203584 表示demo程序的垃圾回收器持有且沒有歸還給操作系統(tǒng)的內(nèi)存為56M

結(jié)論:該場景的內(nèi)存使用正常。

第二個場景

程序輸出如下:

2019/04/08 11:29:49 program begin. Alloc:44040(bytes) HeapIdle:66666496(bytes) HeapReleased:0(bytes) NumGoroutine:1
2019/04/08 11:29:52 all consumer done. consume count:1000000 timeoutcount:0 stats:Alloc:227885584(bytes) HeapIdle:103030784(bytes) HeapReleased:0(bytes) NumGoroutine:1
2019/04/08 11:29:52 sleep...
2019/04/08 11:30:02 program end. Alloc:19885920(bytes) HeapIdle:311320576(bytes) HeapReleased:0(bytes) NumGoroutine:1

分析:
在所有的消費(fèi)協(xié)程消費(fèi)結(jié)束后(all consumer done.):
Alloc:227885584(bytes) HeapIdle:103030784(bytes)
即demo程序申請還未被垃圾回收器回收的內(nèi)存為217M,demo程序的垃圾回收器持有且沒有歸還給操作系統(tǒng)的內(nèi)存為98M
在等待10秒后(program end.):
Alloc:19885920(bytes) HeapIdle:311320576(bytes)
即demo程序申請還未被垃圾回收器回收的內(nèi)存下降為18M,demo程序的垃圾回收器持有且沒有歸還給操作系統(tǒng)的內(nèi)存上升為296M

結(jié)論:
在所有的定時器都沒有被觸發(fā)的場景。如果在定時器設(shè)置的時間內(nèi)(5秒),程序依然持有這部分內(nèi)存,如果在超過定時器設(shè)置的時間后(10秒),程序內(nèi)存被釋放。
但何時進(jìn)一步釋放給操作系統(tǒng)由垃圾回收器決定。

time.NewTimer

我們將上面的demo程序中的time.After更換成time.NewTimer,并調(diào)用timer的Stop方法。相關(guān)的代碼修改如下:

func consume(ch chan int) {
    t := time.NewTimer(5 * time.Second)
    select {
    case <-ch:
        atomic.AddUint32(&consumeCount, 1)
    case <-t.C:
        atomic.AddUint32(&timeoutCount, 1)
    }
    t.Stop()
    allDone.Done()
}

程序輸出如下:

2019/04/08 13:44:44 program begin. Alloc:44040(bytes) HeapIdle:66560000(bytes) HeapReleased:0(bytes) NumGoroutine:1
2019/04/08 13:44:47 all consumer done. consume count:1000000 timeoutcount:0 stats:Alloc:11036656(bytes) HeapIdle:119873536(bytes) HeapReleased:49831936(bytes) NumGoroutine:1
2019/04/08 13:44:47 sleep...
2019/04/08 13:44:57 program end. Alloc:11036864(bytes) HeapIdle:119750656(bytes) HeapReleased:49831936(bytes) NumGoroutine:1

分析:
在所有的消費(fèi)協(xié)程消費(fèi)結(jié)束后:
Alloc:11036656(bytes) HeapIdle:119873536(bytes)
即程序申請還未被垃圾回收器回收的內(nèi)存為10M,程序的垃圾回收器持有且沒有歸還給操作系統(tǒng)的內(nèi)存為114M

結(jié)論:
說明使用time.NewTimer并調(diào)用Stop方法后,內(nèi)存會立即被垃圾回收器回收。

何時需要調(diào)用Stop方法

func (t *Timer) Stop() bool

Stop prevents the Timer from firing. It returns true if the call stops the timer, false if the timer has already expired or been stopped.
調(diào)用Stop方法可阻止Timer被觸發(fā)。如果Stop調(diào)用停止了timer,那么返回true,如果timer已經(jīng)被觸發(fā)過或者已經(jīng)被停止了,那么返回false。

結(jié)論:
Stop方法在timer未被觸發(fā)或已被觸發(fā)或已被關(guān)閉的情況下都可以被調(diào)用。在高性能場景下,我們應(yīng)該統(tǒng)一在select結(jié)束后,即timer不再使用后調(diào)用Stop方法。

time.NewTicker

time.NewTicker和time.NewTimer的區(qū)別是Timer只被觸發(fā)一次,而Ticker會周期性持續(xù)觸發(fā)。

我們將demo程序的NewTimer修改成NewTicker,如果不調(diào)用Stop方法,那么不光內(nèi)存得不到釋放,cpu也會周期性的被大量占用,即使我們已經(jīng)不再使用那個Ticker對象了。(即對應(yīng)的golang底層的定時器還在工作)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容