TimeWheel演算法介紹及在應用上的探索

vivo互联网技术發表於2024-08-29

作者:來自 vivo 網際網路伺服器團隊- Li Fan

本文從追溯時間輪演算法的出現,介紹了時間輪演算法未出現前,基於佇列的定時任務實現,以及基於佇列的定時任務實現所存在的缺陷。接著我們介紹了時間輪演算法的演算法思想及其資料結構,詳細闡述了三種時間輪模型的資料結構和優劣性。

再次,我們介紹時間輪演算法在 Dubbo 框架中的應用,並給出了它在 Dubbo 中的主要實現方式。

最後,我們以專案中的某個服務架構最佳化出發,介紹了目前設計中存在的缺陷,並藉助來自中介軟體團隊的,包含時間輪演算法實現的延遲 MQ,給出了最佳化設計的方法。

第一章 定時任務及時間輪演算法發展

1.1 時間輪演算法的出現

在計算程式中,定時器用於指定一個具體的時間點去執行某一個既定的任務。而時間輪演算法就是這樣一種能夠實現延遲功能(定時器)的巧妙演算法。時間輪演算法首次出現在 1997 年 George Varghese 和 Anthony Lauck 發表於 IEEE 期刊,名為“Hashed and Hierarchical Timing Wheels: Efficient Data Structures for Implementing a Timer Facility”的論文上。此文章指出,實現作業系統定時器模組的常規演算法需要 O(n)的時間複雜度啟動和維護計時器,對於更大問題規模 (n),這樣的時間開銷是巨大的,文中提出並證明了,透過一種環狀桶的資料結構,可以做到使用 O(1)的時間複雜度,就可以啟動,停止和維護計時器,並介紹了對時間間隔劃分的處理,第一種方式是將所有的計時器時間間隔進行雜湊(Hash),這些時間間隔被雜湊到時間輪上特定的槽位中(Slot),第二種方式是利用多粒度定時輪組成具有層級結構的組合,以擴充套件更大的時間範圍。這兩種結構將在第二章中詳細介紹。

1.2 基於佇列的定時任務執行模型

在計算機的世界中,只有待解決的問題大規模化以後,演算法的價值才能夠得到最大化的體現。在介紹時間輪演算法之前,我們有必要介紹另一種定時任務的實現,即基於佇列的定時任務。佇列這種資料結構無論是在作業系統中還是各程式語言如 Java 中都被大量使用,本文不再展開贅述。

下面從執行緒模型、定時任務種類和任務佇列的資料結構三個方面展開詳細介紹:

(1)執行緒模型

使用者執行緒:負責定時任務的註冊;輪詢執行緒:負責從任務佇列中掃描出符合執行條件的任務,例如任務的待執行時間已經到達,輪詢執行緒將從佇列中取出該任務,並交由非同步執行緒池處理該任務。非同步執行緒池:專門負責任務的執行。

(2)定時任務

定時任務主要分為一次性執行的定時任務(Dubbo 中超時判斷)以及重複執行的定時任務,這兩種定時任務都很好理解,一次性執行的定時任務在規定的未來某一時刻或距離現在的一段固定時長後執行,分別對應絕對值和相對值的概念。

而重複執行的定時任務是在一次性執行任務的基礎上多次重複執行,這意味著,在上述執行緒協調工作中,當重複執行任務執行完成一次後,將被重新放回任務佇列中。

(3)任務佇列資料結構

從最簡單的資料結構出發,假設我們選用最基本的佇列,或者考慮到增減任務的方便,選擇雙向連結串列做為任務佇列,為任務佇列中的每個任務提供一個時間戳欄位,這種實現的策略會產生哪些問題?

最大的問題是在查詢上,假設任務佇列中存在一些任務,那麼為了找出達到規定時刻的待執行任務,輪詢執行緒需要掃描全部任務,此種資料結構的時間複雜度為 O(n),而且存在大量的空輪詢,即大部分的任務都沒有達到執行時間,這種效率幾乎是不可接受的。

為了提升查詢效率,可以嘗試從資料結構出發,利用有序佇列,在計算機的演算法中,有序性可以顯著提高遍歷的效率,這樣一來,定時任務佇列輪詢執行緒從頭向尾遍歷時,在發現任意任務未達到規定執行時間戳後,就可以停止遍歷。

