前言
在很多应用系统中我们常常要定时执行一些任务。比如,营销系统需要定时生效活动、定时发短信、定时变更数据等等;在实际中我们改选用什么定时任务框架来实现业务,定时任务会遇到哪些坑,我们自己如何去最大化定时任务的性能。本文主要介绍单机和分布式两大类的解决方案,并且简要介绍两类方案中的常见的应用组件或者框架的应用场景和基本的实现原理,重点分析下单机的定时任务的实现原理和优缺点,分布式框架在后续文章分析。
概括
如图是文章主要介绍的解决方案,单机按照原理分为三类,基于线程while和sleep实现的、基于排序容器作为任务存储的实现方案,常见就是最小堆实现的单线程的Timer、线程池的ScheduledThreadPoolExecutor,另外就是效率更高的时间轮的实现方案,代表有RingBufferWheel;单机的任务只能在单机执行,而我们现在应用架构大多是分布式,分布式的定时任务框架主要是为了在分布式场景下,解决定时任务的高可用、高并发问题应运而生,初步类三种业界比较典型的简单介绍。
简单原理
分布式的定时任务框架也是脱胎单机的原理而来,这里先介绍单机的几种实现方案,并且简单的对比分析
1、线程while+sleep方案
要我们自己来实现一个定时任务,首先想到的就是一个线程里面while循环,sleep需要延迟的时间就像这样,我们实现一个每隔5s执行的一个任务
public static void main(String[] args) {
final long timeInterval = 5000;
Runnable runnable = new Runnable() {
@Override
public void run() {
while (true) {
System.out.println(Thread.currentThread().getName() + " 定时间隔5秒");
try {
Thread.sleep(timeInterval);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread thread = new Thread(runnable);
thread.start();
实现很简单,但是如果我还需要个3s的定时任务,怎么办?那就再写一个while+sleep,有多少个定时任务就写多少个?这样我们就发现了在大量的定时任务线程在调度切换时消耗会非常大,整体效率低,好,那我可以把所有定时任务存起来,排好序,按照顺序用中断的思想来调度,这样减少每个线程的轮换消耗吗?可以,不过不需要自己再写了,jdk自带的timer定时器已经干好了
2、Timer定时器
先看看Timer定时器这么用的
感谢图片提供:https://maimai.cn/article/detail?fid=1349482665&efid=5YQ1gtMNIJpXCeZdDb9uFw&use_rn=1
发现没有使用上跟我们方案1差不多,也是相当于起多个线程task,只不过共用了同一个调度器,timer,这就是他的关键,那它又是怎么调度的?怎么就比while+sleep效率高呢?
timer主要是将定时任务都排到数组构成的最小堆的数据结构中,根据触发时间早晚排好序,最近要触发的放到堆顶,到点调度器调度进行触发。
这里面主要是两个核心过程,一个是新加入任务要堆排序,一个是执行任务需要取堆顶
新加入任务时:
存储任务的是数组构成的堆
private final TaskQueue queue = new TaskQueue();
...
private TimerTask[] queue = new TimerTask[128];
新任务来时,把任务添加到数列末尾,size自增,并且触发fixUp方法进行堆排序,
接下来就来看看这个堆如何排序
fixUp原理就是新加入的元素在队尾会不断与已有顺序的数组元素不断比较,不断向前找到自己的位置,堆顶的元素就是最早执行的
任务执行时:
timer初始化时的另起线程会去取一个堆顶的任务(getMin),判断执行时间是否满足,满足就取出来执行,并且从堆中删除(removeMin如果是可重复任务,就重新设置下一次执行时间);如果没有队列空的,没有任务,那就await阻塞即可
具体源代码可看下图
这里有必要说下removeMin(从队列中删除当前任务),原理跟fixUp差不多,只不过是把堆尾的任务提到堆顶,然后再依次比较将任务往后移,直到到达合适的位置。(实际完成一次重排)
总结下这个利用最小堆存储定时任务,新增调度线程的方法,代价是:多个个调度线程和调度存储、调度机制,换来的收益是:少了方案1每个定时任务一个线程的多线程开销,提升执行效率;整体的新加任务写入效率:O(log(n)),取任务执行效率O(1)
这个方案也带来了一些问题
1、串行阻塞:调度线程只有一个,长任务会阻塞短任务的执行
2、容错能力差:没有异常处理能力,一旦一个任务执行故障,后续任务都无法执行
针对1的问题,我们可以采用线程池构造多线程调度来避免长任务阻塞影响短任务;
针对2的问题,可以每个调度任务一个线程,彼此互不影响
在线程池包中就有一类专门解决定时任务的线程池,ScheduledThreadPoolExecutor
3、ScheduledThreadPoolExecutor线程池方案
ScheduledThreadPoolExecutor本身就是一个线程池,
原理:
对应Timer中的TaskQueue,ScheduledThreadPoolExecutor用自定义延迟队列DelayedWorkQueue来替代,
新添加任务
也是跟timer差不错,添加一个定时任务,任务会加到DelayedWorkQueue中,这个队列本质也是个最小堆实现,新加任务提供offer方法,这里的compare方法实现堆排序的过程
任务执行
就跟线程池执行一样,把阻塞延迟队列按照执行时间排好序的队列取出来,判断执行条件,满足条件调度执行,并且从阻塞队列中剔除(finishpoll),并且对队列进行一次重排
总结下定时任务线程池
功能上:通过多线程解决了Timer的单线程的串行阻塞问题,异常阻塞问题,多线程定时任务互不影响,一个任务异常,其他任务照常调度。
效率上:也因为使用线程池比timer单线程效率更高,特别是在处理多定时任务时。
但是是不是就没问题了呢?我们发现线程池定时任务的排序容器跟timer一样,也是最小堆,他的新任务写入排序效率是O(log(n)),执行取任务是O(1),这里排序的写入排序效率是有空间可提升的,有可能优化到O(1)的时间复杂度。那就是第4中解决方案,时间轮方案
接下来就来看看时间轮怎么解决的
4、时间轮(RingBufferWheel)
核心思想就是:空间换时间,在最小堆实现是我们发现写入时、删除重排都需要依次比较,然后找到合适位置,因此这里排序消耗就是O(log(n)),我们能不能像HashMap一样,做到O(1)的put效率呢?而且这里还要实现排序。我们就来看看常见的数据结构
数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)
线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)
二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。
哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)
对,我们就用hash表,用数组做存储,然后通过hash寻址来,达到插入、查找都是O(1),当然填下没有免费的午餐,hash表就会面临hash冲突,hashMap是通过hash表+链表(红黑树)的结构来实现插入、查找的高效的近似O(1)。我们也当然可以这么干,用hashMap来做任务的存储,但是这个时间排序的转化工作量也不小,而且线程不安全。
这里可以用hash表的思想,将插入、取分开指针来做,并且还可以实现线程安全,这里就是ringBuffer。
ringbuffer实现文章较多:这里不做赘述
https://www.cnblogs.com/happyframework/p/3481697.html
https://blog.csdn.net/jkqwd1222/article/details/82194305
写入效率问题是解决了,但是也带来了高复杂度的问题,在运维保障时难度加大,ringbuffer的数量大小的维护,多线程下数据计数问题,数据持久化的问题,异常容错,这些定时任务业务之外的问题都需要考虑
总之,软件世界没有银弹,要结合自己所需来实现。
下一篇将来看看我们常用的分布式定时任务框架我们如何选用,如何实现的?
欢迎持续关注交流互联网技术,
微信:zbb_0813
邮箱:zhoubinbin0813@163.com