【Go進階—基礎特性】定時器

與昊發表於2022-03-28

在實際的應用中,我們經常會需要在特定的延遲後,或者定時去做某件事情。這時就需要用到定時器了,Go 語言提供了一次性定時器 time.Timer 和週期型定時器 time.Ticker。

如何使用

Timer 是一次性的定時器,經過指定的時間後觸發一個事件,這個事件通過其本身提供的 channel 進行通知。與之相關的主要方法如下:

// 建立 Timer
func NewTimer(d Duration) *Timer
// 停止 Timer
func (t *Timer) Stop() bool
// 重置 Timer
func (t *Timer) Reset(d Duration) bool
// 建立 Timer,返回它的 channel
func After(d Duration) <-chan Time
// 建立一個延遲執行 f 函式的 Timer
func AfterFunc(d Duration, f func()) *Timer

Timer 的主要使用場景有:

  1. 設定超時時間;
  2. 延遲執行某個方法。

Ticker 是週期型定時器,即週期性的觸發一個事件,通過 Ticker 提供的管道將事件傳遞出去。主要方法有:

// 建立 Ticker
func NewTicker(d Duration) *Ticker
// 停止 Ticker,Ticker 在使用完後務必要釋放,否則會產生資源洩露
func (t *Ticker) Stop()
// 啟動一個匿名的 Ticker(無法停止)
func Tick(d Duration) <-chan Time

Ticker 的使用場景都和定時任務有關,例如定時進行聚合等批量處理。

實現原理

我們先來看看 Timer 和 Ticker 的資料結構:

type Timer struct {
    C <-chan Time
    r runtimeTimer
}

type Ticker struct {
    C <-chan Time
    r runtimeTimer
}

發現二者一模一樣,而且都包含有 runtimeTimer 欄位,這個 runtimeTimer 才是定時器底層真正的資料結構。翻看 Timer 和 Ticker 的相關實現程式碼,與這兩個結構本身相關的邏輯都很簡單,真正複雜的是底層 runtimeTimer 的設計與維護,這也是我們要重點介紹的內容。

演進歷史

Go 語言的計時器實現經歷過很多個版本的迭代,到最新的版本為止,計時器的實現分別經歷了以下幾段歷史:

  1. Go 1.9 版本之前,所有的計時器由全域性唯一的四叉堆維護;
  2. Go 1.10 ~ 1.13 版本,全域性使用 64 個四叉堆維護所有的計時器,每個處理器(P)建立的計時器會由對應的四叉堆維護;
  3. Go 1.14 版本之後,每個處理器單獨管理計時器並通過網路輪詢器觸發。

在最開始的實現中,執行時建立的所有計時器都會加入到全域性唯一的四叉堆中,然後有一個專門的協程 timerproc 來管理這些計時器,執行時會在計時器到期或者加入了更早的計時器時喚醒 timerproc 來處理。那這樣一來,就會產生兩個效能上的問題,第一個就是全域性唯一的四叉堆帶來的鎖爭用問題,第二個就是喚醒 timerproc 帶來的上下文切換問題。

Go 1.10 版本中將全域性的四叉堆分割成了 64 個更小的四叉堆,這種分片的方式,降低了鎖的粒度,解決了上面提到的第一個問題,但是第二個問題還是懸而未決。

在最新版本的實現中,所有的計時器都以最小四叉堆的形式儲存在處理器 runtime.p 中,這樣的設計方式讓兩個效能問題都迎刃而解。

資料結構

runtime.timer 是 Go 語言計時器的內部表示,每一個計時器都儲存在對應處理器的最小四叉堆中,下面是執行時計時器對應的結構體:

type timer struct {
    pp puintptr

    when     int64
    period   int64
    f        func(interface{}, uintptr)
    arg      interface{}
    seq      uintptr
    nextwhen int64
    status   uint32
}
  • pp:計時器所在的處理器 P 的指標地址。
  • when:計時器被喚醒的時間。
  • period:計時器再次被喚醒的時間間隔,只有 Ticker 會用到。
  • f:回撥函式,每次在計時器被喚醒時都會呼叫。
  • arg:回撥函式 f 的引數。
  • seq:回撥函式 f 的引數,該引數僅在 netpoll 的應用場景下使用。
  • nextwhen:當計時器狀態為 timerModifiedXX 時,將會使用 nextwhen 的值設定到 when 欄位上。
  • status:計時器的當前狀態值。

