這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
2016年,Go語言在Tiobe程式設計語言熱門排行榜上位次的大幅躥升(2016年12月份Tiobe榜單:go位列第16位,Rating值:1.939%)。與此同時,我們也能切身感受到Go語言在世界範圍蓬勃發展,其在中國地界兒上的發展更是尤為猛烈^0^:For gopher們的job變多了、網上關於Go的資料也大有“汗牛充棟”之勢。作為職業Gopher^0^,要為這個生態添磚加瓦,就要多思考、多總結,關鍵還要做到“遇到了問題,就要說出來,給出你的見解”。每篇文章都有自己的切入角度和關注重點,因此Gopher們也無需過於擔憂資料的“重複”。
這次,我來說說在使用Go標準庫中Timer的Reset方法時遇到的問題。
一、關於Timer原理的一些說明
在網路編程方面,從使用者視角看,golang表象上是一種“阻塞式”網路編程範式,而支撐這種“阻塞式”範式的則是內建於go編譯後的executable file中的runtime。runtime利用網路IO多工機制實現多個進行網路通訊的goroutine的合理調度。goroutine中的執行函數則相當於你在傳統C編程中傳給epoll機制的回呼函數。golang一定層度上消除了在這方面“回調”這種“逆向思維”給你帶來的心智負擔,簡化了網路編程的複雜性。
但長時間“阻塞”顯然不能滿足大多數業務情景,因此還需要一定的逾時機制。比如:在socket層面,我們通過顯式設定net.Dialer的Timeout或使用SetReadDeadline、SetWriteDeadline以及SetDeadline;在應用程式層協議,比如http,client通過設定timeout參數,server通過TimeoutHandler來限制操作的time limit。這些timeout機制,有些是通過runtime的網路多工timeout機制實現,有些則是通過Timer實現的。
標準庫中的Timer讓使用者可以定義自己的逾時邏輯,尤其是在應對select處理多個channel的逾時、單channel讀寫的逾時等情形時尤為方便。
1、Timer的建立
Timer是一次性的時間觸發事件,這點與Ticker不同,後者則是按一定時間間隔持續觸發時間事件。Timer常見的使用情境如下:
情境1:t := time.AfterFunc(d, f)情境2:select { case m := <-c: handle(m) case <-time.After(5 * time.Minute): fmt.Println("timed out")}或:t := time.NewTimer(5 * time.Minute)select { case m := <-c: handle(m) case <-t.C: fmt.Println("timed out")}
從這兩個情境中,我們可以看到Timer三種建立姿勢:
t:= time.NewTimer(d)t:= time.AfterFunc(d, f)c:= time.After(d)
雖然姿勢不同,但背後的原理則是相通的。
Timer有三個要素:
* 定時時間:也就是那個d* 觸發動作:也就是那個f* 時間channel: 也就是t.C
對於AfterFunc這種建立方式而言,Timer就是在逾時(timer expire)後,執行函數f,此種情況下:時間channel無用。
//$GOROOT/src/time/sleep.gofunc AfterFunc(d Duration, f func()) *Timer { t := &Timer{ r: runtimeTimer{ when: when(d), f: goFunc, arg: f, }, } startTimer(&t.r) return t}func goFunc(arg interface{}, seq uintptr) { go arg.(func())()}
注意:從AfterFunc源碼可以看到,外面傳入的f參數並非直接賦值給了內部的f,而是作為wrapper function:goFunc的arg傳入的。而goFunc則是啟動了一個新的goroutine來執行那個外部傳入的f。這是因為timer expire對應的事件處理函數的執行是在go runtime內唯一的timer events maintenance goroutine: timerproc中。為了不block timerproc的執行,必須啟動一個新的goroutine。
//$GOROOT/src/runtime/time.gofunc timerproc() { timers.gp = getg() for { lock(&timers.lock) ... ... f := t.f arg := t.arg seq := t.seq unlock(&timers.lock) if raceenabled { raceacquire(unsafe.Pointer(t)) } f(arg, seq) lock(&timers.lock) } ... ... unlock(&timers.lock) }}
而對於NewTimer和After這兩種建立方法,則是Timer在逾時(timer expire)後,執行一個標準庫中內建的函數:sendTime。sendTime將當前當前事件send到timer的時間Channel中,那麼說這個動作不會阻塞到timerproc的執行嗎?答案肯定是不會的,其原因就在下面代碼中:
//$GOROOT/src/time/sleep.gofunc NewTimer(d Duration) *Timer { c := make(chan Time, 1) t := &Timer{ C: c, ... ... } ... ... return t}func sendTime(c interface{}, seq uintptr) { // Non-blocking send of time on c. // Used in NewTimer, it cannot block anyway (buffer). // Used in NewTicker, dropping sends on the floor is // the desired behavior when the reader gets behind, // because the sends are periodic. select { case c.(chan Time) <- Now(): default: }}
我們看到NewTimer中建立了一個buffered channel,size = 1。正常情況下,當timer expire,t.C無論是否有goroutine在read,sendTime都可以non-block的將目前時間發送到C中;同時,我們看到sendTime還加了雙保險:通過一個select判斷c buffer是否已滿,一旦滿了,直接退出,依然不會block,這種情況在reuse active timer時可能會遇到。
2、Timer的資源釋放
很多Go初學者在使用Timer時都會擔憂Timer的建立會佔用系統資源,比如:
有人會認為:建立一個Timer後,runtime會建立一個單獨的Goroutine去計時並在expire後發送目前時間到channel裡。
還有人認為:建立一個timer後,runtime會申請一個os層級的定時器資源去完成計時工作。
實際情況並不是這樣。恰好近期gopheracademy blog發布了一篇 《How Do They Do It: Timers in Go》,通過對timer源碼的分析,講述了timer的原理,大家可以看看。
go runtime實際上僅僅是啟動了一個單獨的goroutine,運行timerproc函數,維護了一個”最小堆”,定期wake up後,讀取堆頂的timer,執行timer對應的f函數,並移除該timer element。建立一個Timer實則就是在這個最小堆中添加一個element,Stop一個timer,則是從堆中刪除對應的element。
同時,從上面的兩個Timer常見的使用情境中代碼來看,我們並沒有顯式的去釋放什麼。從上一節我們可以看到,Timer在建立後可能佔用的資源還包括:
- 0或一個Channel
- 0或一個Goroutine
這些資源都會在timer使用後被GC回收。
綜上,作為Timer的使用者,我們要做的就是盡量減少在使用Timer時對最小堆管理goroutine和GC的壓力即可,即:及時調用timer的Stop方法從最小堆刪除timer element(如果timer 沒有expire)以及reuse active timer。
BTW,這裡還有一篇討論go Timer精度的文章,大家可以拜讀一下。
二、Reset到底存在什麼問題?
鋪墊了這麼多,主要還是為了說明Reset的使用問題。什麼問題呢?我們來看下面的例子。這些例子主要是為了說明Reset問題,現實中很可能大家都不這麼寫代碼邏輯。當前環境:go version go1.7 darwin/amd64。
1、example1
我們的第一個example如下:
//example1.gofunc main() { c := make(chan bool) go func() { for i := 0; i < 5; i++ { time.Sleep(time.Second * 1) c <- false } time.Sleep(time.Second * 1) c <- true }() go func() { for { // try to read from channel, block at most 5s. // if timeout, print time event and go on loop. // if read a message which is not the type we want(we want true, not false), // retry to read. timer := time.NewTimer(time.Second * 5) defer timer.Stop() select { case b := <-c: if b == false { fmt.Println(time.Now(), ":recv false. continue") continue } //we want true, not false fmt.Println(time.Now(), ":recv true. return") return case <-timer.C: fmt.Println(time.Now(), ":timer expired") continue } } }() //to avoid that all goroutine blocks. var s string fmt.Scanln(&s)}
example1.go的邏輯大致就是 一個consumer goroutine試圖從一個channel裡讀出true,如果讀出false或timer expire,那麼繼續try to read from the channel。這裡我們每次迴圈都建立一個timer,並在go routine結束後Stop該timer。另外一個producer goroutine則負責生產訊息,並發送到channel中。consumer中實際發生的行為取決於producer goroutine的發送行為。
example1.go執行的結果如下:
$go run example1.go2016-12-21 14:52:18.657711862 +0800 CST :recv false. continue2016-12-21 14:52:19.659328152 +0800 CST :recv false. continue2016-12-21 14:52:20.661031612 +0800 CST :recv false. continue2016-12-21 14:52:21.662696502 +0800 CST :recv false. continue2016-12-21 14:52:22.663531677 +0800 CST :recv false. continue2016-12-21 14:52:23.665210387 +0800 CST :recv true. return
輸出如預期。但在這個過程中,我們新建立了6個Timer。
2、example2
如果我們不想重複建立這麼多Timer執行個體,而是reuse現有的Timer執行個體,那麼我們就要用到Timer的Reset方法,見下面example2.go,考慮篇幅,這裡僅列出consumer routine代碼,其他保持不變:
//example2.go.... ...// consumer routine go func() { // try to read from channel, block at most 5s. // if timeout, print time event and go on loop. // if read a message which is not the type we want(we want true, not false), // retry to read. timer := time.NewTimer(time.Second * 5) for { // timer is active , not fired, stop always returns true, no problems occurs. if !timer.Stop() { <-timer.C } timer.Reset(time.Second * 5) select { case b := <-c: if b == false { fmt.Println(time.Now(), ":recv false. continue") continue } //we want true, not false fmt.Println(time.Now(), ":recv true. return") return case <-timer.C: fmt.Println(time.Now(), ":timer expired") continue } } }()... ...
按照go 1.7 doc中關於Reset使用的建議:
To reuse an active timer, always call its Stop method first and—if it had expired—drain the value from its channel. For example:if !t.Stop() { <-t.C}t.Reset(d)
我們改造了example1,形成example2的代碼。由於producer行為並未變更,實際example2執行時,每次迴圈Timer在被Reset之前都沒有expire,也沒有fire a time to channel,因此timer.Stop的調用均返回true,即成功將timer從“最小堆”中移除。example2的執行結果如下:
$go run example2.go2016-12-21 15:10:54.257733597 +0800 CST :recv false. continue2016-12-21 15:10:55.259349877 +0800 CST :recv false. continue2016-12-21 15:10:56.261039127 +0800 CST :recv false. continue2016-12-21 15:10:57.262770422 +0800 CST :recv false. continue2016-12-21 15:10:58.264534647 +0800 CST :recv false. continue2016-12-21 15:10:59.265680422 +0800 CST :recv true. return
和example1並無二致。
3、example3
現在producer routine的發送行為發生了變更:從以前每隔1s發送一次資料變成了每隔7s發送一次資料,而consumer routine不變:
//example3.go//producer routine go func() { for i := 0; i < 10; i++ { time.Sleep(time.Second * 7) c <- false } time.Sleep(time.Second * 7) c <- true }()
我們來看看example3.go的執行結果:
$go run example3.go2016-12-21 15:14:32.764410922 +0800 CST :timer expired
程式hang住了。你能猜到在哪裡hang住的嗎?對,就是在drain t.C的時候hang住了:
// timer may be not active and may not fired if !timer.Stop() { <-timer.C //drain from the channel } timer.Reset(time.Second * 5)
producer的發送行為發生了變化,Comsumer routine在收到第一個資料前有了一次time expire的事件,for loop回到loop的開始端。這時timer.Stop函數返回的不再是true,而是false,因為timer已經expire,最小堆中已經不包含該timer了,Stop在最小堆中找不到該timer,返回false。於是example3代碼嘗試抽幹(drain)timer.C中的資料。但timer.C中此時並沒有資料,於是routine block在channel recv上了。
在Go 1.8以前版本中,很多人遇到了類似的問題,並提出issue,比如:
time: Timer.Reset is not possible to use correctly #14038
不過go team認為這還是文檔中對Reset的使用描述不夠充分導致的,於是在Go 1.8中對Reset方法的文檔做了補充,Go 1.8 beta2中Reset方法的文檔改為了:
Resetting a timer must take care not to race with the send into t.C that happens when the current timer expires. If a program has already received a value from t.C, the timer is known to have expired, and t.Reset can be used directly. If a program has not yet received a value from t.C, however, the timer must be stopped and—if Stop reports that the timer expired before being stopped—the channel explicitly drained:if !t.Stop() { <-t.C}t.Reset(d)
大致意思是:如果明確time已經expired,並且t.C已經被取空,那麼可以直接使用Reset;如果程式之前沒有從t.C中讀取過值,這時需要首先調用Stop(),如果返回true,說明timer還沒有expire,stop成功刪除timer,可直接reset;如果返回false,說明stop前已經expire,需要顯式drain channel。
4、example4
我們的example3就是“time已經expired,並且t.C已經被取空,那麼可以直接使用Reset ”這第一種情況,我們應該直接reset,而不用顯式drain channel。如何將這兩種情形合二為一,很直接的想法就是增加一個開關變數isChannelDrained,標識timer.C是否已經被取空,如果取空,則直接調用Reset。如果沒有,則drain Channel。
增加一個變數總是麻煩的,RussCox也給出一個未經詳盡驗證的方法,我們來看看用這種方法改造的example4.go:
//example4.go//consumer go func() { // try to read from channel, block at most 5s. // if timeout, print time event and go on loop. // if read a message which is not the type we want(we want true, not false), // retry to read. timer := time.NewTimer(time.Second * 5) for { // timer may be not active, and fired if !timer.Stop() { select { case <-timer.C: //try to drain from the channel default: } } timer.Reset(time.Second * 5) select { case b := <-c: if b == false { fmt.Println(time.Now(), ":recv false. continue") continue } //we want true, not false fmt.Println(time.Now(), ":recv true. return") return case <-timer.C: fmt.Println(time.Now(), ":timer expired") continue } } }()
執行結果:
$go run example4.go2016-12-21 15:38:16.704647957 +0800 CST :timer expired2016-12-21 15:38:18.703107177 +0800 CST :recv false. continue2016-12-21 15:38:23.706665507 +0800 CST :timer expired2016-12-21 15:38:25.705314522 +0800 CST :recv false. continue2016-12-21 15:38:30.70900638 +0800 CST :timer expired2016-12-21 15:38:32.707482917 +0800 CST :recv false. continue2016-12-21 15:38:37.711260142 +0800 CST :timer expired2016-12-21 15:38:39.709668705 +0800 CST :recv false. continue2016-12-21 15:38:44.71337522 +0800 CST :timer expired2016-12-21 15:38:46.710880007 +0800 CST :recv false. continue2016-12-21 15:38:51.713813305 +0800 CST :timer expired2016-12-21 15:38:53.713063822 +0800 CST :recv true. return
我們利用一個select來包裹channel drain,這樣無論channel中是否有資料,drain都不會阻塞住。看似問題解決了。
5、競爭條件
如果你看過timerproc的代碼,你會發現其中的這樣一段代碼:
// go1.7// $GOROOT/src/runtime/time.go f := t.f arg := t.arg seq := t.seq unlock(&timers.lock) if raceenabled { raceacquire(unsafe.Pointer(t)) } f(arg, seq) lock(&timers.lock)
我們看到在timerproc執行f(arg, seq)這個函數前,timerproc unlock了timers.lock,也就是說f的執行並沒有在鎖內。
前面說過,f的執行是什嗎?
對於AfterFunc來說,就是啟動一個goroutine,並在這個新goroutine中執行使用者傳入的函數;
對於After和NewTimer這種建立姿勢建立的timer而言,f的執行就是sendTime的執行,也就是向t.C中send 目前時間。
注意:這時候timer expire過程中sendTime的執行與“drain channel”是分別在兩個goroutine中執行的,誰先誰後,完全依靠runtime調度。於是example4.go中的看似沒有問題的代碼,也可能存在問題(當然需要時間粒紋足夠小,比如ms級的Timer)。
如果sendTime的執行發生在drain channel執行前,那麼就是example4.go中的執行結果:Stop返回false(因為timer已經expire了),顯式drain channel會將資料讀出,後續Reset後,timer正常執行;
如果sendTime的執行發生在drain channel執行後,那麼問題就來了,雖然Stop返回false(因為timer已經expire),但drain channel並沒有讀出任何資料。之後,sendTime將資料發到channel中。timer Reset後的Timer中的Channel實際上已經有了資料,於是當進入下面的select執行體時,”case <-timer.C:”瞬間返回,觸發了timer事件,沒有啟動逾時等待的作用。
這也是issue:*time: Timer.C can still trigger even after Timer.Reset is called #11513中問到的問題。
go官方文檔中對此也有描述:
Note that it is not possible to use Reset's return value correctly, as there is a race condition between draining the channel and the new timer expiring. Reset should always be invoked on stopped or expired channels, as described above. The return value exists to preserve compatibility with existing programs.
三、真的有Reset方法的正確使用姿勢嗎?
綜合上述例子和分析,Reset的使用似乎沒有理想的方案,但一般來說,在特定商務邏輯下,Reset還是可以正常工作的,就如example4那樣。即便出現問題,如果瞭解了Reset背後的原理,問題解決起來也是會很快很準的。
文中的相關代碼可以在這裡下載。
四、參考資料
Golang官方有關Timer的issue list:
runtime: special case timer channels #8898
time:timer stop ,how to use? #14947
time: document proper usage of Timer.Stop #14383
*time: Timer.Reset is not possible to use correctly #14038
Time.After doesn’t release memory #15781
runtime: timerproc does not get to run under load #15706
time: time.After uses memory until duration times out #15698
time:timer stop panic #14946
*time: Timer.C can still trigger even after Timer.Reset is called #11513
time: Timer.Stop documentation incorrect for Timer returned by AfterFunc #17600
相關資料:
- go中的定時器timer
- Go內部實現之timer
- Go定時器
- How Do They Do It: Timers in Go
- timer在go可以有多精確
2016, bigwhite. 著作權.