• Netty时间轮HashedWheelTimer原理分析


    HashedWheelTimer初始化

    1. public HashedWheelTimer(
    2. ThreadFactory threadFactory,
    3. long tickDuration, TimeUnit unit, int ticksPerWheel, boolean leakDetection,
    4. long maxPendingTimeouts) {
    5. checkNotNull(threadFactory, "threadFactory");
    6. checkNotNull(unit, "unit");
    7. checkPositive(tickDuration, "tickDuration");
    8. checkPositive(ticksPerWheel, "ticksPerWheel");
    9. // 将ticksPerWheel规格化为2的幂,并初始化时间轮
    10. wheel = createWheel(ticksPerWheel);
    11. mask = wheel.length - 1;
    12. // 把时钟拨动频率转成以纳秒为单位
    13. long duration = unit.toNanos(tickDuration);
    14. // Prevent overflow.
    15. if (duration >= Long.MAX_VALUE / wheel.length) {
    16. throw new IllegalArgumentException(String.format(
    17. "tickDuration: %d (expected: 0 < tickDuration in nanos < %d",
    18. tickDuration, Long.MAX_VALUE / wheel.length));
    19. }
    20. if (duration < MILLISECOND_NANOS) {
    21. logger.warn("Configured tickDuration {} smaller then {}, using 1ms.",
    22. tickDuration, MILLISECOND_NANOS);
    23. this.tickDuration = MILLISECOND_NANOS;
    24. } else {
    25. this.tickDuration = duration;
    26. }
    27. // 通过线程工厂创建时间轮线程
    28. workerThread = threadFactory.newThread(worker);
    29. leak = leakDetection || !workerThread.isDaemon() ? leakDetector.track(this) : null;
    30. this.maxPendingTimeouts = maxPendingTimeouts;
    31. // 条件成立:时间轮实例的数量超过64
    32. if (INSTANCE_COUNTER.incrementAndGet() > INSTANCE_COUNT_LIMIT &&
    33. WARNED_TOO_MANY_INSTANCES.compareAndSet(false, true)) {
    34. // 打印日志进行提醒
    35. reportTooManyInstances();
    36. }
    37. }

    在HashedWheelTimer的构造方法中,主要会做下面几点:

    • 进行时间轮数组的初始化,数组长度为时间轮大小为大于等于 ticksPerWheel 的第一个 2 的幂,和 HashMap的数组初始化类似
    • 通过线程工厂创建出时间轮线程
    • 检查HashedWheelTimer的实例数量是否超过了64,如果超过了则通过打印日志进行提醒

    往HashedWheelTimer中添加延时任务 

    1. public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
    2. checkNotNull(task, "task");
    3. checkNotNull(unit, "unit");
    4. long pendingTimeoutsCount = pendingTimeouts.incrementAndGet();
    5. // 条件成立:说明等待执行的延迟任务已经达到上限了
    6. if (maxPendingTimeouts > 0 && pendingTimeoutsCount > maxPendingTimeouts) {
    7. pendingTimeouts.decrementAndGet();
    8. throw new RejectedExecutionException("Number of pending timeouts ("
    9. + pendingTimeoutsCount + ") is greater than or equal to maximum allowed pending "
    10. + "timeouts (" + maxPendingTimeouts + ")");
    11. }
    12. start();
    13. // Add the timeout to the timeout queue which will be processed on the next tick.
    14. // During processing all the queued HashedWheelTimeouts will be added to the correct HashedWheelBucket.
    15. long deadline = System.nanoTime() + unit.toNanos(delay) - startTime;
    16. // Guard against overflow.
    17. if (delay > 0 && deadline < 0) {
    18. deadline = Long.MAX_VALUE;
    19. }
    20. HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
    21. timeouts.add(timeout);
    22. return timeout;
    23. }
    1. public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
    2. checkNotNull(task, "task");
    3. checkNotNull(unit, "unit");
    4. long pendingTimeoutsCount = pendingTimeouts.incrementAndGet();
    5. // 条件成立:说明等待执行的延迟任务已经达到上限了
    6. if (maxPendingTimeouts > 0 && pendingTimeoutsCount > maxPendingTimeouts) {
    7. pendingTimeouts.decrementAndGet();
    8. throw new RejectedExecutionException("Number of pending timeouts ("
    9. + pendingTimeoutsCount + ") is greater than or equal to maximum allowed pending "
    10. + "timeouts (" + maxPendingTimeouts + ")");
    11. }
    12. start();
    13. // Add the timeout to the timeout queue which will be processed on the next tick.
    14. // During processing all the queued HashedWheelTimeouts will be added to the correct HashedWheelBucket.
    15. long deadline = System.nanoTime() + unit.toNanos(delay) - startTime;
    16. // Guard against overflow.
    17. if (delay > 0 && deadline < 0) {
    18. deadline = Long.MAX_VALUE;
    19. }
    20. HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
    21. timeouts.add(timeout);
    22. return timeout;
    23. }

    可以看到通过newTimeout方法往HashedWheelTimer中添加延时任务的时候,会先去判断一下当前HashedWheelTimer实例中有多少延时任务在等待被执行调度,如果超过了指定的数量(该数量通过构造方法的maxPendingTimeouts参数指定),那么就会抛出异常

            接着会去执行start方法,在start方法中主要做两件事,一个是对HashedWheelTimer的状态进行初始化,另一个是等待startTime属性的初始化,如果调用newTimeout添加延时任务的时候时间轮线程还没启动,那么此时就会通过CountDownLatch.await进行阻塞,当时间轮线程启动完之后,startTime就会被赋与当前时间,并且再通过CountDownLatch.countdown去唤醒调用newTimeout的线程。这样做的目的就是为了在添加延时任务的时候能够获取到时间轮线程的启动时间,为什么一定要获取到这个时间呢?因为既然要实现延时功能,那么就肯定是需要有个相对时间来计算延时任务的调度时间,而时间轮会一直在跑,在跑的过程中再去判断每一个延时任务是否到达调度时间了,而这个判断就需要依赖时间轮线程跑的时候与延时任务用的是同一个相对时间。

            接着根据当前时间+延迟时间-startTime去得到该延迟任务相对于startTime的延迟时间,最后通过HashedWheelTimeout把延迟时间和TimerTask进行包装然后放到普通任务队列中

    时间轮线程运行过程

    在HashedWheelTimer中有一个Worker内部类,该内部类实现了Runnable接口,其实它就是时间轮线程执行的任务,下面是它的run方法:

    1. public void run() {
    2. // 初始化时间轮线程的启动时间
    3. startTime = System.nanoTime();
    4. if (startTime == 0) {
    5. // We use 0 as an indicator for the uninitialized value here, so make sure it's not 0 when initialized.
    6. startTime = 1;
    7. }
    8. // Notify the other threads waiting for the initialization at start().
    9. startTimeInitialized.countDown();
    10. do {
    11. // 获取到下一个时刻的起始时间
    12. final long deadline = waitForNextTick();
    13. if (deadline > 0) {
    14. // 计算出当前时刻的索引下标
    15. int idx = (int) (tick & mask);
    16. // 处理过期的任务
    17. processCancelledTasks();
    18. // 根据idx从时间轮数组中获取到对应的HashedWheelBucket
    19. HashedWheelBucket bucket =
    20. wheel[idx];
    21. // 把普通队列中的延时任务迁移到时间轮数组中
    22. transferTimeoutsToBuckets();
    23. // 执行对应的HashedWheelBucket中的HashedWheelTimeout链表
    24. bucket.expireTimeouts(deadline);
    25. // 时钟指针+1
    26. tick++;
    27. }
    28. }
    29. // 只要时间轮没有关闭,则这个while就一直循环下去
    30. while (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_STARTED);
    31. // Fill the unprocessedTimeouts so we can return them from stop() method.
    32. for (HashedWheelBucket bucket: wheel) {
    33. bucket.clearTimeouts(unprocessedTimeouts);
    34. }
    35. for (;;) {
    36. HashedWheelTimeout timeout = timeouts.poll();
    37. if (timeout == null) {
    38. break;
    39. }
    40. if (!timeout.isCancelled()) {
    41. unprocessedTimeouts.add(timeout);
    42. }
    43. }
    44. processCancelledTasks();
    45. }

    可以看到,在run方法中,一开始就会去给startTime进行赋值初始化,初始化完毕之后就调用CountDownLatch.countdown方法,这里就和上面newTimeout方法对应上了,接着关键的核心逻辑在do...while循环中,我们下面看下在这个循环中主要做了什么

    (1)时钟拨动 

    1. private long waitForNextTick() {
    2. // 计算出到达下一个时刻的相对时间
    3. long deadline = tickDuration * (tick + 1);
    4. for (;;) {
    5. // 计算出时间轮线程已经执行了多长时间
    6. final long currentTime = System.nanoTime() - startTime;
    7. // deadline - currentTime这里就表示当前离下一个时刻还差多少时间
    8. // +999999的目的是为了让任务不被提前执行,比如deadline - currentTime = 2000002,2000002/1000000 = 2ms
    9. // 2ms明显就是提前执行了,所以加上999999就可以计算出3ms(很明显netty的时间轮宁可任务延迟执行也不要任务提前执行)
    10. long sleepTimeMs = (deadline - currentTime + 999999) / 1000000;
    11. // 条件成立:说明此时已经到达了下一个时刻了
    12. if (sleepTimeMs <= 0) {
    13. if (currentTime == Long.MIN_VALUE) {
    14. return -Long.MAX_VALUE;
    15. } else {
    16. return currentTime;
    17. }
    18. }
    19. // 兼容 Windows 平台,因为 Windows 平台的调度最小单位为 10ms,如果不是 10ms 的倍数,可能会引起 sleep 时间不准确
    20. // See https://github.com/netty/netty/issues/356
    21. if (PlatformDependent.isWindows()) {
    22. sleepTimeMs = sleepTimeMs / 10 * 10;
    23. if (sleepTimeMs == 0) {
    24. sleepTimeMs = 1;
    25. }
    26. }
    27. try {
    28. // 睡眠一段时间,等待下一个时刻的到来
    29. Thread.sleep(sleepTimeMs);
    30. } catch (InterruptedException ignored) {
    31. if (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_SHUTDOWN) {
    32. return Long.MIN_VALUE;
    33. }
    34. }
    35. }
    36. }

    在该方法中会去根据时钟指针tick计算出时钟已经走了多长时间,然后再计算出时间轮线程已经运行的时间,两个时间相减就是当前离下一个时刻还剩多少时间了,此时让时间轮线程睡眠这段时间,当睡眠过后,如果指针走过的时间小于等于线程走过的时间,就表示这段时间就到达下一个时钟回拨点了,最后就返回当前时间轮线程的运行时间

    (2)迁移延时任务到时间轮数组

    1. private void transferTimeoutsToBuckets() {
    2. // 在每个时钟刻度中最多只迁移100000个延迟任务,以防止时间轮线程在迁移过程中耗费太多时间,从而导致添加进行的延时任务已经成为过时的任务
    3. for (int i = 0; i < 100000; i++) {
    4. HashedWheelTimeout timeout = timeouts.poll();
    5. if (timeout == null) {
    6. // 队列中没有延时任务了,跳出循环
    7. break;
    8. }
    9. // 跳过已取消的延时任务
    10. if (timeout.state() == HashedWheelTimeout.ST_CANCELLED) {
    11. // Was cancelled in the meantime.
    12. continue;
    13. }
    14. // 计算出该延时任务要走多少个时钟刻度才能执行
    15. long calculated = timeout.deadline / tickDuration;
    16. // 计算当前任务到执行还需要经过几圈时钟拨动
    17. timeout.remainingRounds = (calculated - tick) / wheel.length;
    18. final long ticks = Math.max(calculated, tick); // Ensure we don't schedule for past.
    19. int stopIndex = (int) (ticks & mask);
    20. HashedWheelBucket bucket = wheel[stopIndex];
    21. bucket.addTimeout(timeout);
    22. }
    23. }

    该方法会从普通任务队列中最多获取100000个延时任务,然后进行遍历

    long calculated = timeout.deadline / tickDuration;

    首先会去计算出延时任务需要走多少个时钟刻度才能被执行,也就是计算出calculated的大小

    timeout.remainingRounds = (calculated - tick) / wheel.length;

     计算出这个延时任务的执行时间与时间轮当前时刻差了多少圈

    1. final long ticks = Math.max(calculated, tick); // Ensure we don't schedule for past.
    2. int stopIndex = (int) (ticks & mask);

     最后得到这个延时任务在时间轮中对应的槽,但是这里需要把calculated与tick进行比较取出最大值,为什么要对calculated和tick进行比较呢?通常来说calculated大于或等于tick的,那什么时候calculated会比tick要小呢?答案就是此时从队列中获取到的是上一个时刻的延迟任务,因为在每一个时刻中只会从队列中获取100000个延迟任务,如果队列中的延时任务超过了100000,那么剩下的延时任务就只能在下一个时刻中被获取到了,此时在下一个时刻中计算出来的calculated就会比tick小了,那么要怎么处理这些延时任务呢?此时就可以把这些延时任务放到时间轮数组对应tick的槽中即可,那么当前时就可以去执行上一个时刻遗留下来的延时任务了

    (3)执行当前时刻的延时任务 

    1. public void expireTimeouts(long deadline) {
    2. HashedWheelTimeout timeout = head;
    3. // process all timeouts
    4. while (timeout != null) {
    5. HashedWheelTimeout next = timeout.next;
    6. // 条件成立:说明该延迟任务需要被执行了
    7. if (timeout.remainingRounds <= 0) {
    8. // 从链表中移除该延迟任务,并返回下一个延迟任务
    9. next = remove(timeout);
    10. // 执行该延迟任务
    11. if (timeout.deadline <= deadline) {
    12. timeout.expire();
    13. } else {
    14. // The timeout was placed into a wrong slot. This should never happen.
    15. throw new IllegalStateException(String.format(
    16. "timeout.deadline (%d) > deadline (%d)", timeout.deadline, deadline));
    17. }
    18. }
    19. // 条件成立:说明当前还未轮到该延迟任务执行,但是已经被取消了
    20. else if (timeout.isCancelled()) {
    21. next = remove(timeout);
    22. }
    23. // 条件成立:说明当前还未轮到该延迟任务执行
    24. else {
    25. // 时间轮圈数-1
    26. timeout.remainingRounds --;
    27. }
    28. timeout = next;
    29. }
    30. }

    通过前面的操作就可以获取到当前时刻的HashedWheelBucket对象,然后遍历里面的延迟任务链表,在遍历的时候会去判断这个延迟任务时候是当前这一轮的,如果不是的话就把圈数-1,反之就表示需要执行这个延时任务 

    (4)时钟指针+1,重复上面的过程 

    1. do {
    2. // 获取到下一个时刻的起始时间
    3. final long deadline = waitForNextTick();
    4. if (deadline > 0) {
    5. // 计算出当前时刻的索引下标
    6. int idx = (int) (tick & mask);
    7. // 处理已取消的任务
    8. processCancelledTasks();
    9. // 根据idx从时间轮数组中获取到对应的HashedWheelBucket
    10. HashedWheelBucket bucket = wheel[idx];
    11. // 把普通队列中的延时任务迁移到时间轮数组中
    12. transferTimeoutsToBuckets();
    13. // 执行对应的HashedWheelBucket中的HashedWheelTimeout链表
    14. bucket.expireTimeouts(deadline);
    15. // 时钟指针+1
    16. tick++;
    17. }
    18. }
    19. // 只要时间轮没有关闭,则这个while就一直循环下去
    20. while (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_STARTED);

    在上面的步骤走完之后,时钟指针+1,然后再判断时间轮的状态是否关闭的了,如果没有则重复上面的流程

    Netty时间轮的缺点

    Netty的时间轮是单线程的,所以如果在执行延时任务的过程中阻塞了,那么就会影响后面的延时任务的执行。同时它也不适合时间跨度范围很大的场景,比如往时间轮中扔很多的延时十几天时间的任务,这样这些延时任务在这期间都会以链表的形式存在于时间轮数组中,从而一直占用JVM内存

    Netty时间轮与xxl-job时间轮的比较 

    • Netty的时间轮是先等到这个时刻结束之后再去把这个时刻的任务捞出来进行调度,而xxl-job则相反,它是直接就把当前时刻的任务执行完了之后,再去等到下一个时刻的到来
    • xxl-job的时间轮并不支持任意延时时间的延时任务,最多只能支持延时60s之内的延时任务,因为它没有对时间轮圈数进行处理(内部是一个map,延时1s的任务和延时61s的任务会产生hash冲突,就会导致执行1s的任务的同时还会执行61s的任务),而Netty时间轮的设计则支持了这一点,对每一个任务通过增加一个remainingRounds属性去记录该任务的相当于当前时刻的时间轮圈数,从而在时间轮执行每一个槽的时候去进行判断是否是当前时刻的任务
    • xxl-job的时间轮如果在某一个时刻的任务调度时间超过了2s,比如说3s,那么就会丢失掉一个时刻的任务没有被调度到,也就是说xxl-job的时间轮最多只能让一个时刻的任务调度不超过2s,而Netty的时间轮则没有这个担忧,不管一个时刻内的延时任务被调度的时长超过了多久,它都能不丢失后面的每一个延时任务的调度(当然了,这样的话后面的延时任务的执行时间也就会被延后了)
    • 在xxl-job的时间轮中,是直接把延时任务放到时间轮容器中的,而Netty的时间轮则是在当前时刻结束之后,才去队列中把延时任务搬到时间轮容器中,这样进行比较的话,xxl-job的时间轮线程其实就比较纯粹了,因为它只干执行延时任务的事,而Netty的时间轮线程还要干对延时任务进行迁移到时间轮容器的事情

    Netty时间轮的最佳使用方式

    建议对Netty时间轮按模块去创建实例,因为每一个模块的业务不同,导致创建的时间轮参数则不同,特别是tickDuration这个参数,如果说某一个模块的延时任务平均的延时执行时间大概是30s,那么我们就可以设置tickDuration为30s,这样的话就不会让Netty的时间轮线程一直处于空转的状态(如果tickDuration设置为1s,则在30s内就空转30次)。比如我们可以以枚举的方式去创建Netty时间轮实例

    1. public enum HashedWheelTimerInstance {
    2. // 订单模块
    3. ORDER(30, TimeUnit.SECONDS, 64),
    4. // 用户模块
    5. USER(1, TimeUnit.MINUTES, 64);
    6. // 时间轮实例
    7. private final HashedWheelTimer wheelTimer;
    8. HashedWheelTimerInstance(long tickDuration, TimeUnit timeUnit, int ticksPerWheel) {
    9. wheelTimer = new HashedWheelTimer(r -> {
    10. Thread t = new Thread(r);
    11. t.setUncaughtExceptionHandler((t1, e) -> System.out.println(t1.getName() + e.getMessage()));
    12. t.setName("-HashedTimerWheelInstance-");
    13. return t;
    14. }, tickDuration, timeUnit, ticksPerWheel);
    15. }
    16. public HashedWheelTimer getWheelTimer() {
    17. return wheelTimer;
    18. }
    19. }

  • 相关阅读:
    Win10笔记本开热点后电脑断网的解决方法
    MyBatis 学习(一)之 MyBatis 概述
    LeetCode 面试题 01.09. 字符串轮转
    【Vue3】学习命名路由和嵌套路由
    NO9 蓝桥杯单片机之串口通信的使用
    LVS+Keepalived & 实验
    【problem】解决idea提示Method breakpoints may dramatically slow down debugging
    外汇天眼:法国金融市场管理局(AMF)致力于向零售投资者提供有关金融产品费用的信息
    CentOS7安装squid代理服务器
    求负反馈电路的电压增益
  • 原文地址:https://blog.csdn.net/weixin_37689658/article/details/132878027