Netty時間輪調度算法原理分析,再不瞭解你就out啦

一、時間輪介紹

之前公司內部搭建的延遲隊列服務有用到時間輪,但是一直沒有了解過它的實現原理。

最近有個和支付寶對接的項目,支付寶接口有流量控制,一定的時間內只允許 N 次接口調用,針對一些業務我們需要頻繁調用支付寶開放平臺接口,如果不對請求做限制,很容易觸發流控告警。

爲了避免這個問題,我們按照一定延遲規則將任務加載進時間輪內,通過時間輪的調度來實現接口異步調用。

很多開源框架都實現了時間輪算法,這裏以 Netty 爲例,看下 Netty 中時間輪是怎麼實現的。

1.1 快速入門

下面是一個 API 使用例子。

public class WheelTimerSamples {

    private static final HashedWheelTimerInstance INSTANCE = HashedWheelTimerInstance.INSTANCE;

    public static void main(String[] args) throws IOException {

        INSTANCE.getWheelTimer().newTimeout(new PrintTimerTask(), 3, TimeUnit.SECONDS);
        System.in.read();
    }

    static class PrintTimerTask implements TimerTask {
        @Override
        public void run(Timeout timeout) {
            System.out.println("Hello world");
        }
    }

    enum HashedWheelTimerInstance {
        INSTANCE;
        private final HashedWheelTimer wheelTimer;

        HashedWheelTimerInstance() {
            wheelTimer = new HashedWheelTimer(r -> {
                Thread t = new Thread(r);
                t.setUncaughtExceptionHandler((t1, e) -> System.out.println(t1.getName() + e.getMessage()));
                t.setName("-HashedTimerWheelInstance-");
                return t;
            }, 100, TimeUnit.MILLISECONDS, 64);
        }

        public HashedWheelTimer getWheelTimer() {
            return wheelTimer;
        }
    }
}

上面的例子中我們自定義了一個 HashedWheelTimer ,然後自定義了一個 TimerTask ,將一個任務加載進時間輪,3s 後執行這個任務,怎麼樣是不是很簡單。

在定義時間輪時建議按照業務類型進行區分,將時間輪定義爲多個單例對象。

PS:因爲時間輪是異步執行的,在任務執行之前 JVM 不能退出,所以 System.in.read(); 這一行代碼不能刪除。

1.2 原理圖解

二、原理分析

2.1 時間輪狀態

時間輪有以下三種狀態:

  • WORKER_STATE_INIT:初始化狀態,此時時間輪內的工作線程還沒有開啓
  • WORKER_STATE_STARTED:運行狀態,時間輪內的工作線程已經開啓
  • WORKER_STATE_SHUTDOWN:終止狀態,時間輪停止工作

狀態轉換如下,轉換原理會在下面講到:

2.2 構造函數

public HashedWheelTimer(
            ThreadFactory threadFactory,
            long tickDuration, TimeUnit unit, int ticksPerWheel, boolean leakDetection,
            long maxPendingTimeouts) {

        if (threadFactory == null) {
            throw new NullPointerException("threadFactory");
        }
        if (unit == null) {
            throw new NullPointerException("unit");
        }
        if (tickDuration <= 0) {
            throw new IllegalArgumentException("tickDuration must be greater than 0: " + tickDuration);
        }
        if (ticksPerWheel <= 0) {
            throw new IllegalArgumentException("ticksPerWheel must be greater than 0: " + ticksPerWheel);
        }

        // 初始化時間輪數組,時間輪大小爲大於等於 ticksPerWheel 的第一個 2 的冪,和 HashMap 類似
        wheel = createWheel(ticksPerWheel);
        // 取模用,用來定位數組中的槽
        mask = wheel.length - 1;

        // 爲了保證精度,時間輪內的時間單位爲納秒
        long duration = unit.toNanos(tickDuration);

        // 時間輪內的時鐘撥動頻率不宜太大也不宜太小
        if (duration >= Long.MAX_VALUE / wheel.length) {
            throw new IllegalArgumentException(String.format(
                    "tickDuration: %d (expected: 0 < tickDuration in nanos < %d",
                    tickDuration, Long.MAX_VALUE / wheel.length));
        }

        if (duration < MILLISECOND_NANOS) {
            logger.warn("Configured tickDuration {} smaller then {}, using 1ms.",
                        tickDuration, MILLISECOND_NANOS);
            this.tickDuration = MILLISECOND_NANOS;
        } else {
            this.tickDuration = duration;
        }

        // 創建工作線程
        workerThread = threadFactory.newThread(worker);

        // 非守護線程且 leakDetection 爲 true 時檢測內存是否泄漏
        leak = leakDetection || !workerThread.isDaemon() ? leakDetector.track(this) : null;

        // 初始化最大等待任務數
        this.maxPendingTimeouts = maxPendingTimeouts;

        // 如果創建的時間輪實例大於 64,打印日誌,並且這個日誌只會打印一次
        if (INSTANCE_COUNTER.incrementAndGet() > INSTANCE_COUNT_LIMIT &&
            WARNED_TOO_MANY_INSTANCES.compareAndSet(false, true)) {
            reportTooManyInstances();
        }
    }