但是維護有序性也需要付出代價,普通任務佇列入隊一個任務的時間複雜度僅僅是 O(1),而有序任務佇列入隊一個任務的時間複雜度為 O(nlogn)。其次,我們可以借鑑分治的思想,將任務佇列分成 n 份,利用多執行緒遍歷,線上程完全併發執行的情況下,問題規模簡化到原來的 1/n。但是多執行緒也會 CPU 執行效率降低。

綜上分析,定時任務框架需要具有如下要素:

  1. 嚴格高效的資料結構,並不能基於簡單的佇列結構來儲存任務,否則輪詢的執行效率永遠無法提高。
  2. 簡單的併發模型:CPU 的執行緒非常寶貴,不應占用過多執行緒資源。

時間輪演算法解決了上述基於佇列的定時任務執行模型的缺陷,因此時間輪演算法思想在後面網際網路技術發展中得到了大量應用,我們熟悉的 Linux Crontab,以及 Java 開發中常用的 Dubbo、Netty、Quartz、Akka、ZooKeeper、Kafka 等,幾乎所有的時間任務排程都採用了時間輪演算法的思想。

值得一提的是,在 Dubbo 中,為了增強系統容錯,很多地方需要用到只需一次執行的任務排程,比如消費者需要知道各個 RPC 呼叫是否超時,而在 Dubbo 最開始的實現中,是採用將所有的返回結果(defaultFuture),都放入一個集合中,並透過一個定時任務,間隔掃描所有的 future,逐個判斷是否超時。這樣邏輯簡單,但是浪費效能,後面 Dubbo 借鑑了 Netty,引入了時間輪。

第二章 時間輪演算法思想介紹及應用場景介紹

2.1 時間輪簡介

時間輪實質上是一種高效利用執行緒資源的任務排程模型,將大批次的任務全部整合進一個排程器中,從而對任務進行統一的排程管理,針對定時任務,延時任務等事件的排程效率非常高。

時間輪演算法的核心是:第一章中描述的對任務佇列進行輪詢的執行緒不再負責遍歷所有的任務,而是僅僅遍歷時間刻度。時間輪演算法好比指標不斷在時鐘上旋轉、遍歷,如果發現某一時刻上有任務(任務佇列),那麼就會將任務佇列上的所有任務都執行一遍,這樣便大幅度的減少了額外的掃描操作。

第一章中,我們提出了一個高效的定時任務框架需要具備嚴格高效的資料結構和簡單的併發模型兩個特點,而時間輪模型正是具備了這樣的特點。

基於時間輪演算法思想,後續也出現了很多種時間輪模型,目前流行的大致有三種,分別為簡單時間輪模型、帶有 round 的時間輪模型以及分層時間輪模型,下面將依次介紹這三種時間輪模型。

2.2 時間輪模型

2.2.1 簡單時間輪模型

簡單時間輪模型不再使用佇列作為資料結構,而是使用陣列加連結串列的形式(很經典的組合), 如下圖所示,該時間輪透過陣列實現,可以很方便地透過下標定位到定時任務鏈路,因此,新增、刪除、執行定時任務的時間複雜度為 O(1)。

圖 2.2.1 簡單時間輪模型

顯然,這種簡單時間輪就解決了任務佇列中遍歷效率低下的問題,輪詢執行緒遍歷到某一個時間刻度後,總是執行對應刻度上任務佇列中的所有任務(通常是將任務扔給非同步執行緒池來處理),而不再需要遍歷檢查所有任務的時間戳是否達到要求。

透過增加槽(slot)的數量,可以細化的時間粒度以及得到更大的時間跨度,但是這樣的實現方式有巨大的缺陷:

  1. 當時間粒度小,時間跨度大,而任務又很少的時候,時間槽的輪詢效率變低。
  2. 當時間粒度小,時間槽數量多,而任務又很少時,很多槽位佔用的記憶體空間是沒有意義的。

2.2.2 帶有 round 的時間輪模型

類比迴圈陣列的思想,後人設計了帶 round 的時間輪,這種時間輪的結構如下圖所示:

圖 2.2.2 帶有 round 的時間輪模型

如圖 2.2.2 所示,expire 代表到期時間,round 表示時間輪要在轉動幾圈之後才執行任務,也就是說當指標轉到某個 bucket 時,不能像簡單的單時間輪那樣直接執行 bucket 下所有的任務。而且要去遍歷該 bucket 下的連結串列,判斷時間輪轉動的次數是否等於節點中的 round 值,只有當 expire 和 round 都相同的情況下,才能執行任務。

