1. 程式人生 > 程式設計 >定時任務方案大百科

定時任務方案大百科

原文地址: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 便可實現取消任務的需求(雖然是比較少見),使用方法如下:

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

分散式定時任務

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

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

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

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

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

感興趣的朋友可以自行檢視下他們的原始碼或官方檔案。

總結

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

本文所涉及到的所有原始碼:

github.com/crossoverJi…

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