Golang 定時器底層實現深度剖析


本文將基於 Golang 源碼對 Timer 的底層實現進行深度剖析。主要包含以下內容:

  1. Timer 和 Ticker 在 Golang 中的底層實現細節,包括數據結構等選型。
  2. 分析 time.Sleep 的實現細節,Golang 如何實現 Goroutine 的休眠。

注:本文基於 go-1.13 源碼進行分析,而在 go 的 1.14 版本中,關於定時器的實現略有一些改變,以後會再專門寫一篇文章進行分析。

概述

我們在日常開發中會經常用到 time.NewTicker 或者 time.NewTimer 進行定時或者延時的處理邏輯。

Timer 和 Ticker 在底層的實現基本一致,本文將主要基於 Timer 進行探討研究。Timer 的使用方法如下:

import (
    "fmt"
    "time"
)

func main() {
 timer := time.NewTimer(2 * time.Seconds)
 <-timer.C
 fmt.Println("Timer fired")
}

在上面的例子中,我們首先利用 time.NewTimer 構造了一個 2 秒的定時器,同時使用 <-timer.C 阻塞等待定時器的觸發。

Timer 的底層實現

對於 time.NewTimer 函數,我們可以輕易地在 go 源碼中找到它的實現,其代碼位置在 time/sleep.go#L82。如下:

func NewTimer(d Duration) *Timer {
 c := make(chan Time, 1)
 t := &Timer{
  C: c,
  r: runtimeTimer{
   when: when(d),
   f:    sendTime,
   arg:  c,
  },
 }
 startTimer(&t.r)
 return t
}

NewTimer 主要包含兩步:

  1. 創建一個 Timer 對象,主要包括其中的 C 屬性和 r 屬性。r 屬性是 runtimeTimer 類型。
  2. 調用 startTimer 函數,啓動 timer。

在 Timer 結構體中的屬性 C 不難理解,從最開始的例子就可以看到,它是一個用來接收 Timer 觸發消息的 channel。注意,這個 channel 是一個有緩衝 channel,緩衝區大小爲 1。

我們主要看的是 runtimeTimer 這個結構體:

  1. when: when 代表 timer 觸發的絕對時間。計算方式就是當前時間加上延時時間。
  2. f: f 則是 timer 觸發時,調用的 callback。而 arg 就是傳給 f 的參數。在 Ticker 和 Timer 中,f 都是 sendTime。

timer 對象構造好後,接下來就調用了 startTimer 函數,從名字來看,就是啓動 timer。具體裏面做了哪些事情呢?

startTimer 具體的函數定義在 runtime/time.go 中,裏面實際上直接調用了另外一個函數 addTimer。我們可以看下 addTimer 的代碼 /runtime/time.go#L131:

func addtimer(t *timer) {
 // 得到要被插入的 bucket
 tb := t.assignBucket()

 // 加鎖,將 timer 插入到 bucket 中
 lock(&tb.lock)
 ok := tb.addtimerLocked(t)
 unlock(&tb.lock)

 if !ok {
  badTimer()
 }
}

可以看到 addTimer 至少做了兩件事:

  1. 調用 assignBucket,得到獲取可以被插入的 bucket
  2. 調用 addtimerLocked 將 timer 插入到 bucket 中。從函數名可以看出,這同時也是個加鎖操作。

那麼問題來了,bucket 是什麼?timer 插入到 bucket 中後,會以何種方式觸發?

Timerbucket

在 go 1.13 的 runtime 中,共有 64 個全局的 timer bucket。每個 bucket 負責管理一些 timer。timer 的整個生命週期包括創建、銷燬、喚醒和睡眠等都由 timer bucket 管理和調度。

timersBucket 的結構: 最小四叉堆

每個 timersBucket 實際上內部是使用最小四叉堆來管理和存儲各個 timer。最小堆是非常常見的用來管理 timer 的數據結構。在最小堆中,作爲排序依據的 key 是 timer 的 when 屬性,也就是何時觸發。即最近一次觸發的 timer 將會處於堆頂。如下圖:

在這裏插入圖片描述

timerproc 的調度