狀態

現階段計時器所包含的狀態有下面幾種:

狀態含義
timerNoStatus計時器尚未設定狀態
timerWaiting等待計時器啟動
timerRunning執行計時器的回撥方法
timerDeleted計時器已經被刪除,但仍然在某些 P 的堆中
timerRemoving計時器正在被刪除
timerRemoved計時器已經停止,且不在任何 P 的堆中
timerModifying計時器正在被修改
timerModifiedEarlier計時器已被修改為更早的時間
timerModifiedLater計時器已被修改為更晚的時間
timerMoving計時器已經被修改,正在被移動
  • 處於 timerRunning、timerRemoving、timerModifying 和 timerMoving 狀態的時間比較短。
  • 處於 timerWaiting、timerRunning、timerDeleted、timerRemoving、timerModifying、timerModifiedEarlier、timerModifiedLater 和 timerMoving 狀態時計時器在處理器(P)的堆上。
  • 處於 timerNoStatus 和 timerRemoved 狀態時計時器不在堆上。
  • 處於 timerModifiedEarlier 和 timerModifiedLater 狀態時計時器雖然在堆上,但是可能位於錯誤的位置上,需要重新排序。

相關操作

新增計時器

當我們呼叫 time.NewTimer 或 time.NewTicker 時,會執行 runtime.addtimer 函式新增計時器:

func addtimer(t *timer) {
    if t.when < 0 {
        t.when = maxWhen
    }
    if t.status != timerNoStatus {
        throw("addtimer called with initialized timer")
    }
    t.status = timerWaiting

    when := t.when

    pp := getg().m.p.ptr()
    lock(&pp.timersLock)
    cleantimers(pp)
    doaddtimer(pp, t)
    unlock(&pp.timersLock)

    wakeNetPoller(when)
}
  1. 邊界處理以及狀態判斷;
  2. 呼叫 cleantimers 清理處理器中的計時器;
  3. 呼叫 doaddtimer 初始化網路輪詢器,並將當前計時器加入處理器的 timers 四叉堆中;
  4. 呼叫 wakeNetPoller 中斷正在阻塞的網路輪詢,根據時間判斷是否需要喚醒網路輪詢器中休眠的執行緒。
刪除計時器

在計時器的使用中,一般會呼叫 timer.Stop() 方法來停止計時器,本質上就是讓這個 timer 從輪詢器中消失,也就是從處理器 P 的堆中移除 timer:

func deltimer(t *timer) bool {
    for {
        switch s := atomic.Load(&t.status); s {
        case timerWaiting, timerModifiedLater:
            // timerWaiting/timerModifiedLater -> timerDeleted
            ...
        case timerModifiedEarlier:
            // timerModifiedEarlier -> timerModifying -> timerDeleted
            ...
        case timerDeleted, timerRemoving, timerRemoved:
            // timerDeleted/timerRemoving/timerRemoved 
            return false
        case timerRunning, timerMoving:
            // timerRunning/timerMoving
            osyield()
        case timerNoStatus:
            return false
        case timerModifying:
            osyield()
        default:
            badTimer()
        }
    }
}

在 deltimer 中遵循了基本的規則處理:

  1. timerWaiting/timerModifiedLater -> timerDeleted。
  2. timerModifiedEarlier -> timerModifying -> timerDeleted。
  3. timerDeleted/timerRemoving/timerRemoved -> 無需變更,已經滿足條件。
  4. timerRunning/timerMoving/timerModifying -> 正在執行、移動中,無法停止,等待下一次狀態檢查再處理。
  5. timerNoStatus -> 無法停止,不滿足條件。
修改計時器

