在開始正題以前,先閒聊幾句。有人說,計算機科學這個學科,軟件方向研究到頭就是數學,硬件方向研究到頭就是物理,最輕鬆的是中間這批使用者,能夠不太懂物理,不太懂數學,依舊可使用計算機做爲本身謀生的工具。這個規律具備普適應,看看「定時器」這個例子,往應用層研究,有 Quartz,Spring Schedule 等框架;往分佈式研究,又有 SchedulerX,ElasticJob 等分佈式任務調度;往底層實現看,又有多種定時器實現方案的原理、工做效率、數據結構能夠深究…簡單上手使用一個框架,並不能體現出我的的水平,如何與他人構成區分度?我以爲至少要在某一個方向有所建樹:html
回到這篇文章的主題,我首先會圍繞第三個話題討論:設計實現一個定時器,可使用什麼算法,採用什麼數據結構。接着再聊聊第一個話題:探討一些優秀的定時器實現方案。java
不少場景會用到定時器,例如git
定時器像水和空氣通常,廣泛存在於各個場景中,通常定時任務的形式表現爲:通過固定時間後觸發、按照固定頻率週期性觸發、在某個時刻觸發。定時器是什麼?能夠理解爲這樣一個數據結構:github
存儲一系列的任務集合,而且 Deadline 越接近的任務,擁有越高的執行優先級 在用戶視角支持如下幾種操做: NewTask:將新任務加入任務集合 Cancel:取消某個任務 在任務調度的視角還要支持: Run:執行一個到期的定時任務算法
判斷一個任務是否到期,基本會採用輪詢的方式,每隔一個時間片 去檢查 最近的任務 是否到期,而且,在 NewTask 和 Cancel 的行爲發生以後,任務調度策略也會出現調整。數組
說到底,定時器仍是靠線程輪詢實現的。微信
咱們主要衡量 NewTask(新增任務),Cancel(取消任務),Run(執行到期的定時任務)這三個指標,分析他們使用不一樣數據結構的時間/空間複雜度。數據結構
在 Java 中,LinkedList
是一個自然的雙向鏈表併發
NewTask:O(N) Cancel:O(1) Run:O(1) N:任務數框架
NewTask O(N) 很容易理解,按照 expireTime 查找合適的位置便可;Cancel O(1) ,任務在 Cancel 時,會持有本身節點的引用,因此不須要查找其在鏈表中所在的位置,便可實現當前節點的刪除,這也是爲何咱們使用雙向鏈表而不是普通鏈表的緣由是 ;Run O(1),因爲整個雙向鏈表是基於 expireTime 有序的,因此調度器只須要輪詢第一個任務便可。
在 Java 中,PriorityQueue
是一個自然的堆,能夠利用傳入的 Comparator
來決定其中元素的優先級。
NewTask:O(logN) Cancel:O(logN) Run:O(1) N:任務數
expireTime 是 Comparator
的對比參數。NewTask O(logN) 和 Cancel O(logN) 分別對應堆插入和刪除元素的時間複雜度 ;Run O(1),由 expireTime 造成的小根堆,咱們總能在堆頂找到最快的即將過時的任務。
堆與雙向有序鏈表相比,NewTask 和 Cancel 造成了 trade off,但考慮到現實中,定時任務取消的場景並非不少,因此堆實現的定時器要比雙向有序鏈表優秀。
Netty 針對 I/O 超時調度的場景進行了優化,實現了 HashedWheelTimer
時間輪算法。
HashedWheelTimer
是一個環形結構,能夠用時鐘來類比,鐘面上有不少 bucket ,每個 bucket 上能夠存放多個任務,使用一個 List 保存該時刻到期的全部任務,同時一個指針隨着時間流逝一格一格轉動,並執行對應 bucket 上全部到期的任務。任務經過取模
決定應該放入哪一個 bucket 。和 HashMap 的原理相似,newTask 對應 put,使用 List 來解決 Hash 衝突。
以上圖爲例,假設一個 bucket 是 1 秒,則指針轉動一輪表示的時間段爲 8s,假設當前指針指向 0,此時須要調度一個 3s 後執行的任務,顯然應該加入到 (0+3=3) 的方格中,指針再走 3 次就能夠執行了;若是任務要在 10s 後執行,應該等指針走完一輪零 2 格再執行,所以應放入 2,同時將 round(1)保存到任務中。檢查到期任務時只執行 round 爲 0 的, bucket 上其餘任務的 round 減 1。
再看圖中的 bucket5,咱們能夠知道在 後,有兩個任務須要執行,在
後有一個任務須要執行。
NewTask:O(1) Cancel:O(1) Run:O(M) Tick:O(1) M: bucket ,M ~ N/C ,其中 C 爲單輪 bucket 數,Netty 中默認爲 512
時間輪算法的複雜度可能表達有誤,比較難算,僅供參考。另外,其複雜度還受到多個任務分配到同一個 bucket 的影響。而且多了一個轉動指針的開銷。
傳統定時器是面向任務的,時間輪定時器是面向 bucket 的。
構造 Netty 的 HashedWheelTimer
時有兩個重要的參數:tickDuration
和 ticksPerWheel
。
tickDuration
:即一個 bucket 表明的時間,默認爲 100ms,Netty 認爲大多數場景下不須要修改這個參數;ticksPerWheel
:一輪含有多少個 bucket ,默認爲 512 個,若是任務較多能夠增大這個參數,下降任務分配到同一個 bucket 的機率。Kafka 針對時間輪算法進行了優化,實現了層級時間輪 TimingWheel
若是任務的時間跨度很大,數量也多,傳統的 HashedWheelTimer
會形成任務的 round
很大,單個 bucket 的任務 List 很長,並會維持很長一段時間。這時可將輪盤按時間粒度分級:
如今,每一個任務除了要維護在當前輪盤的 round
,還要計算在全部下級輪盤的round
。當本層的round
爲0時,任務按下級 round
值被下放到下級輪子,最終在最底層的輪盤獲得執行。
NewTask:O(H) Cancel:O(H) Run:O(M) Tick:O(1) H:層級數量
設想一下一個定時了 3 天,10 小時,50 分,30 秒的定時任務,在 tickDuration = 1s 的單層時間輪中,須要通過: 次指針的撥動才能被執行。但在 wheel1 tickDuration = 1 天,wheel2 tickDuration = 1 小時,wheel3 tickDuration = 1 分,wheel4 tickDuration = 1 秒 的四層時間輪中,只須要通過
次指針的撥動!
相比單層時間輪,層級時間輪在時間跨度較大時存在明顯的優點。
JDK 中的 Timer
是很是早期的實現,在如今看來,它並非一個好的設計。
// 運行一個一秒後執行的定時任務
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
// do sth
}
}, 1000);
複製代碼
使用 Timer
實現任務調度的核心是 Timer
和 TimerTask
。其中 Timer
負責設定 TimerTask
的起始與間隔執行時間。使用者只須要建立一個 TimerTask
的繼承類,實現本身的 run
方法,而後將其丟給 Timer
去執行便可。
public class Timer {
private final TaskQueue queue = new TaskQueue();
private final TimerThread thread = new TimerThread(queue);
}
複製代碼
其中 TaskQueue 是使用數組實現的一個簡易的堆。另一個值得注意的屬性是 TimerThread
,Timer
使用惟一的線程負責輪詢並執行任務。Timer
的優勢在於簡單易用,但也由於全部任務都是由同一個線程來調度,所以整個過程是串行執行的,同一時間只能有一個任務在執行,前一個任務的延遲或異常都將會影響到以後的任務。
輪詢時若是發現 currentTime < heapFirst.executionTime,能夠 wait(executionTime - currentTime) 來減小沒必要要的輪詢時間。這是廣泛被使用的一個優化。
Timer
只能被單線程調度TimerTask
中出現的異常會影響到 Timer
的執行。因爲這兩個缺陷,JDK 1.5 支持了新的定時器方案 ScheduledExecutorService
。
// 運行一個一秒後執行的定時任務
ScheduledExecutorService service = Executors.newScheduledThreadPool(10);
service.scheduleA(new Runnable() {
@Override
public void run() {
//do sth
}
}, 1, TimeUnit.SECONDS);
複製代碼
相比 Timer
,ScheduledExecutorService
解決了同一個定時器調度多個任務的阻塞問題,而且任務異常不會中斷 ScheduledExecutorService
。
ScheduledExecutorService
提供了兩種經常使用的週期調度方法 ScheduleAtFixedRate 和 ScheduleWithFixedDelay。
ScheduleAtFixedRate 每次執行時間爲上一次任務開始起向後推一個時間間隔,即每次執行時間爲 : ,
,
, …
ScheduleWithFixedDelay 每次執行時間爲上一次任務結束起向後推一個時間間隔,即每次執行時間爲:,
,
, ...
因而可知,ScheduleAtFixedRate 是基於固定時間間隔進行任務調度,ScheduleWithFixedDelay 取決於每次任務執行的時間長短,是基於不固定時間間隔的任務調度。
ScheduledExecutorService
底層使用的數據結構爲 PriorityQueue
,任務調度方式較爲常規,不作特別介紹。
Timer timer = new HashedWheelTimer();
//等價於 Timer timer = new HashedWheelTimer(100, TimeUnit.MILLISECONDS, 512);
timer.newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//do sth
}
}, 1, TimeUnit.SECONDS);
複製代碼
前面已經介紹過了 Netty 中 HashedWheelTimer
內部的數據結構,默認構造器會配置輪詢週期爲 100ms,bucket 數量爲 512。其使用方法和 JDK 的 Timer
十分類似。
private final Worker worker = new Worker();// Runnable
private final Thread workerThread;// Thread
複製代碼
因爲篇幅限制,我並不打算作詳細的源碼分析,但上述兩行來自 HashedWheelTimer
的代碼闡釋了一個事實:HashedWheelTimer
內部也一樣是使用單個線程進行任務調度。與 JDK 的 Timer
同樣,存在」前一個任務執行時間過長,影響後續定時任務執行「的問題。
理解 HashedWheelTimer 中的 ticksPerWheel,tickDuration,對兩者進行合理的配置,可使得用戶在合適的場景獲得最佳的性能。
毋庸置疑,JDK 的 Timer
使用的場景是最窄的,徹底能夠被後二者取代。如何在 ScheduledExecutorService
和 HashedWheelTimer
之間如何作選擇,須要區分場景,作一個簡單的對比:
ScheduledExecutorService
是面向任務的,當任務數很是大時,使用堆(PriorityQueue)維護任務的新增、刪除會致使性能降低,而 HashedWheelTimer
面向 bucket,設置合理的 ticksPerWheel,tickDuration ,能夠不受任務量的限制。因此在任務很是多時,HashedWheelTimer
能夠表現出它的優點。HashedWheelTimer
內部的 Worker 線程依舊會不停的撥動指針,雖然不是特別消耗性能,但至少不能說:HashedWheelTimer
必定比 ScheduledExecutorService
優秀。HashedWheelTimer
因爲開闢了一個 bucket 數組,佔用的內存會稍大。上述的對比,讓咱們獲得了一個最佳實踐:在任務很是多時,使用 HashedWheelTimer
能夠得到性能的提高。例如服務治理框架中的心跳定時任務,服務實例很是多時,每個客戶端都須要定時發送心跳,每個服務端都須要定時檢測鏈接狀態,這是一個很是適合使用 HashedWheelTimer
的場景。
咱們須要注意HashedWheelTimer
使用單線程來調度任務,若是任務比較耗時,應當設置一個業務線程池,將HashedWheelTimer
當作一個定時觸發器,任務的實際執行,交給業務線程池。
若是全部的任務都知足: taskNStartTime - taskN-1StartTime > taskN-1CostTime,即任意兩個任務的間隔時間小於先執行任務的執行時間,則無需擔憂這個問題。
實際使用 HashedWheelTimer
時,應當將其當作一個全局的任務調度器,例如設計成 static 。時刻謹記一點:HashedWheelTimer
對應一個線程,若是每次實例化 HashedWheelTimer
,首先是線程會不少,其次是時間輪算法將會徹底失去意義。
ticksPerWheel,tickDuration 這兩個參數尤其重要,ticksPerWheel 控制了時間輪中 bucket 的數量,決定了衝突發生的機率,tickDuration 決定了指針撥動的頻率,一方面會影響定時的精度,一方面決定 CPU 的消耗量。當任務數量很是大時,考慮增大 ticksPerWheel;當時間精度要求不高時,能夠適當加大 tickDuration,不過大多數狀況下,不須要 care 這個參數。
當時間跨度很大時,提高單層時間輪的 tickDuration 能夠減小空轉次數,但會致使時間精度變低,層級時間輪既能夠避免精度下降,又避免了指針空轉的次數。若是有時間跨度較長的定時任務,則能夠交給層級時間輪去調度。此外,也能夠按照定時精度實例化多個不一樣做用的單層時間輪,dayHashedWheelTimer、hourHashedWheelTimer、minHashedWheelTimer,配置不一樣的 tickDuration,此法雖 low,但不失爲一個解決方案。Netty 設計的 HashedWheelTimer
是專門用來優化 I/O 調度的,場景較爲侷限,因此並無實現層級時間輪;而在 Kafka 中定時器的適用範圍則較廣,因此其實現了層級時間輪,以應對更爲複雜的場景。
[2] novoland.github.io/併發/2014/07/…
[3] www.cs.columbia.edu/~nahum/w699…
歡迎關注個人微信公衆號:「Kirito的技術分享」,關於文章的任何疑問都會獲得回覆,帶來更多 Java 相關的技術分享。