每個 timerbucket 負責管理一堆這樣有序的 timer,同時每個 timerbucket 都有一個對應的名爲 timerproc 的 goroutine 來負責不斷調度這些 timer。代碼在 /runtime/time.go#L247

對於每個 timerbucket 對應的 timeproc,該 goroutine 也不是時時刻刻都在監聽。timerproc 的主要流程概括起來如下:

  1. 創建。timeproc 是懶加載的,雖然 64 個 timerBucket 一直是存在的,但是這些 timerproc 對應的 goroutine 並不是一開始就存在。第一個 timer 被加到 timerbucket 中時,纔會調用 go timerproc(tb), 創建該 goroutine。
  2. 調度。從 timerbucket 不斷取堆頂元素,如果堆頂的 timer 已觸發,則將其從最小堆中移除,並調用對應的 callback。這裏的 callback 也就是 runtimeTimer 結構體中的 f 屬性。
  3. 如果 timer 是個 ticker(週期性 timer),則生成新的 timer 塞進 timerbucket 中。
  4. 掛起。如果 timerbucket 爲空,意味着所有的 timer 都被消費完了。則調用 gopark 掛起該 goroutine。
  5. 喚醒。當有新的 timer 被添加到該 timerbucket 中時,如果 goroutine 處於掛起狀態,會調用 goready 重新喚醒 timerproc。

當 timer 觸發時,timerproc 會調用對應的 callback。對於 timer 和 ticker 來說,其 callback 都是 sendTime 函數,如下:

func sendTime(c interface{}, seq uintptr) {
 select {
 case c.(chan Time) <- Now():
 default:
 }
}

這裏的 c interface{},也就是我們上文中提到的,在定義 timer 或 ticker 時,timer 對象中的 C 屬性, 在 timer 和 ticker 中,它都被初始化爲長度爲 1 的有緩衝 channel。

調用 sendTime 時,會向 channel 中傳遞一個值。由於是緩衝爲 1 的 buffer,因此當緩衝爲空時,sendTime 可以無阻塞地把數據放到 channel 中。

如果定時時間過短,也不用擔心用戶調用 <-timer.C 接收不到觸發事件,因爲事件已經放到了 channel 中。

而對於 ticker 來說,sendTime 會被調用多次,而 channel 的緩衝長度只有 1。如果 ticker 沒有來得及消費 channel,會不會導致 timerproc 調用 callback 阻塞呢?答案是不會的。因爲我們可以看到,在這個 select 語句中,有一個 default 選項,如果 channel 不可寫,會觸發 default。對於 ticker 來說,如果之前的觸發事件沒有來得及消費,那新的觸發事件到來,就會被立即丟棄。

因此對於 timerproc 來說,調用 sendTime 的時候,永遠不會阻塞。這樣整個 timerproc 的過程也不會因爲用戶側的行爲,導致某個 timer 沒有來得及消費而造成阻塞。

爲什麼是 64 個 timer bucket?

64 個 timerbucket 的定義代碼如下,在 /runtime/time.go#L39 可以看到。

const timersLen = 64

var timers [timersLen]struct {
 timersBucket

 // The padding should eliminate false sharing
 // between timersBucket values.
 pad [cpu.CacheLinePadSize - unsafe.Sizeof(timersBucket{})%cpu.CacheLinePadSize]byte
}

不過 64 個 timerbucket,而不是一個,或者說爲什麼不至於與 GOMAXPROCS 保持一致呢?

首先,在 go 1.10 之前,go runtime 中的確只有一個 timers 對象,負責管理 timer。這個時候也就沒有分桶了,整個定時器調度模型非常簡單。但問題也非常明顯,

  1. 創建和停止 timer 都需要對 bucket 進行加鎖操作。
  2. 當 timer 過多時,單個 bucket 的調度負擔太重,可能會造成 timer 的延遲。

因此,在 go 1.10 中,引入了全局 64 個 timer 分桶的策略。將 timer 打散到分桶內,每個桶負責自己分配到的 timer 即可。好處也非常明顯,可以有效降低了鎖粒度和 timer 調度的負擔。

