Netty原始碼學習9——從Timer到ScheduledThreadPoolExecutor到HashedWheelTimer

Cuzzz發表於2023-12-24

系列文章目錄和關於我

一丶前言

之前在學習netty原始碼的時候,經常看netty hash時間輪(HashedWheelTimer)的出現,時間輪作為一種定時排程機制,在jdk中還存在Timer和ScheduledThreadPoolExecutor。那麼為什麼netty要重複造輪子暱,HashedWheelTimer又是如何實現的,解決了什麼問題?

這一篇將從Timer-->ScheduledThreadPoolExecutor-->HashedWheelTimer 依次進行講解,學習其中優秀的設計。

二丶Timer

1.基本結構

Timer 始於java 1.3,原理和內部結構也相對簡單,

image-20231220124136990

如上圖所示,Timer內部存在一個執行緒(TimerThread例項)和一個陣列實現的堆

TimerThread執行時會不斷從陣列中拿deadline最早的任務,進行執行。為了更快的拿到dealline最早的任務,Timer使用陣列構建了一個堆,堆排序的依據便是任務的執行時間。

Timer中只存在一個執行緒TimerThread來執行定時任務,因此如果一個任務耗時太長會延後其他任務的執行

並且TimerThread不會catch任務執行產生的異常,也就是說如果一個任務執行失敗了,那麼TimerThread的執行會終止

2.原始碼

2.1 TimerThread 的執行

如下是TimerThread 執行的原始碼

  • 基於等待喚醒機制,避免無意義自旋
  • 每次都拿任務佇列中ddl最早的任務
  • 如果週期任務,會計算下一次執行時間,重新塞到任務佇列中
  • 巧妙的使用了 period 等於0,小於0,大於0進行非週期執行任務,fixed delay,fixed rate的區分
private void mainLoop() {
    while (true) {
        try {
            TimerTask task;
            boolean taskFired;
            // 對佇列上鎖,也就是提交任務和拿任務是序列的
            synchronized(queue) {
                // 如果Timer被關閉newTasksMayBeScheduled會為false
                // 這裡使用等待喚醒機制來阻塞TimerThread直到存在任務
                while (queue.isEmpty() && newTasksMayBeScheduled)
                    queue.wait();
                // 說明newTasksMayBeScheduled 為false 且沒任務,那麼TimerTask的死迴圈被break,
                if (queue.isEmpty())
                    break; 
                long currentTime, executionTime;
                task = queue.getMin();
                
                // 對任務上鎖,避免併發執行,TimerTask 使用state記錄任務狀態
                synchronized(task.lock) {
                    // 任務取消
                    if (task.state == TimerTask.CANCELLED) {
                        queue.removeMin();
                        continue; 
                    }
                   
                    currentTime = System.currentTimeMillis();
                    executionTime = task.nextExecutionTime;
                    // 需要執行
                    if (taskFired = (executionTime<=currentTime)) {
                        // task.period == 0 說明不是週期執行的任務
                        if (task.period == 0) { 
                            queue.removeMin();
                            task.state = TimerTask.EXECUTED;
                        } else { 
                            // task.period  小於0 那麼是fixed-delay ,
                            //  task.period 大於0 那麼是fixed-rate
                            // 如果是週期性的,會再次塞到任務佇列中
                            queue.rescheduleMin(
                              task.period<0 ? currentTime   - task.period
                                            : executionTime + task.period);
                        }
                    }
                }
                // 沒到執行的時間,那麼等待
                if (!taskFired) 
                    queue.wait(executionTime - currentTime);
            }
            // 到這裡會釋放鎖 ,因為任務的執行不需要鎖
            // 任務執行
            if (taskFired)  
                task.run();
        } catch(InterruptedException e) {
        }
    }
}

這段程式碼筆者認為有一點可以最佳化的,那就是在判斷任務是否需要執行,根據period計算執行時間的時候,會在持有任務佇列鎖的情況下,拿任務鎖執行——但是判斷任務是否需要執行,根據period計算執行時間 這段時間其實是可以釋放佇列鎖的!這樣併發的能力可以更強一點,可能Timer的定位也不是應用在高併發任務提交執行的場景,畢竟內部也只有一個執行緒,所以也無傷大雅。