構造函數中的參數相當重要,當自定義時間輪時,我們應該根據業務的範圍設置合理的參數:

tickDuration

時間輪的時鐘撥動時長應該根據業務設置恰當的值,如果設置的過大,可能導致任務觸發時間不準確。如果設置的過小,時間輪轉動頻繁,任務少的情況下加載不到任務,屬於一直空轉的狀態,會佔用 CPU 線程資源。

爲了防止時間輪佔用過多的 CPU 資源,當創建的時間輪對象大於 64 時會以日誌的方式提示。

構造函數中只是初始化了輪線程,並沒有開啓,當第一次往時間輪內添加任務時,線程纔會開啓。

2.3 往時間輪內添加任務

@Override
    public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
        if (task == null) {
            throw new NullPointerException("task");
        }
        if (unit == null) {
            throw new NullPointerException("unit");
        }

        // 等待的任務數 +1
        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 + ")");
        }

        // 開啓時間輪內的線程
        start();

        // 計算當前添加任務的執行時間
        long deadline = System.nanoTime() + unit.toNanos(delay) - startTime;

        // Guard against overflow.
        if (delay > 0 && deadline < 0) {
            deadline = Long.MAX_VALUE;
        }
        // 將任務加入隊列
        HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
        timeouts.add(timeout);
        return timeout;
    }

任務會先保存在隊列中,當時間輪的時鐘撥動時纔會判斷是否將隊列中的任務加載進時間輪。

public void start() {
        switch (WORKER_STATE_UPDATER.get(this)) {
            case WORKER_STATE_INIT:
                // 這裏存在併發,通過 CAS 操作保證最終只有一個線程能開啓時間輪的工作線程
                if (WORKER_STATE_UPDATER.compareAndSet(this, WORKER_STATE_INIT, WORKER_STATE_STARTED)) {
                    workerThread.start();
                }
                break;
            case WORKER_STATE_STARTED:
                break;
            case WORKER_STATE_SHUTDOWN:
                throw new IllegalStateException("cannot be started once stopped");
            default:
                throw new Error("Invalid WorkerState");
        }

        while (startTime == 0) {
            try {
                // startTimeInitialized 是一個 CountDownLatch,目的是爲了保證工作線程的 startTime 屬性初始化
                startTimeInitialized.await();
            } catch (InterruptedException ignore) {
                // Ignore - it will be ready very soon.
            }
        }
    }

這裏通過 CAS 加鎖的方式保證線程安全,避免多次開啓。

工作線程開啓後, start() 方法會被阻塞,等工作線程的 startTime 屬性初始化完成後才被喚醒。爲什麼只有等 startTime 初始化後才能繼續執行呢?因爲上面的 newTimeout 方法在線程開啓後,需要計算當前添加進來任務的執行時間,而這個執行時間是根據 startTime 計算的。

2.4 時間輪調度

@Override
        public void run() {
            // 初始化 startTime.
            startTime = System.nanoTime();
            if (startTime == 0) {
                startTime = 1;
            }

            // 用來喚醒被阻塞的 HashedWheelTimer#start() 方法,保證 startTime 初始化
            startTimeInitialized.countDown();

            do {
                // 時鐘撥動
                final long deadline = waitForNextTick();
                if (deadline > 0) {
                    int idx = (int) (tick & mask);
                    // 處理過期的任務
                    processCancelledTasks();
                    HashedWheelBucket bucket =
                            wheel[idx];
                    // 將任務加載進時間輪
                    transferTimeoutsToBuckets();
                    // 執行當前時間輪槽內的任務
                    bucket.expireTimeouts(deadline);
                    tick++;
                }
            } while (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_STARTED);

            // 時間輪關閉,將還未執行的任務以列表的形式保存到 unprocessedTimeouts 集合中,在 stop 方法中返回出去
            // 還未執行的任務可能會在兩個地方,一:時間輪數組內,二:隊列中
            for (HashedWheelBucket bucket: wheel) {
                bucket.clearTimeouts(unprocessedTimeouts);
            }
            for (;;) {
                HashedWheelTimeout timeout = timeouts.poll();
                if (timeout == null) {
                    break;
                }
                if (!timeout.isCancelled()) {
                    unprocessedTimeouts.add(timeout);
                }
            }
            // 處理過期的任務
            processCancelledTasks();
        }

