本文首發(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底層的定時器還在工作)