2.2 任務的提交

任務的提交最終都呼叫到sched(TimerTask task, long time, long period)方法

image-20231220134307326

這裡比較有趣的是,加入到佇列後,會判斷當前任務是不是排程時間最早的任務,如果是那麼進行喚醒!這麼處理的原因可見下圖解釋:

image-20231220134852927

同樣我不太理解為什麼,Timer的作者要拿到佇列鎖,後拿任務鎖,去複製TimerTask的屬性,完全可以將TimerTask的修改放在佇列鎖的外面,如下

image-20231220135115770

2.3 佇列實現的堆

image-20231220135301671

可以看到新增任務需要進行fixUp,調整陣列中的元素,實現小根堆,這裡時間複雜度是logN

3.Timer的不足

  • 單執行緒:如果存在多個定時任務,那麼後面的定時任務會由於前面任務的執行而delay
  • 錯誤傳播:一個定時任務執行失敗,那麼會導致Timer的結束
  • 不友好的API:使用Timer執行延遲任務,需要程式設計師將任務保證為TimerTask,並且TimerTask無法獲取延遲任務結果

三丶ScheduledThreadPoolExecutor

java 1.5引入的juc工具包,其中ScheduledThreadPoolExecutor就提供了定時排程的能力

  • 其繼承了ThreadPoolExecutor,具備多執行緒併發執行任務的能力。
  • 更強的錯誤恢復:如果一個任務丟擲異常,並不會影響排程器本身和其他任務
  • 更友好的API:支援傳入Runnable,和Callable,排程執行緒將返回ScheduledFuture,我們可以透過ScheduledFuture來檢視任務執行狀態,以及獲取任務結果