時間輪每撥動一次 tick 就會 +1,根據這個值與(時間輪數組長度 - 1)進行 & 運算,可以定位時間輪數組內的槽。因爲 tick 值一直在增加,所以時間輪數組看起來就像一個不斷循環的圓。

  • 先初始化 startTime 值,因爲後面任務執行的時間是根據 startTime 計算的
  • 時鐘撥動,如果時間未到,則 sleep 一會兒
  • 處理過期的任務
  • 將任務加載進時間輪
  • 執行當前時鐘對應時間輪內的任務
  • 時間輪關閉,將所有未執行的任務封裝到 unprocessedTimeouts 集合中,在 stop 方法中返回出去
  • 處理過期的任務

上面簡單羅列了下 run 方法的大概執行步驟,下面是具體方法的分析。

2.5 時鐘撥動

如果時間輪設置的 tickDuration 爲 100ms 撥動一次,當時鍾撥動一次後,應該計算下一次時鐘撥動的時間,如果還沒到就 sleep 一會兒,等到撥動時間再醒來。

private long waitForNextTick() {
            // 計算時鐘下次撥動的相對時間
            long deadline = tickDuration * (tick + 1);

            for (;;) {
                // 獲取當前時間的相對時間
                final long currentTime = System.nanoTime() - startTime;
                // 計算距離時鐘下次撥動的時間
                // 這裏之所以加 999999 後再除 10000000, 是爲了保證足夠的 sleep 時間
                // 例如:當 deadline - currentTime = 2000002 的時候,如果不加 999999,則只睡了 2ms
                // 而 2ms 其實是未到達 deadline 時間點的,所以爲了使上述情況能 sleep 足夠的時間,加上 999999 後,會多睡 1ms
                long sleepTimeMs = (deadline - currentTime + 999999) / 1000000;

                // <=0 說明可以撥動時鐘了
                if (sleepTimeMs <= 0) {
                    if (currentTime == Long.MIN_VALUE) {
                        return -Long.MAX_VALUE;
                    } else {
                        return currentTime;
                    }
                }

                // 這裏是爲了兼容 Windows 平臺,因爲 Windows 平臺的調度最小單位爲 10ms,如果不是 10ms 的倍數,可能會引起 sleep 時間不準確
                // See https://github.com/Netty/Netty/issues/356
                if (PlatformDependent.isWindows()) {
                    sleepTimeMs = sleepTimeMs / 10 * 10;
                }

                try {
                    // sleep 到下次時鐘撥動
                    Thread.sleep(sleepTimeMs);
                } catch (InterruptedException ignored) {
                    if (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_SHUTDOWN) {
                        return Long.MIN_VALUE;
                    }
                }
            }
        }

如果時間不到就 sleep 等待一會兒,爲了使任務時鐘準確,可以從上面的代碼中看出 Netty 做了一些優化,比如 sleepTimeMs 的計算,Windows 平臺的處理等。

2.6 將任務從隊列加載進時間輪

private void transferTimeoutsToBuckets() {

            // 一次最多隻處理隊列中的 100000 個任務
            for (int i = 0; i < 100000; i++) {
                HashedWheelTimeout timeout = timeouts.poll();
                if (timeout == null) {
                    // all processed
                    break;
                }
                // 過濾已經取消的任務
                if (timeout.state() == HashedWheelTimeout.ST_CANCELLED) {
                    continue;
                }
                // 計算當前任務到執行還需要經過幾次時鐘撥動
                // 假設時間輪數組大小是 10,calculated 爲 12,需要時間輪轉動一圈加兩次時鐘撥動後後才能執行這個任務,因此還需要計算一下圈數
                long calculated = timeout.deadline / tickDuration;
                // 計算當前任務到執行還需要經過幾圈時鐘撥動
                timeout.remainingRounds = (calculated - tick) / wheel.length;
                // 有的任務可能在隊列裏很長時間,時間過期了也沒有被調度,將這種情況的任務放在當前輪次內執行
                final long ticks = Math.max(calculated, tick); // Ensure we don't schedule for past.
                // 計算任務在時間輪數組中的槽
                int stopIndex = (int) (ticks & mask);
                HashedWheelBucket bucket = wheel[stopIndex];
                // 將任務放到時間輪的數組中,多個任務可能定位時間輪的同一個槽,這些任務通過以鏈表的形式鏈接
                bucket.addTimeout(timeout);
            }
        }

        void addTimeout(HashedWheelTimeout timeout) {
            assert timeout.bucket == null;
            // 任務構成雙向鏈表
            timeout.bucket = this;
            if (head == null) {
                head = tail = timeout;
            } else {
                tail.next = timeout;
                timeout.prev = tail;
                tail = timeout;
            }
        }        

