記一次golang定時器引發的詭異錯誤

作爲一隻在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時,自然兩個分支都會進入,從而引發了預期之外的錯誤。

這種問題現在再回頭來看,可能會覺得理所當然不該這麼用,但是如果不瞭解定時器的實現,貿然使用時,很難發現這個問題點。

記錄下來,防止後面再踩坑。至於要怎麼修改,其實看一眼要實現的目的很容易想到,就不在此贅述了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章