這種結構的時間輪明顯減少了所需刻度的個數,即彌補了簡單時間輪在時間槽位較多,而任務較少情況下記憶體空間浪費的問題。

但是這種結構的時間輪並不能減少輪詢執行緒的輪詢次數,效率相對較低。

2.2.3 分層時間輪模型

分層時間輪也是一種對簡單時間輪的改良方案,它的設計理念可以類比於日常生活中的時鐘,分別有時、分、秒三個層級,並且每個輪盤分別具有 24、60、60 個刻度,因此,只需要 144 個刻度,即可表示一天的時間,而這種表示方式的優勢在於,倍數級別時間表示的新增,只需要常數級別的刻度增加。例如,在 144 個刻度可表示的一天時間的基礎上,新增 30 個刻度,即可精細表示一個月的時間。

圖 2.2.3 分層時間輪模型

分層時間輪的工作方式為低層級的時間輪帶動高層級的時間輪轉動,圖中箭頭為任務的“下放”,例如,2 號 8 點 40 分 0 秒執行的任務,當天輪轉動到刻度 2 時,會將第 2 天的任務,下放到對應時輪刻度為 8 的槽位中,當時輪轉動到 8 時,會將任務繼續下放到分輪刻度為 40 的槽位中,直至到最低層次的時間輪,轉動到該槽位時,將該槽位中的任務,全部執行。

針對時間複雜度,這種時間輪對比帶有 round 的時間輪不再遍歷計算對比任務的 round,而是直接全部取出執行。

針對空間複雜度,分層時間輪利用維度上升的思路對時間輪進行分層,每個層級的時間粒度對應一個時間輪,多個時間輪之間進行級聯協作。

2.3 時間輪應用場景介紹

時間輪作為高效的排程模型,在各種場景均有廣泛的應用,常見的場景主要有如下幾個:

(1)定時器

時間輪常用於實現定時器,可以在指定時間執行特定任務。定時器可以用於週期性任務、超時任務等,如輪詢 I/O 事件、定期重新整理快取、定時清理垃圾資料等。

(2)負載均衡

時間輪可以用於實現負載均衡演算法,將請求分配到不同的伺服器上,避免單個伺服器負載過重。時間輪可以根據伺服器的負載情況來動態調整分配策略,實現動態負載均衡。

(3)事件驅動

時間輪可以用於實現事件驅動模型,將事件分配到不同的處理器上,提高併發處理能力。事件可以是 I/O 事件、定時事件、使用者事件等,時間輪可以根據事件的型別和優先順序來動態調整分配策略,實現高效的事件驅動模型。

(4)資料庫管理

時間輪可以用於實現資料庫管理,將資料分配到不同的儲存裝置上,提高資料讀寫效率。時間輪可以根據資料的型別、大小和訪問頻率等來動態調整資料分配策略,實現高效的資料庫管理。

(5)其他應用

時間輪還可以用於其他一些應用,如訊息佇列、任務排程、網路流量控制等,具體應用取決於具體的需求和場景。

第三章 時間輪在 Dubbo 的應用與實現

3.1 Dubbo 中時間輪的應用

Dubbo 的設計中,客戶端在呼叫服務端的時候,會對任務進行計時,如果任務超時,那麼會被檢測到,並重試請求。在 Dubbo 最開始的實現中,是採用將所有的返回結果(defaultFuture),都放入一個集合中,並透過一個定時任務,間隔掃描所有的 future,逐個判斷是否超時。

這樣邏輯簡單,但是浪費效能,後面 Dubbo 借鑑了 Netty,引入了時間輪。任務交由時間輪管理,由專門的執行緒進行排程。

3.2 Dubbo 中時間輪的實現

Dubbo 中時間輪演算法的實現,主要有一個類和三個介面:

首先是 Timer 介面,這個一個排程的核心介面,主要用於後臺的一次性排程,我們僅介紹 newTimeOut 方法,這個方法就是把一個任務扔給排程器執行,第一個引數型別 TimerTask,即需要執行的任務。

接下來是 TimeTask 介面,它只有一個方法 run,引數型別是 Timeout,我們注意到上面 Timer 介面的 newTimeout 這個方法返回的引數就是 Timeout,和此處的入參相同,實際這裡傳入的 Timeout 引數就是 newTimeout 的返回值。

Timeout 物件與 TimerTask 物件一一對應,兩者的關係類似於執行緒池返的 Future 物件與提交到執行緒池中的任務物件之間的關係。