由於ScheduledThreadPoolExecutor繼承了ThreadPoolExecutor,其中執行任務的執行緒執行邏輯同ThreadPoolExecutor(《JUC原始碼學習筆記5——1.5w字和你一起刨析執行緒池ThreadPoolExecutor原始碼,全網最細doge》

1.基本結構

image-20231224154954170

ScheduleThreadPoolExecutor內部結構和ThreadPoolExecutor類似,不同的是內部的阻塞佇列是DelayedWorkQueue——基於陣列實現的堆,依據延遲時間進行排序,堆頂,依據Condition等待喚醒機制實現的阻塞佇列;另外堆中的元素是ScheduledFuture

2.原始碼

2.1 ScheduledFutureTask的執行

public void run() {
    // 是否週期性,就是判斷period是否為0。
    boolean periodic = isPeriodic();
    // 檢查任務是否可以被執行。
    if (!canRunInCurrentRunState(periodic))
        cancel(false);
    // 如果非週期性任務直接呼叫run執行即可。
    else if (!periodic)
        ScheduledFutureTask.super.run();
    // 如果成功runAndRest,則設定下次執行時間並呼叫reExecutePeriodic。
        else if (ScheduledFutureTask.super.runAndReset()) {
        setNextRunTime();
        // 需要重新將任務放到工作佇列中
        reExecutePeriodic(outerTask);
    }
}

可以看到任務實現週期執行的關鍵在於任務執行完後會再次被放到延遲阻塞佇列中,ScheduledFutureTask的父類是FutureTask,其內部使用volatile修飾的狀態欄位來記錄任務執行狀態,使用cas避免任務重複執行(詳細可看《JUC原始碼學習筆記7——FutureTask原始碼解析》

2.2 DelayedWorkQueue

交給ScheduledThreadPoolExecutor執行的任務,都放在DelayedWorkQueue中,下面我們看看DelayedWorkQueue是如何接收任務,以及獲取任務的邏輯

2.2.1 offer接收任務
public boolean offer(Runnable x) {
    if (x == null)
        throw new NullPointerException();
    RunnableScheduledFuture<?> e = (RunnableScheduledFuture<?>)x;
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        int i = size;
        if (i >= queue.length)
            // 容量擴增50%。
            grow();
        size = i + 1;
        // 第一個元素
        if (i == 0) {
            queue[0] = e;
            setIndex(e, 0);
        } else {
            // 插入堆尾。
            siftUp(i, e);
        }
        // 如果新加入的元素成為了堆頂,則原先的leader就無效了。
        if (queue[0] == e) {
            leader = null;
            // 那麼進行喚醒,因為加入的任務延遲時間是最短的,可能之前佇列存在一個延遲時間更長的任務,導致有執行緒block了,這時候需要進行喚醒
            available.signal();
        }
    } finally {
        lock.unlock();
    }
    return true;
}

可以看到大致原理和Timer中的阻塞佇列類似,但是其中出現了leader(DelayedWorkQueue中的Thread型別屬性)目前我們還不直到此屬性的作用,需要我們結合take原始碼進行分析

2.2.2 take獲取任務
public RunnableScheduledFuture<?> take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    // 上鎖
    lock.lockInterruptibly();
    try {
        for (;;) {
            // 堆頂元素,也就是延遲最小的元素,馬上就要執行的任務
            RunnableScheduledFuture<?> first = queue[0];
            // 如果當前佇列無元素,則在available條件上無限等待直至有任務透過offer入隊並喚醒。
            if (first == null)
                available.await();
            else {
                // 延遲最小任務的延遲
                long delay = first.getDelay(NANOSECONDS);
                // 如果delay小於0說明任務該立刻執行了。
                if (delay <= 0)
                    // 從堆中移除元素並返回結果。
                    return finishPoll(first);

                first = null;
                // 如果目前有leader的話,當前執行緒作為follower在available條件上無限等待直至喚醒。
                if (leader != null)
                    available.await();
                else {
                    // 如果沒用leader 那麼當前執行緒設定為leader,
                    Thread thisThread = Thread.currentThread();
                    leader = thisThread;
                    try {
                        // 進行超時等待喚醒 ,等待直到可以執行,or存在其他需要更早的任務被add進行佇列
                        available.awaitNanos(delay);
                    } finally {
                        // 如果喚醒之後leader 還是自己那麼設定為null
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
    } finally {
       // leader為null ,佇列頭部存在任務,那麼喚醒一個執行緒來獲取任務
        if (leader == null && queue[0] != null)
            available.signal();
        // 如果leader 不為null,或者佇列沒用元素,那麼直接釋放鎖
        lock.unlock();
    }
}

整個原理看下來並不複雜,無非是以及Condition提供的等待喚醒機制實現任務的延遲的執行。

但是程式碼中存在leader相關的操作,這才是DelayedWorkQueue的精華,下面我們對這個leader機制進行學習

2.2.3 Leader-Follower 模式

DelayedWorkQueue中的leader是一個Thread型別的屬性,它指向了用於在佇列頭等待任務的執行緒。用於最小化不必要的定時等待

當一個執行緒成為leader執行緒時,它只等待下一個延遲過去,而其他執行緒則無限期地等待。在leader從take或poll返回之前,leader執行緒必須向其他執行緒發出訊號,除非其他執行緒在此期間成為引導執行緒。每當佇列的頭被一個過期時間較早的任務替換時,leader欄位就會透過重置為null而無效,並向一些等待執行緒(但不一定是當前的leader)發出訊號。

這麼說可能不是很好理解,我們結合程式碼進行分析,如下是take中的一段:

image-20231224172745736

  • 如果leader 不為null,讓前來拿任務的執行緒無限期等待

    • 為什麼要這麼做——減少無意義的鎖競爭,最早執行的任務已經分配給leader了,

      follower只需要等著即可

    • follower等什麼?——等leader拿到任務後進行喚醒,leader拿到任務,那麼接下來follower需要執行後續的任務了;或者堆中插入了另外一個延遲時間更小的任務

  • 如果leader為null,那麼當前執行緒成為leader

    • 這意味著堆頂延遲時間最短的任務交由當前執行緒執行,當前執行緒只需要等待堆頂任務延遲時間結束即可

    • leader什麼時候被喚醒:

      延遲時間到,或者堆中插入了另外一個延遲時間更小的任務

這裡就可以看出Leader-Follower是怎麼減少無意義的鎖競爭的,leader是身先士卒的將第一個任務攔在身上,讓自己的Follower可以進行永久的睡眠(超時等待),只有leader拿到任務準備執行了,才會喚醒自己的Follower——太溫柔了,我哭死。下面我們看看leader喚醒Follower的程式碼

image-20231224174738569

上面展示了leader任務到時間後的程式碼邏輯,可以看到leader任務到期後會設定leader為null(這象徵了leader的交接,leader去執行任務了,找一個follower做副手),然後如果堆中有任務,那麼喚醒一個follower,緊接著前leader就可以執行任務了

其實還存在另外一種case,那就是leader在awaitNanos的中途,存在另外一個更加緊急的任務被塞到堆中

image-20231224175409230

可以看到這裡的leader-follower模式,可以有效的減少鎖競爭,因為leader在拿到任務後會喚醒一個執行緒,從而讓follower可以await,而不是無意義的獲取DelayedWorkQueue的鎖,看有沒有任務需要執行!

  • 優點

    • 減少鎖競爭:透過減少同時嘗試獲取下一個到期任務的執行緒數量,降低了鎖競爭,從而提高了併發效能。
    • 節省資源:避免多個執行緒在相同的時間點上喚醒,減少了因競爭而造成的資源浪費。
    • 更好的響應性:由於 leader 執行緒是唯一等待到期任務的執行緒,因此它能夠快速響應任務的到期並執行它,而無需從多個等待執行緒中選擇一個來執行任務。
  • 缺點

    • 潛在的延遲:如果 leader 執行緒因為其他原因被阻塞或者執行緩慢,它可能會延遲其他任務的執行,因為沒有其他執行緒在等待那個特定的任務到期(比如leader倒黴的很久沒用獲得cpu時間片)。
    • 複雜性增加:實現 leader-follower 模式需要更多的邏輯來跟蹤和管理 leader 狀態,這增加了程式碼的複雜性。(程式碼初看,完全看球不同)
    • 故障點:leader 執行緒可能成為單點故障。如果 leader 執行緒異常退出或被中斷,必須有機制來確保另一個執行緒能夠取代它成為新的 leader。(這裡使用的finally關鍵字)

最後,在DelayQueue中也使用了leader-follower來進行效能最佳化

3.ScheduledThreadPoolExecutor優缺點

  • 優點

    • 任務排程: ScheduledThreadPoolExecutor 允許開發者排程一次性或重複執行的任務,這些任務可以基於固定的延遲或固定的頻率來執行。
    • 執行緒複用: 它維護了一個執行緒池,這樣執行緒就可以被複用來執行多個任務,避免了為每個任務建立新執行緒的開銷。
    • 併發控制: 執行緒池提供了一個限制併發執行緒數量的機制,這有助於控制資源使用,提高系統穩定性。
    • 效能最佳化: 使用內部 DelayedWorkQueue 來管理延遲任務,可以減少不必要的執行緒喚醒,從而提高效能。
    • 任務同步: ScheduledThreadPoolExecutor 提供了一種機制來獲取任務的結果或取消任務,透過返回的 ScheduledFuture物件可以控制任務的生命週期。
    • 異常處理: 它提供了鉤子方法(如 afterExecute),可以用來處理任務執行過程中未捕獲的異常。
  • 缺點

    • 資源限制: 如果任務執行時間過長或者任務提交速度超過執行緒池的處理能力,那麼執行緒池可能會飽和,導致效能下降或新任務被拒絕。

      DelayedWorkQueue是無界佇列,因此任務都會由核心執行緒執行,大量提交的時候沒用辦法進行執行緒的增加

    • 存在大量定時任務提交的時候,效能較低:基於陣列實現的堆,調整的時候需要logN的時間複雜度完成

四丶HashedWheelTimer 時間輪

1.引入

筆者學習HashedWheelTimer的時候,問chatgpt netty在哪裡使用了時間輪,chatgpt說在IdleStateHandler(當通道有一段時間未執行讀取、寫入時,觸發IdleStateEvent,也就是空閒檢測機制),但是其實在netty的IdleStateHandler並不是使用HashedWheelTimer實現的空閒檢測,依舊是類似ScheduledThreadPoolExecutor的機制(內部使用基於陣列實現的堆)

筆者就質問chagpt:"你放屁.jpg"

猛虎王之你放屁(萬惡之源)_嗶哩嗶哩_bilibili

chatgpt承認了錯誤,然後說它推薦這麼做,因為HashedWheelTimer在處理大量延遲任務的時候效能優於基於陣列實現的堆。

下面我們就來學習為什麼時間輪在處理大量延遲任務的時候效能優於基於陣列實現的堆。

2.時間輪演算法

時間輪演算法(Timewheel Algorithm)是一種用於管理定時任務的高效資料結構,它的核心思想是將時間劃分為一系列的槽(slots),每個槽代表時間輪上的一個基本時間單位。時間輪演算法的主要作用是最佳化計時器任務排程的效能,尤其是在處理大量短時任務時,相比於傳統的資料結構(如最小堆),它能顯著降低任務排程的複雜度。

如下是時間輪的簡單示意,可以看到多個任務使用雙向連結串列進行連線

image-20231224182834942

還存在多層次的時間輪(模擬時針分針秒針)對於週期性很長的定時任務,單層時間輪可能會導致槽的數量過多。為了解決這個問題,可以使用多層時間輪,即每個槽代表的時間跨度越來越大,較低層級代表短時間跨度,較高層級代表長時間跨度

image-20231224193446894

從這裡可以看出時間輪為什麼在存在大量延遲任務的時候效能比堆更好: 時間輪的插入操作通常是常數時間複雜度(O(1)),因為它透過計算定時任務的執行時間與當前時間的差值,將任務放入相應的槽中,這個操作與定時任務的總數無關。 在堆結構中,插入操作的時間複雜度是O(log N),其中N是堆中元素的數量。這是因為插入新元素後,需要透過上浮(或下沉)操作來維持堆的性質

3.HashedWheelTimer基本結構

Netty原始碼學習9——從Timer到ScheduledThreadPoolExecutor到HashedWheelTimer
  • 時間輪(Wheel):

    時間輪是一個固定大小的陣列,陣列中的每個元素都是一個槽(bucket)。
    每個槽對應一個時間間隔,這個間隔是時間輪的基本時間單位。
    所有的槽合起來構成了整個時間輪的範圍,例如,如果每個槽代表一個毫秒,那麼一個大小為1024的時間輪可以表示1024毫秒的時間範圍。

  • 槽(Bucket):每個槽是一個連結串列,用於儲存所有計劃在該槽時間到期的定時任務。

    任務透過計算它們的延遲時間來確定應該放入哪個槽中。

  • 指標(Cursor or Hand):

    時間輪中有一個指標,代表當前的時間標記。這個指標會週期性地移動到下一個槽,模擬時間的前進。每次指標移動都會檢查相應槽中的任務,執行到期的任務。

  • 任務(TimerTask):

    任務通常是實現了TimerTask介面的物件,其中包含了到期執行的邏輯。
    任務還包含了延遲時間和週期性資訊,這些資訊使得時間輪可以正確地排程每個任務

  • 工作執行緒(Worker Thread):

    HashedWheelTimer通常包含一個工作執行緒,它負責推進時間輪的指標,並處理到期的定時任務。

4.使用demo

public class HashedWheelTimerDemo {

    public static void main(String[] args) {
        // 建立HashedWheelTimer
        HashedWheelTimer timer = new HashedWheelTimer();
        
        // 提交一個延時任務,將在3秒後執行
        TimerTask task1 = new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                System.out.println("Task 1 executed after 3 seconds");
            }
        };
        timer.newTimeout(task1, 3, TimeUnit.SECONDS);
        
        // 提交一個週期性執行的任務,每5秒執行一次
        TimerTask task2 = new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                System.out.println("Task 2 executed periodically every 5 seconds");
                // 重新提交任務,實現週期性執行
                timer.newTimeout(this, 5, TimeUnit.SECONDS);
            }
        };
        timer.newTimeout(task2, 5, TimeUnit.SECONDS);
        
        // 注意:在實際應用中,不要忘記最終停止計時器,釋放資源
        // timer.stop();
    }
}

