作爲一隻在9127工作制下摸魚的程序猿,週六自然是愉快的加班了。一早上除了一位新同學在我們的“敏捷迭代”下錯刪了接口之外沒什麼大事。
臨近中午,突然隔壁組大佬找到我,表示有個go語言服務偶現panic的問題需要求助。瞭解了一下,原來是他們組的一個妹子(小姐姐??)寫的代碼的問題。okok,既然大佬都來找我了,幫忙解決下順便……再好不過了。
咳咳,進入正題,將問題場景的代碼先放出來:
var flag bool
timer1 := time.NewTicker(time.Millisecond * 500)
timer2 := time.NewTicker(time.Millisecond * 1000)
if flag {
timer1.Stop()
} else {
timer2.Stop()
}
for {
select {
case <-timer1.C:
//todo do something
case <-timer2.C:
//todo do something else
}
}
其實要實現的功能很簡單,兩個定時器timer1和timer2,根據flag條件的不同,停止其中一個定時器,後續業務流程只有一個定時器生效。當然,這個功能很簡單,有更好的寫法,先不說代碼的好壞,這段代碼中隱藏的問題其實很容易忽略。
這段代碼有一定機率引起select的兩個分支都會進入的情況,與預期不符合,導致執行錯誤分支代碼時,出現不可預期的問題。
先看下Ticker的結構和NewTicker方法的源碼:
Ticker:
type Ticker struct {
C <-chan Time // The channel on which the ticks are delivered.
r runtimeTimer
}
NewTicker方法:
func NewTicker(d Duration) *Ticker {
if d <= 0 {
panic(errors.New("non-positive interval for NewTicker"))
}
// Give the channel a 1-element time buffer.
// If the client falls behind while reading, we drop ticks
// on the floor until the client catches up.
c := make(chan Time, 1)
t := &Ticker{
C: c,
r: runtimeTimer{
when: when(d),
period: int64(d),
f: sendTime,
arg: c,
},
}
startTimer(&t.r)
return t
}
可以看到,Ticker其實是對runtimeTimer的一個封裝,增加一個成員C,用作定時器超時觸發的通道。而NewTicker就是對Ticker的創建過程,新建了通道C,並構造了runtimeTimer的結構,其中成員方法f就是runtimeTimer超時觸發的方法,先不考慮runtimeTimer內部的實現,看一下sendTime方法:
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:
}
}
不得不說,這裏的設計還是很精妙的,c是一個緩衝空間爲1的通道,使用緩衝通道的特性,做到了定時器外部業務不阻塞內部調度的特性。當定時器超時時,會將當前時間放入通道中,如果通道已經滿了,不能放入就丟棄。總之,不會阻塞定時器的內部調度。而外部在使用時,只要從Ticker.C這個通道中,不斷讀取即可,能取到值時,說明發生過超時,執行相應業務即可。
另外問題代碼中還調用了Stop方法,Stop方法內部實現主要是調用runtimeTimer的方法來停止,停止之後,定時器超時不會再觸發上述的sendTimer方法,即不會再向通道c中放入數據使外部使用者讀到。
簡單明確了Ticker的實現,現在回到問題代碼,看下究竟有什麼問題。一開始在創建timer1和timer2是調用了NewTicker方法,根據上面的分析,調用了這兩個方法之後,底層的定時器已經開始計時,接下來執行if條件判斷後才停止了其中一個定時器。一般來講,這種相鄰幾行代碼之間,應該間隔時間很短,都在納秒級別,即還沒等到定時器觸發,就停止了定時器。但是,go語言的協程調度的機制其實無法保證這種時間間隔。當發生方法調用時,當前協程是有可能出讓出所佔有的線程,讓其它協程先跑的。以此來對外呈現出併發的效果,協程之間並不能完全保證並行。關於go的協程調度機制,後續可以再詳細聊一聊。
這樣一來,如果某些時候,正好這個執行這個方法的協程在這兩行中間出讓了線程,就極可能導致兩行代碼之間的時間間隔超過超時時間。一旦發生這種情況,雖然按邏輯停掉了一個定時器,但是在停掉之前已經觸發了一次。這樣Ticker.C這個通道里已經被放入了觸發數據,繼續往下執行select時,自然兩個分支都會進入,從而引發了預期之外的錯誤。
這種問題現在再回頭來看,可能會覺得理所當然不該這麼用,但是如果不瞭解定時器的實現,貿然使用時,很難發現這個問題點。
記錄下來,防止後面再踩坑。至於要怎麼修改,其實看一眼要實現的目的很容易想到,就不在此贅述了。