时间轮算法解析

一、如何实现定时任务?

1.1、采用while+sleep的组合

这里最简单,就是每来一个任务,这边就定义一个线程,然后通过sleep来延迟执行任务,通过while循环来周期性的执行任务

在这里插入图片描述

上面那种方式存在问题就是每个任务我都需要创建一个对应的线程,这个时候如果系统中有大量的定时任务,那么在调度切换的时候性能消耗会非常大,而且线程数多也会占用系统资源,不适合大规模场景下的定时任务

1.2、最小堆实现

这个是采用最小堆的实现,从queue中获取排在最前面的过期的任务,然后判断是否到执行时间点,如果到达就立即执行,这里就只是用一个线程来管理所有的任务,减少了线程之前的切换开销提高执行效率

在这里插入图片描述

采用最小堆的实现有如下几个问题:

  1. 添加任务的效率变成了最小堆的写入效率O(Log(n))
  2. 只有一个调度线程,任务如果执行时间耗时较长就会阻塞其他任务的执行,例:A任务执行了1分钟,那么B任务至少要等待1分钟才能执行。

1.3、ScheduledThreadPoolExecutor实现

这个是采用ScheduledThreadPoolExecutor的实现,实现原理是每一个被调度的任务都会由线程池来管理执行,因此任务是并发执行的,相互之间不会受到干扰。需要注意的是,只有当任务的执行时间到来时,ScheduledThreadPoolExecutor 才会真正启动一个线程,其余时间 ScheduledThreadPoolExecutor 都是在轮询任务的状态。

在这里插入图片描述

1.3.1、ScheduledThreadPoolExecutor实现原理

  1. 首先是校验基本参数,然后把任务封装到ScheduledFutureTask线程中
  2. 调用delayedExecute进行延迟执行

在这里插入图片描述

这里super.getQueue()得到的是一个自定义的new DelayedWorkQueue()阻塞队列,数据存储方面也是一个最小堆结构的队列,这一点在初始化new ScheduledThreadPoolExecutor()

在这里插入图片描述在这里插入图片描述

DelayedWorkQueue其实是ScheduledThreadPoolExecutor中的一个静态内部类,在添加的时候,会将任务加入到RunnableScheduledFuture数组中,同时线程池中的Woker线程会通过调用任务队列中的take()方法获取对应的ScheduledFutureTask线程任务,接着执行对应的任务线程

在这里插入图片描述

我们知道Worker线程拿到的任务是ScheduledFutureTask任务线程类,最终执行任务的其实就是它
ScheduledFutureTask任务线程,才是真正执行任务的线程类,只是绕了一圈,做了很多包装,run()方法就是真正执行定时任务的方法。

在这里插入图片描述

这里Worker线程从DelayedWorkQueue中获取任务,然后拿到堆顶的元素,判断如果没到时间然后sleep对应的时间后执行。

在这里插入图片描述

这里使用ScheduledExecutorService已经可以帮我们解决了大部分需要使用定时任务的业务需求,但是和上面一样都是采用最小堆的结构,新任务加入效率是O(log(n)),取任务的效率是O(1),这里写入效率是可以提升的,采用时间轮算法,可以优化到O(1)的时间复杂度。

二、时间轮算法

2.1、普通单时间轮算法

时间轮可以以O(1)的时间复杂度添加和取出到期的延时任务,在执行效率上比优先级队列要高,他是通过一个数组来存储延时任务,当多个延时任务在同一个时刻执行时,组成一个双向链表(为了O(1)地添加新的延时任务)。再通过一个指针每tick时间循环地后移,每到达一个位置时,就执行该位置下地延时任务。

在这里插入图片描述

单时间轮存在的问题:

  1. 内存和资源的消耗巨大:假如我们设置每个刻度是1ms,那么如果我们要支持天级别的到期时间,那么我们就只能扩充bucket的范围来实现,例如将 bucket 设置成 2^32 个,但是这样会带来巨大的内存消耗,显然需要优化改进。
  2. 调度线程出现遍历效率低下的问题:当时间刻度增多,而任务数较少时,轮询线程的遍历效率会下降,例如,如果只有 50 个时间刻度上有任务,但却需要遍历 1440 个时间刻度。这违背了我们提出时间轮算法的初衷:解决遍历轮询线程遍历效率低的问题。
  3. 浪费内存空间问题:在时间刻度密集,任务数少的情况下,大部分时间刻度所占用的内存空间是没有任何意义的。如果要将时间精度设为秒,那么整个时间轮将需要 86400 个单位的时间刻度,此时时间轮算法的遍历线程将遇到更大的运行效率低的问题。

2.2、改进版单时间轮算法

在上面时间轮的时间刻度随着时间维度的增加而增加不是一个好的思路,我们可以额外增加一个remainingRounds,表示时间轮的轮数,当指针转到某个 bucket 时,不能像简单的单时间轮那样直接执行 bucket 下所有的定时器,而是要去遍历该 bucket 下的链表,判断判断时间轮转动的次数是否等于节点中的 round 值,只有当 expire 和 round 都相同的情况下,才能执行该任务。轮询线程的执行逻辑是每隔一秒处理一个时间刻度上任务队列中的所有任务,任务的 round 字段减 1,接着判断如果 round 字段的值变为 0,那么将任务移出任务队列,交给异步线程池来执行对应任务。如果是重复执行任务,那么再将任务添加到任务队列中。

在这里插入图片描述