5.原始碼

5.1 建立時間輪

HashedWheelTimer構造方法引數有

  • threadFactory:負責new一個thread,這個thread負責推動時鐘指標旋轉。
  • taskExecutor:Executor負責任務到期後任務的執行
  • tickDuration 和 timeUnit 定義了一格的時間長度,預設的就是 100ms。
  • ticksPerWheel 定義了一圈有多少格,預設的就是 512;
  • leakDetection:用於追蹤記憶體洩漏。
  • maxPendingTimeouts:最大允許等待的 Timeout 例項數,也就是我們可以設定不允許太多的任務等待。如果未執行任務數達到閾值,那麼再次提交任務會丟擲RejectedExecutionException 異常。預設不限制。

構造方法主要的工作:

  • 建立HashedWheelBucket陣列

    每一個元素都是一個雙向連結串列,連結串列中的元素是HashedWheelTimeout

    image-20231224220309201

    預設情況下HashedWheelTimer中有512個這樣的元素

  • 建立workerThread,此Thread負責推動時鐘的旋轉,但是並沒用啟動該執行緒,當第一個提交任務的時候會進行workerThread執行緒的啟動

5.2 提交延時任務到HashedWheelTimer

  public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
     
		// 統計等待的任務數量
        long pendingTimeoutsCount = pendingTimeouts.incrementAndGet();
        // 大於閾值,丟擲異常
        if (maxPendingTimeouts > 0 && pendingTimeoutsCount > maxPendingTimeouts) {
            pendingTimeouts.decrementAndGet();
            throw new RejectedExecutionException("Number of pending timeouts ("
                + pendingTimeoutsCount + ") is greater than or equal to maximum allowed pending "
                + "timeouts (" + maxPendingTimeouts + ")");
        }
		
      // 啟動workerThread ,只啟動一次
        start();
		// 計算任務ddl
        long deadline = System.nanoTime() + unit.toNanos(delay) - startTime;
        
        // Guard against overflow.
        if (delay > 0 && deadline < 0) {
            deadline = Long.MAX_VALUE;
        }
      // new一個Timeout 加入到timeouts
      // timeouts 是PlatformDependent.newMpscQueue()————多生產,單消費者的阻塞佇列
        HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
        timeouts.add(timeout);
        return timeout;
    }

