发布时间:2025-02-23 09:01
自然界中定时任务无处不在,太阳每天东升西落,候鸟的迁徙,树木的年轮,人们每天按时上班,每个月按时发工资、交房租,四季轮换,潮涨潮落,等等,从某种意义上说,都可以认为是定时任务。
大概很少有人想过,这些“定时”是怎样做到的。当然,计算机领域的同学们可能对此比较熟悉,毕竟工作中的定时任务也是无处不在的:每天凌晨更新一波数据库,每天9点发一波邮件,每隔10秒钟抢一次火车票。。。
至于怎么实现的?很简单啊,操作系统的crontab,spring框架的quartz,实在不行Java自带的ScheduledThreadPool都可以很方便的做到定时任务的管理调度。
当你熟练的敲下
* * 9 * * ?
等着神奇的事情发生时,你是否想过背后的“玄机”?
大概去年的时候,业务需要实现一个时间调度的工具,定时生成报表,同组的哥们儿想了一个取巧的办法:
当时稍微想了⼀ScheduleThreadPool是怎么做到定时执⾏提交过来的任务的,⼤概有个模糊的概念,后来就把这事忘了。再后来,⼀次在地铁上看到⼀篇⽂章,讲了⼀种叫做时间轮的定时任务调度思想,感觉想法很不错,当年那个模糊的概念似乎清晰了很多,再后来,⼀个偶然的机会,⽹上搜了⼀下,竟然有⼀篇专门讲解时间轮算法的论⽂,顿时兴奋⽆⽐,赶紧打印下来,在上班的地铁上读了半个⽉,总算读完了。
戳这⾥下载:《Hashed and Hierarchical Timing Wheels》
论⽂中的思路很简单但也⼗分巧妙,对算法不断的改进对⽐,各种操作系统,框架中的基于时间的调度算法都是基于时间轮的思想实现的。下⾯我们来看看,这个神奇的时间轮到底是怎样实现定时任务的调度的。
定时任务⼀般有两种:
聪明的你会很快发现,这两者之间可以相互转换,⽐如给你个任务,要求12点执⾏,你看了⼀眼时间,发现现在是9点钟,那么你可以认为这个任务三个⼩时候执⾏。
同样的,给你个任务让你3个⼩时后执⾏,你看了⼀眼现在是9点钟,那么你当然可以认为这个任务12点钟执⾏。
我们先来考虑⼀个简单的情况,你接到三个任务A、B、C(都转换成绝对时间),分别需要再3点钟,4点钟和9点钟执⾏,正当百思不得其解时,不经意间你瞅了⼀眼墙上的钟表,瞬间来了灵感,如醍醐灌顶,茅塞顿开:
如上图中所⽰,我只需要把任务放到它需要被执⾏的时刻,然后等着时针转到这个时刻时,取出该时刻放置的任务,执⾏就可以了。 这就是时间轮算法最核⼼的思想了。 什么?时针怎么转? while-true-sleep 下⾯让我们⼀点⼀点增加复杂度。
多数定时任务是需要重复执⾏,⽐如每天上午9点执⾏⽣成报表的任务。对于重复执⾏的任务,其实我们需要关⼼的只是下次执⾏时间,并不关⼼这个任务需要循环多少次,还是那每天上午9点的这个任务来说。
由上⾯的过程我们可以看到,时间轮⾄少需要提供4个功能:
上⾯说的是同⼀个时刻只有⼀个任务需要执⾏的情况,更通⽤的情况显然是同⼀时刻可能需要执⾏多个任务,⽐如每天上午九点除了⽣成报表之外,还需要执⾏发送邮件的任务,需要执⾏创建⽂件的任务,还需执⾏数据分析的任务等等,于是你刚才可能就⽐较好奇的时间轮的数据结构到现在可能更加好奇了,那我们先来说说时间轮的数据结构吧。
⾸先,时钟可以⽤数组或者循环链表表⽰,这个每个时钟的刻度就是⼀个槽,槽⽤来存放该刻度需要执⾏的任务,如果有多个任务需要执⾏呢?每个槽⾥⾯放⼀个链表就可以了,就像下⾯图中这样:
同⼀时刻存在多个任务时,只要把该刻度对应的链表全部遍历⼀遍,执⾏(扔到线程池中异步执⾏)其中的任务即可。
如果任务不只限定在⼀天之内呢?⽐如我有个任务,需要每周⼀上午九点执⾏,我还有另⼀个任务,需要每周三的上午九点执⾏。⼀种很容易想到的解决办法是:
⼀天24个⼩时,⼀周168个⼩时,为了解决上⾯的问题,我可以把时间轮的刻度(槽)从12个增加到168个,⽐如现在是星期⼆上午10点钟,那么下周⼀上午九点就是时间轮的第9个刻度,这周三上午九点就是时间轮的第57个刻度,⽰意图如下:
仔细思考⼀下,会发现这中⽅式存在⼏个缺陷:
时间刻度太多会导致时间轮⾛到的多数刻度没有任务执⾏,⽐如⼀个⽉就2个任务,我得移动720次,其中718次是⽆⽤功。
时间刻度太多会导致存储空间变⼤,利⽤率变低,⽐如⼀个⽉就2个任务,我得需要⼤⼩是720的数组,如果我的执⾏时间的粒度精确到秒,那就更恐怖了。
于是乎,聪明的你脑袋⼀转,想到另⼀个办法:
这次我不增加时间轮的刻度了,刻度还是24个,现在有三个任务需要执⾏,
⽐如现在是9⽉11号星期⼆上午10点,时间轮转⼀圈是24⼩时,到任务⼀下次执⾏(下周⼆上午九点),需要时间轮转过6圈后,到第7圈的第9个刻度开始执⾏。
任务⼆下次执⾏第3圈的第9个刻度,任务三是第2圈的第9个刻度。
时间轮每移动到⼀个刻度时,遍历任务列表,把round值-1,然后取出所有round=0的任务执⾏,若是重复任务,执行完后将round恢复到最初值,再将任务重新放入时间轮中。
这样做能解决时间轮刻度范围过⼤造成的空间浪费,但是却带来了另⼀个问题:时间轮每次都需要遍历任务列表,耗时增加,当时间轮刻度粒度很⼩(秒级甚⾄毫秒级),任务列表⼜特别长时,这种遍历的办法是不可接受的。
当然,对于⼤多数场景,这种⽅法还是适⽤的。有没有既节省空间,⼜节省时间的办法呢? 答案是有的,正如《Hashed and Hierarchical Timing Wheels》标题中提到的,有⼀种分层时间轮,可以解决做到既节省空间,⼜节省时间:
分层时间轮是这样⼀种思想:
针对时间复杂度的问题:不做遍历计算round,凡是任务列表中的都应该是应该被执⾏的,直接全部取出来执⾏。
针对空间复杂度的问题:分层,每个时间粒度对应⼀个时间轮,多个时间轮之间进⾏级联协作。
第一点很好理解,第二点有必要举个例子来说明:
比如我有三个任务:
三个任务涉及到四个时间单位:⼩时、天、星期、⽉份。
拿任务三来说,任务三得到执⾏的前提是,时间刻度先得来到12号这⼀天,然后才需要关注其更细⼀级的时间单位:上午9点。
基于这个思想,我们可以设置三个时间轮:⽉轮、周轮、天轮。
初始添加任务时,任务⼀添加到天轮上,任务⼆添加到周轮上,任务三添加到⽉轮上。
三个时间轮以各⾃的时间刻度不停流转。
当周轮移动到刻度2(星期⼆)时,取出这个刻度下的任务,丢到天轮上,天轮接管该任务,到9点执⾏。
同理,当⽉轮移动到刻度12(12号)时,取出这个刻度下的任务,丢到天轮上,天轮接管该任务,到9点执⾏。
这样就可以做到既不浪费空间,又不浪费时间。
整体的⽰意图如下所⽰:
相比于round时间轮思想,采用分层时间轮算法的优点在于:只需要多耗费极少的空间(从1个时间轮到3个时间轮),就能实现多线程在效率上的提高(一个时间轮是一个线程去行走,3个时间轮可以3个线程行走)。当然这是相对的,若提交的任务都是每隔几个小时重复执行,那显然小时时间轮比月、周、小时时间轮组合的耗费空间少,且执行时间还相同。
时间轮的思想应⽤范围⾮常⼴泛,各种操作系统的定时任务调度,Crontab,还有基于java的通信框架Netty中也有时间轮的实现,⼏乎所有的时间任务调度系统采⽤的都是时间轮的思想。
⾄于采⽤round型的时间轮还是采⽤分层时间轮,看实际需要吧,时间复杂度和实现复杂度的取舍。
参考链接:
https://its301.com/article/qq_34039868/105384808
https://cloud.tencent.com/developer/article/1815722