在上面也提到過,任務剛加進來不會立即到時間輪中去,而是暫時保存到一個隊列中,當時間輪時鐘撥動時,會將任務從隊列中加載進時間輪內。

時間輪每次最大處理 100000 個任務,因爲任務的執行時間是用戶自定義的,所以需要計算任務到執行需要經過多少次時鐘撥動,並計算時間輪撥動的圈數。接着將任務加載進時間輪對應的槽內,可能有多個任務經過 hash 計算後定位到同一個槽,這些任務會以雙向鏈表的結構保存,有點類似 HashMap 處理碰撞的情況。

2.7 執行任務

public void expireTimeouts(long deadline) {
            HashedWheelTimeout timeout = head;

            while (timeout != null) {
                HashedWheelTimeout next = timeout.next;
                // 任務執行的圈數 > 0,表示任務還需要經過 remainingRounds 圈時鐘循環才能執行
                if (timeout.remainingRounds <= 0) {
                    // 從鏈表中移除當前任務,並返回鏈表中下一個任務
                    next = remove(timeout);
                    if (timeout.deadline <= deadline) {
                        // 執行任務
                        timeout.expire();
                    } else {
                        // The timeout was placed into a wrong slot. This should never happen.
                        throw new IllegalStateException(String.format(
                                "timeout.deadline (%d) > deadline (%d)", timeout.deadline, deadline));
                    }
                } else if (timeout.isCancelled()) {
                    // 過濾取消的任務
                    next = remove(timeout);
                } else {
                    // 圈數 -1
                    timeout.remainingRounds --;
                }
                timeout = next;
            }
        }

        public void expire() {
            // 任務狀態校驗
            if (!compareAndSetState(ST_INIT, ST_EXPIRED)) {
                return;
            }

            try {
                task.run(this);
            } catch (Throwable t) {
                if (logger.isWarnEnabled()) {
                    logger.warn("An exception was thrown by " + TimerTask.class.getSimpleName() + '.', t);
                }
            }
        }

時間輪槽內的任務以鏈表形式存儲,這些任務執行的時間可能會不一樣,有的在當前時鐘執行,有的在下一圈或者下兩圈對應的時鐘執行。當任務在當前時鐘執行時,需要將這個任務從鏈表中刪除,重新維護鏈表關係。

2.8 終止時間輪

@Override
    public Set<Timeout> stop() {
        // 終止時間輪的線程不能是時間輪的工作線程
        if (Thread.currentThread() == workerThread) {
            throw new IllegalStateException(
                    HashedWheelTimer.class.getSimpleName() +
                            ".stop() cannot be called from " +
                            TimerTask.class.getSimpleName());
        }
        // 將時間輪的狀態修改爲 WORKER_STATE_SHUTDOWN,這裏有兩種情況
        // 一:時間輪是 WORKER_STATE_INIT 狀態,表明時間輪從創建到終止一直沒有任務進來
        // 二:時間輪是 WORKER_STATE_STARTED 狀態,多個線程嘗試終止時間輪,只有一個操作成功
        if (!WORKER_STATE_UPDATER.compareAndSet(this, WORKER_STATE_STARTED, WORKER_STATE_SHUTDOWN)) {
            // 代碼走到這裏,時間輪只能是兩種狀態中的一個,WORKER_STATE_INIT 和 WORKER_STATE_SHUTDOWN
            // 爲 WORKER_STATE_INIT 表示時間輪沒有任務,因此不用返回未處理的任務,但是需要將時間輪實例 -1
            // 爲 WORKER_STATE_SHUTDOWN 表示是 CAS 操作失敗,什麼都不用做,因爲 CAS 成功的線程會處理
            if (WORKER_STATE_UPDATER.getAndSet(this, WORKER_STATE_SHUTDOWN) != WORKER_STATE_SHUTDOWN) {
                // 時間輪實例對象 -1
                INSTANCE_COUNTER.decrementAndGet();
                if (leak != null) {
                    boolean closed = leak.close(this);
                    assert closed;
                }
            }
            // CAS 操作失敗,或者時間輪沒有處理過任務,返回空的任務列表
            return Collections.emptySet();
        }

        try {
            boolean interrupted = false;
            while (workerThread.isAlive()) {
                // 中斷時間輪工作線程
                workerThread.interrupt();
                try {
                    // 終止時間輪的線程等待時間輪工作線程 100ms,這個過程主要是爲了時間輪工作線程處理未執行的任務
                    workerThread.join(100);
                } catch (InterruptedException ignored) {
                    interrupted = true;
                }
            }

            if (interrupted) {
                Thread.currentThread().interrupt();
            }
        } finally {
            INSTANCE_COUNTER.decrementAndGet();
            if (leak != null) {
                boolean closed = leak.close(this);
                assert closed;
            }
        }
        // 返回未處理的任務
        return worker.unprocessedTimeouts();
    }

