層級時間輪的 Golang 實現
一、引言
最近在工作中負責制定重構計劃,需要將部分業務程式碼從 Python 遷移到 Golang。其中一些功能涉及到 Celery 延時任務,所以一直在思考 Golang 中處理延時任務的有效方案。
其實在軟體系統中,“在一段時間後執行一個任務” 的需求比比皆是。比如:
- 客戶端發起 HTTP 請求後,如果在指定時間內沒有收到伺服器的響應,則自動斷開連線。
為了實現上述功能,通常我們會使用定時器 Timer:
- 客戶端發起請求後,立即建立(啟動)一個 Timer:到期間隔為 d,到期後執行 “斷開連線” 的操作。
- 如果到期間隔 d 以內收到了伺服器的響應,客戶端就刪除(停止)這個 Timer。
- 如果一直沒有收到響應,則 Timer 最終會到期,然後執行 “斷開連線” 的操作。
Golang 內建的 ofollow,noindex">Timer 是採用最小堆來實現的,建立和刪除的時間複雜度都為 O(log n)。現代的 Web 服務動輒管理 100w+ 的連線,每個連線都會有很多超時任務(比如傳送超時、心跳檢測等),如果每個超時任務都對應一個 Timer,效能會比較低下。
論文 Hashed and Hierarchical Timing Wheels 提出了一種用於實現 Timer 的高效資料結構:時間輪。採用時間輪實現的 Timer,建立和刪除的時間複雜度為 O(1)。
常見的時間輪實現有兩種:
- 簡單時間輪(Simple Timing Wheel)—— 比如 Netty4 的 HashedWheelTimer 。
- 層級時間輪(Hierarchical Timing Wheels)—— 比如 Kafka 的 Purgatory 。
參考 Kafka 的層級時間輪實現(基於 Java/Scala 語言),我依葫蘆畫瓢實現了一個 Golang 版本的層級時間輪,實現原始碼作為個人專案放到了 GitHub 。
下面我們來看看簡單時間輪、層級時間輪、Kafka 的層級時間輪變體的實現原理,以及 Golang 實現中的一些要點。
二、簡單時間輪
一個 簡單時間輪 就是一個迴圈列表,列表中的每一格包含一個定時任務列表(雙向連結串列)。一個時間單位為 u、大小為 n 的簡單時間輪,可以包含的定時任務的最大到期間隔為 n*u。
以 u 為 1ms、n 為 3 的簡單時間輪為例,可以包含的定時任務的最大到期間隔為 3ms。
如上圖所示,該簡單時間輪的執行原理如下:
- 初始時,假設當前時間(藍色箭頭)指向第 1 格(此時:到期間隔為 [0ms, 1ms) 的定時任務放第 1 格,[1ms, 2ms) 的放第 2 格,[2ms, 3ms) 的放第 3 格)。
- 此時我們建立一個到期間隔為 1ms 的定時任務 task1,按規則該任務會被插入到第 2 格。
- 隨著時間的流逝,過了 1ms 後當前時間指向第 2 格,這一格包含的定時任務 task1 會被刪除並執行。
- 當前時間指向第 2 格(此時:到期間隔為 [0ms, 1ms) 的定時任務放第 2 格,[1ms, 2ms) 的放第 3 格,[2ms, 3ms) 的放第 1 格),我們繼續建立一個到期間隔為 2ms 的定時任務 task2,按規則該任務被插入到第 1 格。
簡單時間輪的優點是實現簡單,缺點是:
- 一旦選定 n,就不能包含到期間隔超過 n*u 的定時任務。
- 如果定時任務的到期時間跨度較大,就會選擇較大的 n,在定時任務較少時會造成很大的空間浪費。
有一些簡單時間輪的 變體實現 ,它們通過在定時任務中增加記錄 round 輪次資訊,可以有效彌補上述兩個缺點。同樣以上面 u 為 1ms、n 為 3 的簡單時間輪為例,初始時間指向第 1 格;此時如果要建立到期時間為 4ms 的定時任務,可以在該任務中設定 round 為 1(4/3 取整),剩餘到期時間用 4ms 減去 round*3ms 等於 1ms,因此放到第 2 格;等到當前時間指向第 2 格時,判斷任務中的 round 大於 0,所以不會刪除並執行該任務,而是對其 round 減一(於是 round 變為 0);等到再過 3ms 後,當前時間再次指向第 2 格,判斷任務中的 round 為 0,進而刪除並執行該任務。
然而,這些變體實現因為只使用了一個時間輪,所以仍然存在一個缺點:處理每一格的定時任務列表的時間複雜度是 O(n),如果定時任務數量很大,分攤到每一格的定時任務列表就會很長,這樣的處理效能顯然是讓人無法接受的。
三、層級時間輪
層級時間輪通過使用多個時間輪,並且對每個時間輪採用不同的 u,可以有效地解決簡單時間輪及其變體實現的問題。
參考 Kafka 的 Purgatory 中的層級時間輪實現:
- 每一層時間輪的大小都固定為 n,第一層時間輪的時間單位為 u,那麼第二層時間輪(我們稱之為第一層時間輪的溢位時間輪 Overflow Wheel)的時間單位就為 n*u,以此類推。
- 除了第一層時間輪是固定建立的,其他層的時間輪(均為溢位時間輪)都是按需建立的。
- 原先插入到高層時間輪(溢位時間輪)的定時任務,隨著時間的流逝,會被降級重新插入到低層時間輪中。
以 u 為 1ms、n 為 3 的層級時間輪為例,第一層時間輪的時間單位為 1ms、大小為 3,第二層時間輪的時間單位為 3ms、大小為 3,以此類推。
如上圖所示,該層級時間輪的執行原理如下:
- 初始時,只有第一層(Level 1)時間輪,假設當前時間(藍色箭頭)指向第 1 格(此時:到期間隔為 [0ms, 1ms) 的定時任務放第 1 格,[1ms, 2ms) 的放第 2 格,[2ms, 3ms) 的放第 3 格)。
- 此時我們建立一個到期間隔為 2ms 的定時任務 task1,按規則該任務會被插入到第一層時間輪的第 3 格。
- 同一時刻,我們再次建立一個到期間隔為 4ms 的定時任務 task2,因為到期間隔超過了第一層時間輪的間隔範圍,所以會建立第二層(Level 2)時間輪;第二層時間輪中的當前時間(藍色箭頭)也指向第 1 格,按規則該任務會被插入到第二層時間輪的第 2 格。
- 隨著時間的流逝,過了 2ms 後,第一層時間輪中的當前時間指向第 3 格,這一格包含的任務 task1 會被刪除並執行;此時,第二層時間輪的當前時間沒有變化,依然指向第 1 格。
- 隨著時間的流逝,又過了 1ms 後,第一層時間輪中的當期時間指向第 1 格,這一格中沒有任務;此時,第二層當前時間指向第 2 格,這一格包含的任務 task2 會被刪除並重新插入時間輪,因為剩餘到期時間為 1ms,所以 task2 會被插入到第一層時間輪的第 2 格。
- 隨著時間的流逝,又過了 1ms 後,第一層時間輪中的當前時間指向第 2 格,這一格包含的定時任務 task2 會被刪除並執行;此時,第二層時間輪的當前時間沒有變化,依然指向第 2 格。
四、Kafka 的變體實現
在具體實現層面(參考 Kafka Timer 實現原始碼 ),Kafka 的層級時間輪與上面描述的原理有一些差別。
1. 時間輪表示
如上圖所示,在時間輪的表示上面:
- 使用大小為 wheelSize 的陣列來表示一層時間輪,其中每一格是一個 bucket,每個 bucket 的時間單位為 tick。
- 這個時間輪陣列並沒有模擬迴圈列表的行為(如圖左所示),而是模擬了雜湊表的行為。具體而言(如圖右所示),這個時間輪陣列會隨著 currentTime 的流逝而移動,也就是說 currentTime 永遠是指向第一個 bucket 的,每個落到該時間輪的定時任務,都會根據雜湊函式 (expiration/tick)%wheelSize 雜湊到對應的 bucket 中(參考 原始碼 )。
2. 時鐘驅動方式
常規的時間輪實現中,會在一個執行緒中每隔一個時間單位 tick 就醒來一次,並驅動時鐘走向下一格,然後檢查這一格中是否包含定時任務。如果時間單位 tick 很小(比如 Kafka 中 tick 為 1ms)並且(在最低層時間輪的)定時任務很少,那麼這種驅動方式將會非常低效。
Kafka 的層級時間輪實現中,利用了 Java 內建的 DelayQueue 結構,將每一層時間輪中所有 “包含有定時任務的 bucket” 都加入到同一個 DelayQueue 中(參考 原始碼 ),然後 等到有 bucket 到期後再驅動時鐘往前走 (參考 原始碼 ),並逐個處理該 bucket 中的定時任務(參考 原始碼 )。
如上圖所示:
- 往層級時間輪中新增一個定時任務 task1 後,會將該任務所屬的 bucket2 的到期時間設定為 task1 的到期時間 expiration(= 當前時間 currentTime + 定時任務到期間隔 duration),並將這個 bucket2 新增(Offer)到 DelayQueue 中。
- DelayQueue(內部有一個執行緒)會等待 “到期時間最早(earliest)的 bucket” 到期,圖中等到的是排在隊首的 bucket2,於是經由 poll 返回並刪除這個 bucket2;隨後,時間輪會將當前時間 currentTime 往前移動到 bucket2 的 expiration 所指向的時間(圖中是 1ms 所在的位置);最後,bucket2 中包含的 task1 會被刪除並執行。
上述 Kafka 層級時間輪的驅動方式是非常高效的。雖然 DelayQueue 中 offer(新增)和 poll(獲取並刪除)操作的時間複雜度為 O(log n),但是相比定時任務的個數而言,bucket 的個數其實是非常小的(也就是 O(log n) 中的 n 很小),因此效能也是沒有問題的。
五、Golang 實現要點
timingwheel 中的 Golang 實現,基本上都是參考 Kafka 的層級時間輪的原理來實現的。
因為 Golang 中沒有現成的 DelayQueue 結構,所以自己實現了一個 DelayQueue ,其中:
- PriorityQueue —— 從 NSQ 借用過來的 優先順序佇列 (基於最小堆實現)。
- DelayQueue —— Offer(新增 bucket)和 Poll(獲取並刪除 bucket)的運作方式,跟 Golang Timer 執行時中 addtimerLocked 和 timerproc 的運作方式如出一轍,因此參考了其中的實現方式(參考 原理介紹 )。