其中workerThread的啟動如下

image-20231224221859154

至此我們直到延時任務被加入到timeouts,timeouts是一個mpsc佇列,之所以使用mpsc,是因為可能存在多個生產者提交任務,但是消費任務的只有workerThread,mpsc在這種場景下效能更好。

那麼workerThread的工作邏輯是什麼暱

5.3 workerThread工作

image-20231224223310406

  • waitForNextTick類似於模擬時鐘上指標的走動,依賴Thread#sleep

  • 當到下一個刻度的時候,會先處理下取消的任務,其實就是對應bucket中刪除(雙向連結串列的刪除)

  • 然後將mpsc佇列中的任務都放到buckets中去

    image-20231224223908450

    這裡使用了mpsc主要是考慮如果沒加一個任務都直接放到時間輪,那麼鎖競爭太激烈了,可能會導致搶鎖阻塞了一段時間導致任務超時。有點訊息佇列削峰的意思。

  • 接下來就是找到當tick對應的bucket的,然後執行這個bucket中所有需要執行的任務

    image-20231224224348211

    可以看到其實就是遍歷雙向連結串列,找到需要執行任務,任務的執行呼叫expire方法,邏輯如下:image-20231224224438053

    直接交給執行緒池執行,之前之前還會嘗試修改狀態,這裡其實和使用者取消任務由競爭關係,也就是說如果任務提交到執行緒池,那麼取消也無濟於事了。

