前言
節前有更新一篇定時任務的相關文章《延時消息之時間輪》,有朋友提出希望可以完整的介紹下常見的定時任務方案,于是便有了這篇文章。
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
隊列的地方則是在寫入任務的時候。
本質上是調用 ThreadPoolExecutor
的 addWorker()
來寫入任務的,所以消費 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
你的點贊與分享是對我最大的支持