當終止時間輪時,時間輪狀態有兩種情況:

WORKER_STATE_INIT
WORKER_STATE_STARTED

時間輪停止運行後會將未執行的任務返回出去,至於怎麼處理這些任務,由業務方自己定義,這個流程和線程池的 shutdownNow 方法是類似的。

如果時間輪在運行,怎麼才能獲取到未執行的任務呢,答案就在上面的 run() 方法中,如果時間輪處於非運行狀態,會把時間輪數組與隊列中未執行且未取消的任務保存到 unprocessedTimeouts 集合中。而終止時間輪成功的線程只需要等待一會兒即可,這個等待是通過 workerThread.join(100); 實現的。

取消時間輪內的任務相對比較簡單,這裏就不概述了,想要了解的自行查看即可。

上面就是時間輪運行的基本原理了。

三、總結

這裏以問答的形式進行總結,大家也可以看下這些問題,自己能不能很好的回答出來?

3.1 時間輪是不是在初始化完成後就啓動了?

不是,初始化完成時間輪的狀態是 WORKER_STATE_INIT ,此時時間輪內的工作線程還沒有運行,只有第一次往時間輪內添加任務時,纔會開啓時間輪內的工作線程。時間輪線程開啓後會初始化 startTime ,任務的執行時間會根據這個字段計算,而且時間輪中時間的概念是相對的。

3.2 如果時間輪內還有任務未執行,服務重啓了怎麼辦?

時間輪內的任務都在內存中,服務重啓數據肯定都丟了,所以當服務重啓時需要業務方自己做兼容處理。

3.3 如何自定義合適的時間輪參數?

自定義時間輪時有兩個比較重要的參數需要我們注意:

  • tickDuration:時鐘撥動頻率,假設一個任務在 10s 後執行, tickDuration 設置爲 3min 那肯定是不行的, tickDuration 值越小,任務觸發的精度越高,但是沒有任務時,工作線程會一直自旋嘗試從隊列中拿任務,比較消耗 CPU 資源
  • ticksPerWheel:時間輪數組大小,假設當時間輪時鐘撥動時,有 10000 個任務處理,但是我們定義時間輪數組的大小爲 8,這時平均一個時間輪槽內有 1250 個任務,如果這 1250 個任務都在當前時鐘執行,任務執行是同步的,由於每個任務執行都會消耗時間,可能會導致後面的任務觸發時間不準確。反之如果數組長度設置的過大,任務比較少的情況下,時間輪數組很多槽都是空的

所以當使用自定義時間輪時,一定要評估自己的業務後再設置參數。

3.4 Netty 的時間輪有什麼缺陷?

Netty 中的時間輪是通過單線程實現的,如果在執行任務的過程中出現阻塞,會影響後面任務執行。除此之外,Netty 中的時間輪並不適合創建延遲時間跨度很大的任務,比如往時間輪內丟成百上千個任務並設置 10 天后執行,這樣可能會導致鏈表過長 round 值很大,而且這些任務在執行之前會一直佔用內存。

3.5 時間輪要設置成單例的嗎?

強烈建議按照業務模塊區分,每個模塊都創建一個單例的時間輪對象。在上面的代碼中我們看到了,當時間輪對象大於 64 時會以日誌的形式提示。如果時間輪是非單例對象,那時間輪算法完全就失去了作用。

3.6 時間輪與 ScheduledExecutorService 的區別?

ScheduledExecutorService 中的任務維護了一個堆,當有大量任務時,需要調整堆結構導致性能下降,而時間輪通過時鐘調度,可以不受任務量的限制。

當任務量比較少時時間輪會一直自旋空轉撥動時鐘,相比 ScheduledExecutorService 會佔用一定 CPU 資源。

來源:https://www.tuicool.com/articles/ui2mmaF

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