6.品一品優秀的設計

筆者認為這裡優秀的設計主要是在於MPSC的應用

  • 執行緒安全: HashedWheelTimer通常由一個工作執行緒來管理時間輪的推進和執行任務。如果允許多個執行緒直接在時間輪的桶(bucket)中新增任務,就必須處理併發修改的問題,這將大大增加複雜性和效能開銷。MPSC佇列允許多個生產者執行緒安全地新增任務,而消費者執行緒(也就是HashedWheelTimer的工作執行緒)則負責將這些任務從佇列中取出並放入正確的時間槽中。
  • 效能最佳化: 使用MPSC佇列可以減少鎖的競爭,從而提高效能。由於任務首先被放入佇列中,工作執行緒可以在合適的時間批次處理這些任務,這減少了對時間輪資料結構的頻繁鎖定和同步操作。

7.時間輪的優點和缺點

7.1優點

  • 高效的插入和過期檢查: 新增新任務到時間輪的操作是常數時間複雜度(O(1)),而檢查過期任務也是常數時間複雜度,因為只需要檢查當前槽位的任務列表。
  • 可配置的時間粒度: 時間輪的槽數量(時間粒度)是可配置的,可以根據應用程式的需要調整定時器的精度和資源消耗。
  • 處理大量定時任務: HashedWheelTimer尤其適合於需要處理大量定時任務的場景,例如網路應用中的超時監測。