改进版时间轮存在的问题:
运行效率不高:改进版的时间轮如果某个 bucket 上挂载的定时器特别多,那么需要花费大量的时间去遍历这些节点,如果 bucket 下的链表每个节点的 round 都不相同,那么一次遍历下来可能只有极少数的定时器需要立刻执行的,因此很难在时间和空间上都达到理想效果。时间轮每次处理一个时间刻度,就需要处理其上任务队列的所有任务。其运行效率甚至与基于普通任务队列实现的定时任务框架没有区别。

2.3、多级时间轮算法

为了解决单时轮和轮数时间轮引起的性能问题和资源问题的另一种方式是在层次结构中使用多个定时轮,由多个层级来进行多次 hash
进行任务数据的传递,从而减少对应的时间和空间的复杂程度。

多级时间轮 【年、月、日、小时、分钟、秒】级别的 6 个时间轮,每个时间轮分别有(10-年暂时定为 10
年)、12(月)、24(时)、60(分钟)、60(秒)个刻度。子轮转动一圈,父轮转动一格,从父向子前进,无子过期。分层时间轮如下图所示:

在这里插入图片描述

执行流程示例:

任务需要在当天的 17:30:20

  • 执行 任务添加于秒级别时钟轮的第 20 号 Bucket 上,当其轮询线程访问到第 20 号 Bucket 时,就将此任务转移到分钟级别时钟轮的第 30 号 Bucket 上。
  • 当分钟级别的时钟轮线程访问到第 30 号 Bucket,就将此任务转移到小时级别时钟轮的第 7 号 Bucket 上。
  • 当小时级别时钟轮线程访问到第 7 号 bucket 时。终会将任务交给异步线程负责执行,然后将任务再次注册到秒级别的时间轮中。

多级时间轮的优势:

  • 轮询线程效率变高:首先不再需要计算 round 值,其次任务队列中的任务一旦被遍历,就是需要被处理的(没有空轮询问题)。
  • 线程并发性好:虽然引入了并发线程,但是线程数仅仅和时钟轮的级数有关,并不随着任务数的增多而改变。
  • 如果任务按照分钟级别来定时执行,那么当分钟时间轮达到对应刻度时,就会将任务交给异步线程来处理,然后将任务再次注册到秒级别的时钟轮上。

三、时间轮算法源码解析

本次参考Netty的HashedWheelTimer

3.1、HashedWheelTimer使用案例

这里我们创建了HashedWheelTimer,并且往里面提交了一个2s后执行的任务TimerTask。

在这里插入图片描述

3.2、HashedWheelTimer源码解析

3.2.1、HashedWheelTimer构造方法解析

这里构造函数中初始化了一些非常重要的属性:

  1. 调用createWheel初始化时间轮
  2. duration:时钟多长时间拨动一次,值越小,时间轮精度越高,也就是时间轮的格子精度
    3.初始化Worker线程,这个是线程是用来推动时间轮的,移动刻度
  3. leak:内存泄漏检测
  4. maxPendingTimeouts:时间轮内最大等待的任务数

在这里插入图片描述

这里创建了一个时间轮,就是创建了HashedWheelBucket数组。

在这里插入图片描述

3.2.2、HashedWheelTimer#newTimeout添加任务

  1. 这里首先对等待任务数+1,然后判断是否大于maxPendingTimeouts,如果大于则报错丢弃掉该任务
  2. 调用start方法确保wheelTimer处于运行状态
  3. 计算延迟任务的执行时间,然后初始化HashedWheelTimeout对象,将任务添加到timeouts队列中

在这里插入图片描述

3.2.3、Worker#run

任务会先保存在队列中,当时间轮的时钟拨动时才会判断是否将队列中的任务加载进时间轮。我们之前将构造方法的时候讲道理了Worker线程负责推动时间轮,我们看下他的run方法逻辑如下:

  1. 在一个while循环中调用waitForNextTick等待下一个刻度的到来
  2. 如果已经到了下一个刻度,则定位到该刻度的槽位
  3. 处理掉已经过期的timeout
  4. 找到槽位对应的bucket
  5. 把之前放入到队列中的任务加入到时间轮中
  6. 处理当前bucket 对应的所有任务
  7. tick+1

在这里插入图片描述

3.2.3、HashedWheelTimer#waitForNextTick

  1. 这里就是计算时钟下次拨动的相对时间
  2. 如果时间不到就sleep等待一会。

在这里插入图片描述

3.2.4、HashedWheelTimer#processCancelledTasks

  1. 这里会从cancelledTimeouts中获取到已经取消的timeOut,然后从对应的bucket中移除掉

在这里插入图片描述
在这里插入图片描述

3.2.4、HashedWheelTimer#transferTimeoutsToBuckets

  1. 之前我们看到,任务港进来不会立即加载到时间轮中,而是暂时保存到一个队列中,这里会从这个队列中获取任务,然后计算出需要经过几次时钟拨动。
  2. 计算出在时间轮数组中的槽位,然后将任务添加到时间轮对应的槽位中

在这里插入图片描述

3.2.5、HashedWheelTimer#expireTimeouts

这里会把bucket的所有timeout取出来,判断如果任务就是在这次执行,则调用timeout#expire进行执行,否则给任务的remainingRounds减减,等待下次执行。

在这里插入图片描述

3.2.6、HashedWheelTimeout#expire

这里真正执行任务会提交到一个异步线程池中执行。

在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值