Go timer 是如何被排程的?

haohongfan發表於2021-09-24

hi,大家好,我是 haohongfan。

本篇文章剖析下 Go 定時器的相關內容。定時器不管是業務開發,還是基礎架構開發,都是繞不過去的存在,由此可見定時器的重要程度。

我們不管用 NewTimer, timer.After,還是 timer.AfterFun 來初始化一個 timer, 這個 timer 最終都會加入到一個全域性 timer 堆中,由 Go runtime 統一管理。

全域性的 timer 堆也經歷過三個階段的重要升級。

  • Go 1.9 版本之前,所有的計時器由全域性唯一的四叉堆維護,協程間競爭激烈。
  • Go 1.10 - 1.13,全域性使用 64 個四叉堆維護全部的計時器,沒有本質解決 1.9 版本之前的問題
  • Go 1.14 版本之後,每個 P 單獨維護一個四叉堆。

Go 1.14 以後的 timer 效能得到了質的飛昇,不過伴隨而來的是 timer 成了 Go 裡面最複雜、最難梳理的資料結構。本文不會詳細分析每一個細節,我們從大體來了解 Go timer 的工作原理。

1. 使用場景

Go timer 在我們程式碼中會經常遇到。

場景1:RPC 呼叫的防超時處理(下面程式碼節選 dubbogo)

func (c *Client) Request(request *remoting.Request, timeout time.Duration, response *remoting.PendingResponse) error {
    _, session, err := c.selectSession(c.addr)
    // .. 省略
    if totalLen, sendLen, err = c.transfer(session, request, timeout); err != nil {
        if sendLen != 0 && totalLen != sendLen {
          // .. 省略
        }
        return perrors.WithStack(err)
    }

    // .. 省略
    select {
    case <-getty.GetTimeWheel().After(timeout):
        return perrors.WithStack(errClientReadTimeout)
    case <-response.Done:
        err = response.Err
    }
    return perrors.WithStack(err)
}

場景2:Context 的超時處理

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()
    go doSomething()
    
    select {
    case <-ctx.Done():
        fmt.Println("main", ctx.Err())
    }
}

2. 圖解原始碼

2.1 四叉堆原理

timer 的全域性堆是一個四叉堆,特別是 Go 1.14 之後每個 P 都會維護著一個四叉堆,減少了 Goroutine 之間的併發問題,提升了 timer 了效能。

四叉堆其實就是四叉樹,Go timer 是如何維護四叉堆的呢?

  • Go runtime 排程 timer 時,觸發時間更早的 timer,要減少其查詢次數,儘快被觸發。所以四叉樹的父節點的觸發時間是一定小於子節點的。
  • 四叉樹顧名思義最多有四個子節點,為了兼顧四叉樹插、刪除、重排速度,所以四個兄弟節點間並不要求其按觸發早晚排序。

這裡用兩張動圖簡單演示下 timer 的插入和刪除

把 timer 插入堆
add_timer

把 timer 從堆中刪除
delete_timer

2.2 timer 是如何被排程的?

  • 呼叫 NewTimer,timer.After, timer.AfterFunc 生產 timer, 加入對應的 P 的堆上。
  • 呼叫 timer.Stop, timer.Reset 改變對應的 timer 的狀態。
  • GMP 在排程週期內中會呼叫 checkTimers ,遍歷該 P 的 timer 堆上的元素,根據對應 timer 的狀態執行真的操作。

2.3 timer 是如何加入到 timer 堆上的?

把 timer 加入排程總共有下面幾種方式:

  • 通過 NewTimer, time.After, timer.AfterFunc 初始化 timer 後,相關 timer 就會被放入到對應 p 的 timer 堆上。
  • timer 已經被標記為 timerRemoved,呼叫了 timer.Reset(d),這個 timer 也會重新被加入到 p 的 timer 堆上
  • timer 還沒到需要被執行的時間,被呼叫了 timer.Reset(d),這個 timer 會被 GMP 排程探測到,先將該 timer 從 timer 堆上刪除,然後重新加入到 timer 堆上
  • STW 時,runtime 會釋放不再使用的 p 的資源,p.destroy()->timer.moveTimers,將不再被使用的 p 的 timers 上有效的 timer(狀態是:timerWaiting,timerModifiedEarlier,timerModifiedLater) 都重新加入到一個新的 p 的 timer 上

2.4 Reset 時 timer 是如何被操作的?