7.2缺點

  • 有限的時間精度: 由於時間輪是以固定的時間間隔來劃分的,所以它的時間精度受到槽數量和槽間隔的限制,不能提供非常高精度的定時(如毫秒級以下)。這是小根堆優於時間輪的地方
  • 槽位溢位: 單個槽位可能會有多個任務同時過期,如果過期任務的數量非常大,可能會導致任務處理的延遲。這裡netty使用執行緒去執行任務,但是執行緒池可能存在沒用可用執行緒帶來的延遲
  • 系統負載敏感: 當系統負載較高時,定時器的準確性可能會降低,因為HashedWheelTimer的工作執行緒可能無法準確地按照預定的時間間隔推進時間輪。
  • 任務延遲執行: 如果任務在其預定的執行時間點新增到時間輪,可能會出現任務執行時間稍微延後的情況,因為會先塞到MPSC然後等下一個tick才被放到bucket然後才能被執行。

在選擇使用HashedWheelTimer時,需要根據應用場景的具體需求權衡這些優缺點。對於需要處理大量網路超時檢測的場景,HashedWheelTimer常常是一個合適的選擇。然而,如果應用程式需要高度精確的定時器,或者對任務執行的實時性有嚴格的要求,可能需要考慮ScheduledThreadPoolExecutor(Timer就是個垃圾doge)。

五丶思考

ScheduledThreadPoolExecutor和HashedWheelTimer 各有優劣,需要根據使用場景進行權衡

  • 關注任務排程的及時性:選擇ScheduledThreadPoolExecutor
  • 存在大量排程任務:選擇HashedWheelTimer

二者的特性又是由其底層資料結構決定

  • 為了維持小根堆的特性,每次向ScheduledThreadPoolExecutor中新增任務都需要進行調整,在存在大量任務的時候,這個調整的開銷maybe很大(都是記憶體操作,感覺應該還好)
  • 為了讓任務的新增時間複雜度是o(1),HashedWheelTimer 利用hash和陣列o(1)的定址能力,但是也是因為陣列的設計,導致任務的執行需要依賴workerThread每隔一個tick進行排程,喪失了一點任務執行的及時性

這一篇最大的收穫還是ScheduleThreadPoolExecutor中使用的leader-follower模式,以及HashedWheelTimer中mpsc 運用,二者都是在減少無意義的鎖競爭!

相關文章