在我們呼叫 timer.Reset 方法來重新設定 Duration 值的時候,我們就是在對底層的計時器進行修改,對應的是 runtime.modtimer 方法。這個方法比較複雜,就不詳細介紹了,有興趣的可以自己研究一下。modtimer 遵循下述規則處理:

  1. timerWaiting -> timerModifying -> timerModifiedXX。
  2. timerModifiedXX -> timerModifying -> timerModifiedYY。
  3. timerNoStatus -> timerModifying -> timerWaiting。
  4. timerRemoved -> timerModifying -> timerWaiting。
  5. timerDeleted -> timerModifying -> timerModifiedXX。
  6. timerRunning -> 等待狀態改變,才可以進行下一步。
  7. timerMoving -> 等待狀態改變,才可以進行下一步。
  8. timerRemoving -> 等待狀態改變,才可以進行下一步。
  9. timerModifying -> 等待狀態改變,才可以進行下一步。

在完成了計時器的狀態處理後,會分為兩種情況處理:

  1. 待修改的計時器已經被刪除:由於既有的計時器已經沒有了,因此會呼叫 doaddtimer 方法建立一個新的計時器,並將原本的 timer 屬性賦值過去,再呼叫 wakeNetPoller 方法在預定時間喚醒網路輪詢器。
  2. 正常邏輯處理:如果修改後的計時器的觸發時間小於原本的觸發時間,則修改該計時器的狀態為 timerModifiedEarlier,並且呼叫 wakeNetPoller 方法在預定時間喚醒網路輪詢器。
觸發計時器

Go 語言會在兩種場景下觸發計時器,執行計時器中儲存的函式:

  • 排程器排程時會檢查處理器中的計時器是否準備就緒;
  • 系統監控會檢查是否有未執行的到期計時器。

排程器的觸發一共分兩種情況,一種是在排程迴圈 schedule 中,另一種是當前處理器 P 沒有可執行的 G 和計時器,去其他 P 竊取計時器和 G 的 findrunnable 函式中。觸發計時器時執行的是 checkTimers 函式,來剖析一下它的大致過程。

func checkTimers(pp *p, now int64) (rnow, pollUntil int64, ran bool) {
    if atomic.Load(&pp.adjustTimers) == 0 {
        next := int64(atomic.Load64(&pp.timer0When))
        if next == 0 {
            return now, 0, false
        }
        if now == 0 {
            now = nanotime()
        }
        if now < next {
            if pp != getg().m.p.ptr() || int(atomic.Load(&pp.deletedTimers)) <= int(atomic.Load(&pp.numTimers)/4) {
                return now, next, false
            }
        }
    }

    lock(&pp.timersLock)

    adjusttimers(pp)

這一段是調整堆中計時器的過程:

  • 起始先通過 pp.adjustTimers 檢查當前處理器 P 中是否有需要調整的計時器,如果沒有的話:

    • 當沒有需要執行的計時器時,直接返回;
    • 當下一個計時器沒有到期並且需要刪除的計時器不多於總數的 1/4 時都會直接返回。
  • 如果處理器中存在需要調整的計時器,會呼叫 runtime.adjusttimers 根據時間將 timers 切片重新排列。
rnow = now
    if len(pp.timers) > 0 {
        if rnow == 0 {
            rnow = nanotime()
        }
        for len(pp.timers) > 0 {
            if tw := runtimer(pp, rnow); tw != 0 {
                if tw > 0 {
                    pollUntil = tw
                }
                break
            }
            ran = true
        }
    }

執行完調整階段的邏輯後,就是執行計時器的程式碼。這一段通過 runtime.runtimer 查詢並執行堆中需要執行的計時器:

  • 如果成功執行,runtimer 返回 0;
  • 如果沒有需要執行的計時器,runtimer 返回最近的計時器的觸發時間,記錄這個時間並返回。
if pp == getg().m.p.ptr() && int(atomic.Load(&pp.deletedTimers)) > len(pp.timers)/4 {
        clearDeletedTimers(pp)
    }

    unlock(&pp.timersLock)
    return rnow, pollUntil, ran
}

在最後的刪除階段,如果當前 Goroutine 的處理器和傳入的處理器相同,並且處理器中被刪除(timerDeleted 狀態)的計時器佔堆中計時器的 1/4 以上,就會呼叫 runtime.clearDeletedTimers 清理處理器中全部被標記為 timerDeleted 的計時器。

