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

cyhone發表於2020-06-19

原文地址 :程式設計沉思錄 https://www.cyhone.com/articles/analysis-of-golang-timer/

本文將基於 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 叉堆需要的比較次數只有二叉堆的 $\log_N{2}$ 倍。
  2. 對快取更友好。二叉堆對陣列的訪問範圍更大,更加隨機,而 N 叉堆則更集中於陣列的前部,這就對快取更加友好,有利於提高效能。

C 語言知名開源網路庫 libev,其 timer 定時器實現可以在編譯時選擇採用四叉堆。在它的註釋裡提到四叉樹相比來說快取更加友好。 根據 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 排程。整個過程也可以學習到整個決策的走向和取捨。

參考

更多原創文章乾貨分享,請關注公眾號
  • Golang 定時器底層實現深度剖析
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章