hi,你們好,我是 haohongfan。web
本篇文章剖析下 Go 定時器的相關內容。定時器不論是業務開發,仍是基礎架構開發,都是繞不過去的存在,因而可知定時器的重要程度。微信
咱們無論用 NewTimer, timer.After,仍是 timer.AfterFun 來初始化一個 timer, 這個 timer 最終都會加入到一個全局 timer 堆中,由 Go runtime 統一管理。session
全局的 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)app
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 了性能。svg
四叉堆其實就是四叉樹,Go timer 是如何維護四叉堆的呢?函數
-
Go runtime 調度 timer 時,觸發時間更早的 timer,要減小其查詢次數,儘快被觸發。因此四叉樹的父節點的觸發時間是必定小於子節點的。 -
四叉樹顧名思義最多有四個子節點,爲了兼顧四叉樹插、刪除、重排速度,因此四個兄弟節點間並不要求其按觸發遲早排序。
這裏用兩張動圖簡單演示下 timer 的插入和刪除
把 timer 插入堆
把 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 {
timer.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 基本已經結束了,有想跟我討論的能夠在留言區評論。
Go timer 完整流程圖獲取連接:連接: 連接: https://pan.baidu.com/s/1nUvTK_0qBlwbS6LbZXKM7g 密碼: t219 其餘模塊流程圖,請關注公衆號 HHFCodeRv 回覆1獲取。
更多學習學習資料分享,關注公衆號回覆指令:
-
回覆 0,獲取 《Go 面經》 -
回覆 1,獲取 《Go 源碼流程圖》
本文分享自微信公衆號 - HHFCodeRv(hhfcodearts)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。