最後是 TimeOut 介面,它代表的是對一次任務的處理,其中有幾個方法,從介紹上即可看出各方法用途,這裡不再贅述。

上述幾個介面從邏輯上構成了一個任務排程系統。下面是任務排程系統的核心,即時間輪排程器的實現-- HashedWheelTimer。

仔細看它的類上註釋可以發現,該方法並不能提供精確的計時,而是檢測每個 tick 中(也就是時間輪中的一個時間槽),是否有 TimerTask,其期望執行時間已經落後於當前時間,如果是則執行該任務。任務執行時間的精確度可以透過細化時間槽來提升。

預設的 tick duration 是 100 毫秒,大部分網路應用中,I/O 超時並非必須是精準的,例如 5 秒超時,實際上稍晚一會也是可以的,因此這個預設值無需修改。

這個類維護了一種稱為“wheel”的資料結構,也就是我們說的時間輪。簡單地說,一個 wheel 就是一個 hash table,它的 hash 函式是任務的截止時間,也就是我們要透過 hash 函式把這個任務放到它應該在的時間槽中,這樣隨著時間的推移,當我們進入某個時間槽中時,這個槽中的任務也剛好到了它該執行的時間。

這樣就避免了在每一個槽中都需要檢測所有任務是否需要執行。在 HashedWheelTimer 的建構函式中,最重要的是 createWheel 方法,忽略基本的引數校驗,只看方法主流程,首先是對時間槽數量的規範化處理,處理方式為將構造時傳入的數量,修改為大於等於它的最小的 2 的次冪。為什麼這樣處理以及處理的具體方式,有興趣可以研究下原始碼。

接著則是建立時間槽陣列,最後是初始化時間槽陣列的每個引數。

下面介紹下 newTimeout 方法,這個方法的主要作用是向排程器中新增一個待執行的任務,同樣忽略基本的引數校驗,主體流程為:

  1. 第一步將等待排程的任務數+1,如果超過了最大限制,則-1 並丟擲異常。
  2. 第二步則呼叫 start 方法,啟動時間輪。
  3. 第三步計算當前任務的截止時間,並做防溢位處理。
  4. 構造一個 TimeOut ,並放入等待佇列。

這裡我們展開比較重要的 start 方法,首先獲取 worker 的執行狀態,如果是初始化狀態,則更新成已啟動狀態,啟動 workThread 執行緒,若是其他狀態,則做其他相應的處理。接著是等待 workThread 將 startTime 初始化完成(在 Worker 的 run 方法中初始化完成),之所以需要等待 startTime 初始化完成,是因為 newTimeout 方法中,start 方法呼叫後也用到了這個 startTime,不這樣做,任務的截止時間計算會有問題。

至此,我們介紹了利用 HashedWheelTimer 新增一個任務的主體流程,接下來是時間輪的內部運轉。

首先是 HashedWheelTimer 的內部類 Worker,其中 run 方法的主體流程如下:

1.初始化 startTime,這裡與上文中 start 方法內部對應。初始化後,利用閉鎖 CountDownLatch 通知等待執行緒往下執行。

2.當定時器處於已啟動狀態時,不停地推進 ticket,推進的過程分解為:

  • 等待下一個 ticket 的到來。
  • ticket 到來後,計算該 ticket 對應時間輪的槽位(取模運算)。
  • 處理已取消的任務佇列。
  • 獲取當前時間槽,並將待處理任務佇列中的任務放到槽中。
  • 執行當前時間槽中的任務。

3.如果時間輪已經停止了,則執行以下流程:

  • 清理所有時間槽中的未處理任務排程。
  • 清理待處理任務排程佇列,將未取消的加入到未處理集合中。
  • 處理已取消的任務佇列。

我們重點關注下定時器啟動狀態下的第 3 步,獲取當前時間槽,並將待處理任務佇列中的任務放到槽中的方法 transferTimeoutsToBuckets,其流程為以下幾個步驟(這裡規定迴圈了有限次,防止待處理佇列過大,導致本次新增到槽耗費時間過長):

  • 從待處理任務排程佇列中取出第一個任務,進行校驗。
  • 根據取出的待處理任務排程,計算出一個槽。
  • 設定此任務排程的剩餘圈數(從這裡看出 Dubbo 用的是我們在 2.2.2 中介紹的“帶有 round 的時間輪”)。
  • 取計算出的槽和當前槽中的較大者,並進行取模。
  • 將此任務排程加入對應的槽中。

