定時任務方案大百科 前言 Timer ScheduledExecutor 時間輪 分佈式定時任務 總結

原文地址:https://crossoverjie.top

前言

節前有更新一篇定時任務的相關文章《延時消息之時間輪》,有朋友提出希望可以完整的介紹下常見的定時任務方案,於是便有了這篇文章。

Timer

本次會主要討論大家使用較多的方案,首先第一個就是 Timer 定時器,它可以在指定時間後運行或週期性運行任務;使用方法也非常簡單:

這樣便可創建兩個簡單的定時任務,分別在 3s/5s 之後運行。

使用起來確實很簡單,但也有不少毛病,想要搞清楚它所存在的問題首先就要理解其實現原理。

實現原理

定時任務要想做到按照我們給定的時間進行調度,那就得需要一個可以排序的容器來存放這些任務。

Timer 中內置了一個 TaskQueue 隊列,用於存放所有的定時任務。

其實本質上是用數組來實現的一個最小堆,它可以讓每次寫入的定時任務都按照執行時間進行排序,保證在堆頂的任務執行時間是最小的。

這樣在需要執行任務時,每次只需要取出堆頂的任務運行即可,所以它取出任務的效率很高爲

結合代碼會比較容易理解:

在寫入任務的時候會將一些基本屬性存放起來(任務的調度時間、週期、初始化任務狀態等),最後就是要將任務寫入這個內置隊列中。


在任務寫入過程中最核心的方法便是這個 fixUp() ,它會將寫入的任務從隊列的中部通過執行時間與前一個任務做比對,一直不斷的向前比較。

如果這個時間是最早執行的,那最後將會被移動到堆頂。

通過這個過程可以看出 Timer 新增一個任務的時間複雜度爲


再來看看它執行任務的過程,其實在初始化 Timer 的時候它就會在後臺啓動一個線程用於從 TaskQueue 隊列中獲取任務進行調度。


所以我們只需要看他的 run() 即可。

從這段代碼中很明顯可以看出這個線程是一直不斷的在調用

task = queue.getMin();

來獲取任務,最後使用 task.run() 來執行任務。

getMin() 方法中可以看出和我們之前說的一致,每次都是取出堆頂的任務執行。

一旦取出來的任務執行時間滿足要求便可運行,同時需要將它從這個最小堆實現的隊列中刪除;也就是調用的 queue.removeMin() 方法。


其實它的核心原理和寫入任務類似,只不過是把堆尾的任務提到堆頂,然後再依次比較將任務往後移,直到到達合適的位置。

從剛纔的寫入和刪除任務的過程中其實也能看出,這個最小堆只是相對有序並不是絕對的有序。

源碼看完了,自然也能得出它所存在的問題了。

  • 後臺調度任務的線程只有一個,所以導致任務是阻塞運行的,一旦其中一個任務執行週期過長將會影響到其他任務。
  • Timer 本身沒有捕獲其他異常(只捕獲了 InterruptedException),一旦任務出現異常(比如空指針)將導致後續任務不會被執行。

ScheduledExecutor

既然 Timer 存在一些問題,於是在 JDK1.5 中的併發包中推出了 ScheduledThreadPoolExecutor 來替代 Timer,從它所在包路徑也能看出它本身是支持任務併發執行的。

先來看看它的類繼承圖:

可以看到他本身也是一個線程池,繼承了 ThreadPoolExecutor

從他的構造函數中也能看出,本質上也是創建了一個線程池,只是這個線程池中的阻塞隊列是一個自定義的延遲隊列 DelayedWorkQueue(與 Timer 中的 TaskQueue 作用一致)


新建任務

當我們寫入一個定時任務時,首先會將任務寫入到 DelayedWorkQueue 中,其實這個隊列本質上也是使用數組實現的最小堆。

新建任務時最終會調用到 offer() 方法,在這裏也會使用 siftUp() 將寫入的任務移動到堆頂。

