你應該掌握的 Go 高級并發(fā)模式:計時器
前言
如果你認為結(jié)合 Goroutines 去處理時間和計數(shù)器很簡單的話,那你就錯了,這里有提到的一些與 time.Timer 相關的問題或 bug:
- time: Timer.Reset is not possible to use correctly #14038[1]
 - time: Timer.C can still trigger even after Timer.Reset is called #11513[2]
 - time: document proper usage of Timer.Stop #14383[3]
 
看完上面的鏈接內(nèi)容后,如果你依然認為很簡單,那來看看下面的代碼,如下代碼會產(chǎn)生死鎖和競爭條件
- tm := time.NewTimer(1)tm.Reset(100 * time.Millisecond)<-tm.Cif !tm.Stop() {<-tm.C}
 
死鎖代碼片段
- func toChanTimed(t *time.Timer, ch chan int) {t.Reset(1 * time.Second)defer func() {if !t.Stop() {<-t.C}}()select {case ch <- 42:case <-t.C:}}
 
可能代碼比較難懂,下面對相關方法進行闡述。
time.Ticker
- type Ticker struct {C <-chan Time // The channel on which the ticks are delivered.}
 
Ticker 簡單易用,但也有一些小問題
- 如果 C 中已存在一條消息,則發(fā)送消息時將刪除所有未讀值。
 - 必須有停止操作:否則 GC 無法回收它
 - 設置 C 無用:消息仍將在原始的 channel 上發(fā)送。
 
time.Tick
time.Tick 是對 time.NewTicker 的封裝。最好不要使用該方法,除非你準備將 chan 作為返回結(jié)果并在程序的整個生命周期中繼續(xù)使用它。正如官方描述:
垃圾收集器無法恢復底層的 Ticker,出現(xiàn) " 泄漏 ". 請謹慎使用,如有疑問請改用 Ticker。
time.After
這與 Tick 的概念基本相同,它是對 Timer 進行封裝。一旦計時器被觸發(fā),它將被回收。請注意,計時器使用了緩存容量是 1 的通道,即使沒有接收者,它仍可以進行計數(shù)。如上所述,如果您關心性能且希望能夠取消計時,那么你不應該使用 After。
time.Timer ( 也稱為 time.WhatTheFork?!)
對于 Go 來說這是一個比較奇怪的 API :NewTicker(Duration) 返回了一個 *Timer 類型,該類型僅暴露一個定義為 chan 類型的變量 C ,這點非常奇怪。
通常在 Go 語言中允許導出的字段意味著用戶可以獲取或設置該字段,而此處設置變量 C 并沒有實際意義。相反:設置 C 并重置 Timer 并不會影響之前在 C 通道的消息傳遞。更糟糕的是:AfterFunc 返回的 Timer 根本不會使用到 C。
這樣看來,Timer 很奇怪,以下是 API 的概述:
- type Timer struct {C <-chan Time}func AfterFunc(d Duration, f func()) *Timerfunc NewTimer(d Duration) *Timerfunc (*Timer) Stop(bool)func (*Timer) Reset(d Duration) bool
 
四個非常簡單的函數(shù),其中兩個是構(gòu)造函數(shù),有可能出錯嗎?
time.AfterFunc
官方文檔:AfterFunc 持續(xù)時間超時后通過開 Goroutine 去調(diào)用 f 函數(shù),返回一個 Timer 類型,以便通過 Stop 方法取消調(diào)用。
這么描述雖然沒有問題,但需要注意:當調(diào)用 Stop 方法時,如果返回 false ,則表示該函數(shù)已經(jīng)執(zhí)行且停止失敗。但并不意味著函數(shù)已經(jīng)返回,你需要添加一些處理邏輯:
- done := make(chan struct{})f := func() {doStuff()close(done)}t := time.AfterFunc(1*time.Second, f)if !t.Stop() {<-done}
 
這個在 Stop 文檔中有相關說明。
除此之外,返回的計時器不會被觸發(fā),只能用于調(diào)用 Stop 方法。
- t := time.AfterFunc(1*time.Second, func() {fmt.Println("Time has passed!")})// This will deadlock.<-t.C
 
此外,寫這篇文章的時候,重置計時器會在傳入重置函數(shù)的時間段過去后再次調(diào)用 f,但這種特性目前暫沒有文檔規(guī)范,未來可能會被改變。
time.NewTimer
官方文檔 : NewTimer 實例化 Timer 結(jié)構(gòu)體,在持續(xù)時間 d 之后發(fā)送當前時間至通道內(nèi) .
這意味著沒有聲明它就無法構(gòu)建有效的 Timer 類型結(jié)構(gòu)體。如果你需要構(gòu)建一個以便后續(xù)重復使用,可以用該方法進行實例化,或者使用如下代碼實現(xiàn)自主創(chuàng)建和停止計數(shù)器
- t := time.NewTimer(0)if !t.Stop() {<-t.C}
 
你必須從 channel 中讀取數(shù)據(jù)。假如在 New 和 Stop 調(diào)用期間觸發(fā)了定時器,且 channel 存在未消費的數(shù)據(jù), 則 C 會存在一個值。將導致后續(xù)讀取均是錯誤的。
(*time.Timer).Stop
Stop 方法會阻止計時器觸發(fā)。如果調(diào)用停止計時器的方法,則返回 true,如果計時器已超時或者已停止,則返回 false。
以上句子中的“或”非常重要。文檔中所以關于 Stop 的示例都顯示了以下代碼片段:
- if !t.Stop() {<-t.C}
 
關鍵點在于 "or" 它意味著有效 0 次或 1 次。對已消費完通道數(shù)據(jù)和在此期間未調(diào)用 Reset 進行過多次執(zhí)行的情況,均是無效的。綜上所述,當且僅當沒有執(zhí)行對通道數(shù)據(jù)的消費,Stop+drain 才是安全的。
在文檔中體現(xiàn)如下:
例如:假設程序尚未從 t.C 接收數(shù)據(jù):
此外,上面的模式不是線程安全的,因為當消費完通道數(shù)據(jù)時,Stop 返回的值可能已經(jīng)過時了,兩個 Goroutine 嘗試消費通道 C 數(shù)據(jù)也會導致死鎖。
(*time.Timer).Reset
這個方法更有意思,文檔很長,你可以在這里[4] 進行查看
文檔中一個有趣的摘錄:
請注意,因為在清空 channel 和計數(shù)器到期之間存在競爭條件,我們無法正確使用 Reset 返回值。Reset 方法必須作用于已停止或已過期的 channel 上。
文檔所提供 Reset 正確使用方法如下:
- if !t.Stop() {<-t.C}t.Reset(d)
 
不能與來自通道的其他接收者同時使用 Stop 和 Reset 方法, 為了使 C 上傳遞的消息有效,C 應該在每次 重置 之前被消費完。
重置計時器而不清空它將使運行過程時丟棄該值,因為 C 緩存為 1,運行時對其他執(zhí)行是有損發(fā)送[5]。
time.Timer: 把這些方法放在一起
- Stop 僅作用在 New 和 Reset 方法之后才安全
 - Reset 僅在 Stop 方法后有效。
 - 只有在每次運行 Stop 后,channel 消費完時,所接收的值才是有效的。
 - 只有 channel 未被消費時,才允許清空 channel。
 
以下是計時器轉(zhuǎn)換,使用和調(diào)用關系流程圖:

timer.png
如下是一個正確復用計時器的例子,它解決了文章開頭提到的一些問題:
- func toChanTimed(t *time.Timer, ch chan int) {t.Reset(1 * time.Second)// No defer, as we don't know which// case will be selectedselect {case ch <- 42:case <-t.C:// C is drained, early returnreturn}// We still need to check the return value// of Stop, because t could have fired// between the send on ch and this line.if !t.Stop() {<-t.C}}
 
上述代碼可以確保 toChanTimed 返回后可以重新使用計時器















 
 
 







 
 
 
 