即使是通過每次排程器排程和竊取的時候觸發,但畢竟還是具有一定的不確定性,因此 Go 中使用系統監控觸發來做一個兜底:

func sysmon() {
    ...
    for {
        ...
        now := nanotime()
        next, _ := timeSleepUntil()
        ...
        lastpoll := int64(atomic.Load64(&sched.lastpoll))
        if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
            atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
            list := netpoll(0)
            if !list.empty() {
                incidlelocked(-1)
                injectglist(&list)
                incidlelocked(1)
            }
        }
        if next < now {
            startm(nil, false)
        }
        ...
}
  • 呼叫 runtime.timeSleepUntil 獲取計時器的到期時間以及持有該計時器的堆;
  • 如果超過 10ms 的時間沒有網路輪詢,呼叫 runtime.netpoll 輪詢;
  • 如果當前有應該執行的計時器沒有執行,可能存在無法被搶佔的處理器,則啟動新的執行緒處理計時器。
執行計時器

runtime.runtimer 函式會檢查處理器四叉堆上最頂上的計時器,該函式也會處理計時器的刪除和更新:

func runtimer(pp *p, now int64) int64 {
    for {
        t := pp.timers[0]
        switch s := atomic.Load(&t.status); s {
        case timerWaiting:
            if t.when > now {
                return t.when
            }

            runOneTimer(pp, t, now)
            return 0

        case timerDeleted:
            // 刪除堆中的計時器
        case timerModifiedEarlier, timerModifiedLater:
            // 修改計時器的時間
        case timerModifying:
            osyield()
        case timerNoStatus, timerRemoved:
            badTimer()
        case timerRunning, timerRemoving, timerMoving:
            badTimer()
        default:
            badTimer()
        }
    }
}

它會遵循以下的規則處理計時器:

  • timerNoStatus -> 崩潰:未初始化的計時器
  • timerWaiting -> timerWaiting
  • timerWaiting -> timerRunning -> timerNoStatus
  • timerWaiting -> timerRunning -> timerWaiting
  • timerModifying -> 等待狀態改變
  • timerModifiedXX -> timerMoving -> timerWaiting
  • timerDeleted -> timerRemoving -> timerRemoved
  • timerRunning -> 崩潰:併發呼叫該函式
  • timerRemoved、timerRemoving、timerMoving -> 崩潰:計時器堆不一致

如果處理器四叉堆頂部的計時器沒有到觸發時間會直接返回,否則呼叫 runtime.runOneTimer 執行堆頂的計時器:

func runOneTimer(pp *p, t *timer, now int64) {
    f := t.f
    arg := t.arg
    seq := t.seq

    if t.period > 0 {
        delta := t.when - now
        t.when += t.period * (1 + -delta/t.period)
        siftdownTimer(pp.timers, 0)
        if !atomic.Cas(&t.status, timerRunning, timerWaiting) {
            badTimer()
        }
        updateTimer0When(pp)
    } else {
        dodeltimer0(pp)
        if !atomic.Cas(&t.status, timerRunning, timerNoStatus) {
            badTimer()
        }
    }

    unlock(&pp.timersLock)
    f(arg, seq)
    lock(&pp.timersLock)
}

根據計時器的 period 欄位,上述函式會做出不同的處理:

  • 如果 period 欄位大於 0,則代表它是一個 Ticker,需要週期性觸發:

    • 修改計時器下一次觸發的時間並更新其在堆中的位置;
    • 將計時器的狀態更新為 timerWaiting;
    • 呼叫 runtime.updateTimer0When 函式設定處理器的 timer0When 欄位。
  • 如果 period 欄位小於或者等於 0,說明它是一個 Timer,只需觸發一次即可:

    • 呼叫 runtime.dodeltimer0 函式刪除計時器;
    • 將計時器的狀態更新至 timerNoStatus。

在完成更新計時器後,上互斥鎖,呼叫計時器的回撥方法 f,傳入相應引數。完成整個流程。

相關文章