思考
假如现在有个任务需要3s后执行,你会如何实现?
线程实现:让线程休眠3s
如果存在大量任务时,每个任务都需要一个单独的线程,那这个方案的消耗是极其巨大的,那么如何实现高效的调度呢?
时间轮算法就被提出来了
时间轮实现:下图是一个有12个时间格的时间轮,转完一圈需要12s。当我们需要新建一个3s后执行的定时任务,只需要将定时任务放在下标为3的时间格中即可。
当我们需要创建一个12s后执行的定时任务呢?(当前槽位为3)
添加刻度实现,按1秒为一个时间刻度,那么一天会有86400个刻度,当我继续添加一个任务,是86000秒后执行,那么其中大部分轮询都是空轮询,而且会浪费内存空间(每个刻度都有自己的任务队列)
这个时候可以引入“圈数/轮数”的概念,也就是说这个任务还是放在
下标为3的时间格中,不过它的圈数为1(圈数从0开始)。
当我们需要创建一个350s后自动完成的定时任务呢?
如果都用 round 来处理的话,那这个 round 将会变的非常大的一个数字,也会在任务列表中插入很多当前不需要执行的任务,如果每次都执行上面的逻辑,显然会浪费大量的资源。
时间轮
时间轮的数据结构
如上图所示,就是时间轮的一个基础结构,一个 存储定时任务的环形队列,底层采用数组实现,数组中的每个元素可以存放一个定时任务列表(TimerTaskList)。定时任务列表 是一个环形的双向链表,链表中的每一项表示的都是定时任务项(TimerTaskEntry),其中封装了真正的定时任务 TimerTask。
单层时间轮
现在我们不增加刻度,而是通过添加round属性来描述任务。假设我们时间大小设置为20s,我们添加三个任务:
- 10秒后执行的任务
- 30秒后执行的任务
- 50秒后执行的任务
那么这三个任务都在10刻度的任务队列中,分别为round=0, round=1, round=2。时间轮每移动一个刻度时,遍历任务队列取出round=0的任务执行,然后将其余的任务 round-1。
槽位计算公式:定时器放置的槽位 =(当前时间 + 定时器的延迟时间)% 槽位数量
(3+12)%12=3
10%20=10
30%20=10
50%20=10
任务执行轮次的计算公式: 定时器执行圈数 = ((任务执行时间 - 当前时间)/ 固定单位时间)% 槽位数量
((12-0)/12)%12 = 0
((10-0)/20)%20 = 0
((30-0)/20)%20 = 1
((50-0)/20)%20 = 2
基于round的时间轮虽然解决了浪费内存空间的问题,但当时间刻度小,任务队列长的时候会增加耗时。
多层时间轮
与基于round的时间轮不同,分层时间轮采用层级联动的方式,具有以下特点:
- 不做遍历计算round,每一个刻度中的任务都是应该执行的。
- 当任务执行时间超过当前刻度范围时,进入下一层时间轮的范围。
- 定时任务通过升级和降级来转移队列中的位置。
基本模型构成
- tickMs(基本时间跨度):时间轮由多个时间格组成,每个时间格代表当前时间轮的基本时间跨度(tickMs)。
- wheelSize(时间单位个数):时间轮的时间格个数是固定的,可用(wheelSize)来表示,那么整个时间轮的总体时间跨度(interval)可以通过公式 tickMs × wheelSize计算得出
上图的时间轮,设第 1 层的时间精度为 1s,第 2 层的时间精度为 20s,第 3 层的时间精度为 400s。假如我们需要添加一个 350s 后执行的任务 A 的话(当前时间是 0s),这个任务会被放在第 2 层(因为第二层的时间跨度为 20*20=400>350)的第 350/20=17 个时间格子。
当第一层转了 17 圈之后,时间过去了 340s ,第 2 层的指针此时来到第 17 个时间格子。此时,第 2 层第 17 个格子的任务会被降级到第 1 层。
任务 A 当前是 10s 之后执行,因此它会被移动到第 1 层的第 10 个时间格子。
时间轮的应用
时间轮的思想应用范围非常广泛,各种操作系统的定时任务调度,Crontab、Dubbo、新版的XXL-JOB、还有基于java的通信框架Netty中也有时间轮的实现,几乎所有的时间任务调度系统采用的都是时间轮的思想。