Reset 的目的是把 timer 重新加入到 timer 堆中,重新等待被觸發。不過分為兩種情況:

  • 被標記為 timerRemoved 的 timer,這種 timer 是已經從 timer 堆上刪除了,但會重新設定被觸發時間,加入到 timer 堆中
  • 等待被觸發的 timer,在 Reset 函式中只會修改其觸發時間和狀態(timerModifiedEarlier或timerModifiedLater)。這個被修改狀態的 timer 也同樣會被重新加入到 timer堆上,不過是由 GMP 觸發的,由 checkTimers 呼叫 adjusttimers 或者 runtimer 來執行的。

2.5 Stop 時 timer 是如何被操作的?

time.Stop 為了讓 timer 停止,不再被觸發,也就是從 timer 堆上刪除。不過 timer.Stop 並不會真正的從 p 的 timer 堆上刪除 timer,只會將 timer 的狀態修改為 timerDeleted。然後等待 GMP 觸發的 adjusttimers 或者 runtimer 來執行。

真正刪除 timer 的函式有兩個 dodeltimer,dodeltimer0。

2.6 Timer 是如何被真正執行的?

timer 的真正執行者是 GMP。GMP 會在每個排程週期內,通過 runtime.checkTimers 呼叫 timer.runtimer(). timer.runtimer 會檢查該 p 的 timer 堆上的所有 timer,判斷這些 timer 是否能被觸發。

如果該 timer 能夠被觸發,會通過回撥函式 sendTime 給 Timer 的 channel C 發一個當前時間,告訴我們這個 timer 已經被觸發了。

如果是 ticker 的話,被觸發後,會計算下一次要觸發的時間,重新將 timer 加入 timer 堆中。

3. Timer 使用中的坑

確實 timer 是我們開發中比較常用的工具,但是 timer 也是最容易導致記憶體洩露,CPU 狂飆的殺手之一。

不過仔細分析可以發現,其實能夠造成問題就兩個方面:

  • 錯誤建立很多的 timer,導致資源浪費
  • 由於 Stop 時不會主動關閉 C,導致程式阻塞

3.1 錯誤建立很多 timer,導致資源浪費

func main() {
    for {
        // xxx 一些操作
        timeout := time.After(30 * time.Second)
        select {
        case <- someDone:
            // do something
        case <-timeout:
            return
        }
    }
}

上面這段程式碼是造成 timer 異常的最常見的寫法,也是我們最容易忽略的寫法。

造成問題的原因其實也很簡單,因為 timer.After 底層是呼叫的 timer.NewTimer,NewTimer 生成 timer 後,會將 timer 放入到全域性的 timer 堆中。

for 會建立出來數以萬計的 timer 放入到 timer 堆中,導致機器記憶體暴漲,同時不管 GMP 週期 checkTimers,還是插入新的 timer 都會瘋狂遍歷 timer 堆,導致 CPU 異常。

要注意的是,不只 time.After 會生成 timer, NewTimer,time.AfterFunc 同樣也會生成 timer 加入到 timer 中,也都要防止迴圈呼叫。

解決辦法: 使用 time.Reset 重置 timer,重複利用 timer。

我們已經知道 time.Reset 會重新設定 timer 的觸發時間,然後將 timer 重新加入到 timer 堆中,等待被觸發呼叫。

func main() {
    timer := time.NewTimer(time.Second * 5)    
    for {
        t.Reset(time.Second * 5)

        select {
        case <- someDone:
            // do something
        case <-timer.C:
            return
        }
    }
}

3.2 程式阻塞,造成記憶體或者 goroutine 洩露

func main() {
    timer1 := time.NewTimer(2 * time.Second)
    <-timer1.C
    println("done")
}

上面的程式碼可以看出來,只有等待 timer 超時 "done" 才會輸出,原理很簡單:程式阻塞在 <-timer1.C 上,一直等待 timer 被觸發時,回撥函式 time.sendTime 才會傳送一個當前時間到 timer1.C 上,程式才能繼續往下執行。

不過使用 timer.Stop 的時候就要特別注意了,比如:

func main() {
    timer1 := time.NewTimer(2 * time.Second)
    go func() {
        timer1.Stop()
    }()
    <-timer1.C

    println("done")
}

程式就會一直死鎖了,因為 timer1.Stop 並不會關閉 channel C,使程式一直阻塞在 timer1.C 上。

上面這個例子過於簡單了,試想下如果 <- timer1.C 是阻塞在子協程中,timer 被的 Stop 方法被呼叫,那麼子協程可能就會被永遠的阻塞在那裡,造成 goroutine 洩露,記憶體洩露。

Stop 的正確的使用方式:

func main() {
    timer1 := time.NewTimer(2 * time.Second)
    go func() {
        if !timer1.Stop() {
            <-timer1.C
        }
    }()

    select {
    case <-timer1.C:
        fmt.Println("expired")
    default:
    }
    println("done")
}

到此,Go timer 基本已經結束了,有想跟我討論的可以在留言區評論。

相關文章