原理就和之前的 Timer 類似,只不過這裏是通過自定義比較器來排序的,很明顯它是通過任務的執行時間進行比較的。

運行任務

所以這樣就能將任務按照執行時間的順序排好放入到線程池中的阻塞隊列中。

這時就得需要回顧一下之前線程池的知識點了:

在線程池中會利用初始化時候的後臺線程從阻塞隊列中獲取任務,只不過在這裏這個阻塞隊列變爲了 DelayedWorkQueue,所以每次取出來的一定是按照執行時間排序在前的任務。


Timer 類似,要在任務取出後調用 finishPoll() 進行刪除,也是將最後一個任務提到堆頂,然後挨個對比移動到合適的位置。

而觸發消費這個 DelayedWorkQueue 隊列的地方則是在寫入任務的時候。


本質上是調用 ThreadPoolExecutoraddWorker() 來寫入任務的,所以消費 DelayedWorkQueue 也是在其中觸發的。

這裏更多的是關於線程池的知識點,不太清楚的可以先看看之前總結的線程池篇,這裏就不再贅述。

原理看完了想必也知道和 Timer 的優勢在哪兒了。

Timer ScheduledThreadPoolExecutor
單線程阻塞 多線程任務互不影響
異常時任務停止 依賴於線程池,單個任務出現異常不影響其他任務

所以有定時任務的需求時很明顯應當淘汰 Timer 了。

時間輪

最後一個是基於時間輪的定時任務,這個我在上一篇《延時消息之時間輪》有過詳細介紹。

通過源碼分析我們也可以來做一個對比:

ScheduledThreadPoolExecutor 基於時間輪
寫入效率 基於最小堆,任務越多效率越低 HashMap 的寫入類似,效率很高。
執行效率 每次取出第一個,效率很高 每秒撥動一個指針取出任務

所以當寫入的任務較多時,推薦使用時間輪,它的寫入效率更高。

但任務很少時其實 ScheduledThreadPoolExecutor 也不錯,畢竟它不會每秒都去撥動指針消耗 CPU ,而是一旦沒有任務線程會阻塞直到有新的任務寫入進來。

RingBufferWheel 更新

在之前的《延時消息之時間輪》中自定義了一個基於時間輪的定時任務工具 RingBufferWheel ,在網友的建議下這次順便也做了一些調整,優化了 API 也新增了取消任務的 API。

在之前的 API 中,每當新增一個任務都要調用一下 start(),感覺很怪異;這次直接將啓動函數合併到 addTask 中,使用起來更加合理。

同時任務的寫入也支持併發了。


不過這裏需要注意的是 start() 在併發執行的時候只能執行一次,於是就利用了 CAS 來保證同時只有一個線程可以執行成功。

同時在新增任務的時候會返回一個 taskId ,利用此 ID 便可實現取消任務的需求(雖然是比較少見),使用方法如下:

感興趣的朋友可以看下源碼也很容易理解。

分佈式定時任務

最後再擴展一下,上文我們所提到的所有方案都是單機版的,只能在單個進程中使用。

一旦我們需要在分佈式場景下實現定時任務的高可用、可維護之類的需求就得需要一個完善的分佈式調度平臺的支持。

目前市面上流行的開源解決方案也不少:

我個人在工作中只使用過前面兩者,都能很好的解決分佈式調度的需求;比如高可用、統一管理、日誌報警等。

當然這些開源工具其實在定時調度這個功能上和上文中所提到的一些方案是分不開的,只是需要結合一些分佈式相關的知識;比遠程調用、統一協調、分佈式鎖、負載均衡之類的。

感興趣的朋友可以自行查看下他們的源碼或官方文檔。

總結

一個小小的定時器其實涉及到的知識點還不少,包括數據結構、多線程等,希望大家看完多少有些幫助,順便幫忙點贊轉發搞起🥳。

本文所涉及到的所有源碼:

https://github.com/crossoverJie/cim

你的點贊與分享是對我最大的支持

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章