那爲什麼是 64 個 timerbucket,而不是 32 個或者更多,或者不乾脆與 GOMAXPROCS 保持一致?這點在源碼註釋中也有詳細的說明:

Ideally, this would be set to GOMAXPROCS, but that would require dynamic reallocation.
The current value is a compromise between memory usage and performance that should cover the majority of GOMAXPROCS values used in the wild.

理想情況下,分桶的個數和保持 GOMAXPROCS 一致是最優解。但是這就會涉及到 go 啓動時的動態內存分配。作爲運行時應該儘量減少程序負擔。而 64 個 bucket 則是內存佔用和性能之間的權衡了。

每個 bucket 具體負責管理的 timer 和 go 調度模型 GMP 中 P 有關,代碼如下:

func (t *timer) assignBucket() *timersBucket {
 id := uint8(getg().m.p.ptr().id) % timersLen
 t.tb = &timers[id].timersBucket
 return t.tb
}

可以看到,timer 獲取其對應的 bucket 時,是根據 golang 的 GMP 調度模型中的 P 的 id 進行取模。而當 GOMAXPROCS > 64, 一個 bucket 將會同時負責管理多個 P 上的 timer。

爲什麼是四叉堆

TimersBucket 裏面使用最小堆管理 Timer,但是與我們常見的,使用二叉樹來實現最小堆不同,Golang 這裏採用了四叉堆 (4-heap) 來實現。這裏 Golang 並沒有直接給出解釋。這裏直接貼一段 知乎網友對二叉堆和 N 叉堆的分析。

  1. 上推節點的操作更快。假如最下層某個節點的值被修改爲最小,同樣上推到堆頂的操作,N 叉堆需要的比較次數只有二叉堆的 logn 2倍。
  2. 對緩存更友好。二叉堆對數組的訪問範圍更大,更加隨機,而 N 叉堆則更集中於數組的前部,這就對緩存更加友好,有利於提高性能。

C 語言知名開源網絡庫 libev,內部既採用了二叉樹也採用了四叉堆來實現。它在註釋裏提到 benchmark,四叉樹相比來說緩存更加友好,在 50000 + 個 timer 的場景下,四叉樹會有 5% 的性能提升。具體可見 libev/ev.c#L2227

sleep 的實現

我們通常使用 time.Sleep(1 * time.Second) 來將 goroutine 暫時休眠一段時間。sleep作在底層實現也是基於 timer 實現的。代碼在runtime/time.go#L84。有一些比較有意思的地方,單獨拿出來講下。

我們固然也可以這麼做來實現 goroutine 的休眠:

timer := time.NewTimer(2 * time.Seconds)
<-timer.C

這麼做當然可以。但 golang 底層顯然不是這麼做的,因爲這樣有兩個明顯的額外性能損耗。

  1. 每次調用 sleep 的時候,都要創建一個 timer 對象。
  2. 需要一個 channel 來傳遞事件。

既然都可以放在 runtime 裏面做。golang 裏面做的更加乾淨:

  1. 每個 goroutine 底層的 G 對象上,都有一個 timer 屬性,這是個 runtimeTimer 對象,專門給 sleep 使用。當第一次調用 sleep 的時候,會創建這個 runtimeTimer,之後 sleep 的時候會一直複用這個 timer 對象。
  2. 調用 sleep 時候,觸發 timer 後,直接調用 gopark,將當前 goroutine 掛起。
  3. timerproc 調用 callback 的時候,不是像 timer 和 ticker 那樣使用 sendTime 函數,而是直接調 goready 喚醒被掛起的 goroutine。

這個做法和libco的poll實現幾乎一樣:sleep時切走協程,時間到了就喚醒協程。

總結

分析 timer 的實現,可以明顯的看到整個設計的演進,從最開始的全局 timers 對象,到分桶 bucket,以及到 go1.14 最新的 timer 調度。整個過程也可以學習到整個決策的走向和取捨。

參考

  • https://en.wikipedia.org/wiki/D-ary_heap
  • https://www.zhihu.com/question/358807741/answer/922148368
  • https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-timer/
  • https://mp.weixin.qq.com/s/GWeuwifl8Iyew5MD62txYg
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章