總結:這部分內容我們分別從向排程器中新增任務的主體流程和時間輪內部運轉兩個部分,簡單介紹了 Dubbo 中時間輪的實現。

如果感興趣,可以學習其原始碼,裡面很多程式碼設計非常巧妙,比如 startTime 初始化及初始化完成後的執行緒間通訊實現,這些設計思路對筆者這樣的初學者來說很有益處。

第四章 時間輪演算法的應用展望

筆者在剛開始工作時,設計過一個叫做下載中心的服務,這個服務的功能為匯出和下載專案中的資料檔案,實際的定位是為了減少非同步執行緒過多而影響各個核心業務,因此將其功能抽取出來,從而達到減少核心業務壓力的目標。

下載中心的初步設計,考慮到併發請求以及檔案過大帶來的記憶體溢位問題,除了採取各種方式避免外,整體思路是,預計特別大的檔案,先將任務記錄進行持久化,並透過後臺執行緒池慢慢執行這些任務,透過任務記錄,主動拉取資料、生成檔案等。

模型類似於 Netty 中的 BOSS-WORKER 的模式,BOSS 執行緒負責定時從資料庫中查出未消費的任務,並將其分配給 worker 執行緒池進行消費,如圖 4.1 所示。

圖 4.1 改造前應用服務任務消費模式示意圖

當前設計雖然可以做到防止記憶體溢位等問題,但是這樣的設計也存在一定的缺陷:

  1. 如果後續使用者量增多,可以考慮水平擴充服務的數量,但是用於持久化任務記錄的資料庫會成為瓶頸。
  2. 即使 BOSS 執行緒不難做到避免任務的重複消費,但是待執行任務的查詢效率會大大降低。
  3. 整個服務太過於依賴 BOSS 執行緒。

因此,考慮一種方式替代這種 BOSS-WORKER 模式,目前想到的一種方式為 MQ 訊息佇列,將待執行的任務資訊投至 MQ 佇列當中,然後該服務對其進行消費。這樣的做法,即可解決上述 3 個問題,並具備維持任務有序性的優勢。

但是記憶體易溢位的問題仍然存在,因此,考慮限制消費任務的執行緒併發數量。如果超過這個數量,則不再消費任務,而是重新投遞任務至 MQ 佇列中。這裡,我們有更好的做法,即需要將任務重新投遞至 MQ 佇列時,做一些延時的處理,防止反覆重新投遞任務。整體流程如圖 4.2 所示:

圖 4.2 改造後應用服務任務消費模式示意圖

圖中,綠色模組為整個系統設計中的用以排程定時任務的任務排程模組,利用時間輪來統一管理這些定時任務。

對於短暫延時和長延遲的訊息,我們都期望延時儘可能的精確,而對於長延時的訊息,我們還要對其進行持久化,也就是暫存。等到訊息快要到期時,再重新取出,進行投遞。

而這種長延時訊息的持久化,與我們圖 4.1 所示定時從資料庫取任務所遇到的瓶頸是一致的。

我們更期望有成熟的框架,能夠提供 1.長延遲任務的持久化 以及 2. 任務排程 的能力。從中介軟體平臺組提供的 MQ 中,我們發現目前它是已經支援 包含這兩個能力的延遲 MQ 功能的。延遲 MQ 架構大致如下圖所示:

圖 4.3 延遲 MQ 訊息處理流程圖

具體延遲訊息的傳送和處理的流程如下圖所示:

圖 4.4 延遲訊息傳送和處理流程示意圖

實際上,該延遲 MQ 的實現,正是由時間輪實現的排程 以及利用 MongoDB 資料庫 實現的持久化,這與我們所期望的能力完全一致,完全可以滿足我們的需求。

總結

本文從定時任務和時間輪演算法的起源開始,對時間輪演算法進行了介紹。詳細的闡述了時間輪的演算法思想,以及簡單時間輪、帶 round 的時間輪以及分層時間輪這三種常見的時間輪模型,並給出了對應的資料結構實現。

接著以 Dubbo 為例,介紹了時間輪模型在 Dubbo 中的應用,從原始碼出發,介紹了該演算法在 Dubbo 中的主要實現。

最後,我們介紹了筆者自身所做過的一個小模組,展開分析了該模組功能目前所遇到的瓶頸,並給出了透過融合了時間輪演算法的延遲 MQ 來最佳化當前設計的思路。

參考文獻: Hashed and Hierarchical Timing Wheels: EfficientData Structures for Implementing a Timer Facility.

相關文章