前言
在日常开发过程中,软件定时器(即Timer)是经常被使用到的基本组件。无论是简单的周期操作,比如每过1分钟发送一次心跳,还是复杂一点的比如分布式任务调度,它们的底层核心模块都是软件定时器。
定时器的功能简单清晰,大概包含如下几点:
- 指定时间或者延迟一段时间触发任务,这两个其实可以转化成一个需求。比如现在9点钟,指定在12点触发任务,就是从当前时间延时3小时触发任务。
- 支持单次和周期触发,常规实现中周期触发就是在上一次触发完成之后,再提交一个新的任务,所以本质上也是多个单次触发。
- 周期触发还要支持固定频率和固定延时,即平常说的FixRate和FixDelay。
需求看起来很简单,但是要把定时调度这件事实现的准时高效,不是一件简单的事情。下面我们先从主流的Java软件定时器实现看起,最后解析下信也当前使用的分布式调度框架的实现原理。
常用定时器实现
首先,考虑下如果要实现一个定时器的话,要解决哪些问题。准时肯定是第一要求,这就涉及到任务被提交过来以后,怎么尽快的知道这个任务到点该执行了,是用一个线程一直循环的检查吗?多久检查一次才合理呢?如果在检查的间隔用户新提交了一个任务需要立即执行,要怎么才能做到呢?
带着这两个问题就可以进入Java中最常使用的两个定时器java.util.Timer
和ScheduledThreadPoolExecutor
的源码中来一探究竟了。
Timer实现原理
Timer通过一个优先级队列和一个Event Loop线程来实现定时功能。用户提交的定时任务放在一个优先级队列中,执行时间最早的任务会排在队列的最前面。这样Event Loop线程每次只需要查看队列中第一个任务是否到了执行时间,如果没到则调用Object.wait(milliSeconds)
方法等待。原理如下图所示:
图中的PriorityQueue
按照任务的下次触发时间进行排序,所以Event Loop线程永远检查第一个任务是否到了执行时间就可以了。到了时间就取出来执行,如果没到则计算需要等待多长时间。比如现在是9点10分,第一个任务要9点15分执行,则调用Object.wait(5*60*1000)
等待5分钟。
当用户有一个新的任务提交到定时器执行时,首先将任务放入队列,优先级队列会自动重排序。之后唤醒Event Loop线程做一次检查。
EventLoop线程的逻辑如下:
核心代码实现:
private void mainLoop() {
/* 线程启动后一直循环 */
while (true) {
try {
TimerTask task;
boolean taskFired;
/* 获取Queue的锁 */
synchronized(queue) {
/* 如果Queue是空的,并且Timer还在被使用就无限期等待 */
while (queue.isEmpty() && newTasksMayBeScheduled)
queue.wait();
/* Timer已经不再使用并且没有任务待执行,则退出 */
if (queue.isEmpty())
break;
long currentTime, executionTime;
task = queue.getMin();
/* 获取任务锁,防止执行的时候任务已经被用户取消 */
synchronized(task.lock) {
/* 任务已经被取消则继续 */
if (task.state == TimerTask.CANCELLED) {
queue.removeMin();
continue;
}
currentTime = System.currentTimeMillis();
executionTime = task.nextExecutionTime;
...
...
/* 省略部分是判断任务是否周期执行,是的话
* 就计算下次执行时间,放一个新的任务到Queue
里面 */
}
if (!taskFired) // 等待执行
queue.wait(executionTime - currentTime);
}
/* 执行任务逻辑 */
if (taskFired)
task.run();
} catch(InterruptedException e) {
/* wait过程中出现中断,直接忽略 */
}
}
}
上面的代码中使用了两个互斥锁,给queue加锁是为了利用操作系统的wait和notify功能。给task加锁是为了防止对task的并发修改,因为任务在加入Timer之后用户随时可以取消。
存在的问题
Timer最大的问题是任务的执行都在同一个Event Loop线程中,如果某个任务执行时间过长,会影响到下一个。尤其是同一个时间点上有多个任务需要调度的时候,排队执行影响更大。
当然,为了并发执行可以初始化多个Timer,但是会造成线程数无法控制以及任务在线程中分布不均匀。所以,从Java 1.5开始,JDK也推荐使用ScheduledThreadPoolExecutor
来代替Timer实现定时器的功能。
ScheduledThreadPoolExecutor实现原理
Timer使用单线程任务调度和执行会导致延时问题,自然想到用线程池来解决问题,1.5之后java.util.concurrent
包的加入使这件事变得简单。ScheduledThreadPoolExecutor
继承自ThreadPoolExecutor
所以自带线程池,同时ThreadPoolExecutor
的任务本来就是用BlockingQueue
来存储的,所以要实现定时器的功能只需要将这个Queue替换成优先级队列就可以了。ScheduledThreadPoolExecutor
默认采用新实现的DelayedWorkQueue
,这个内部类采用堆来实现了一个优先级队列。整体的结构如下图所示:
在优先级队列的实现中,还做了如下的优化:
- 使用Java 1.5之后新增的ReentrantLock和Condition代替了synchronized互斥锁和Object.wait()/notify()。
- 由于引入了线程池,相对于Timer的实现,多了对队列头部任务的竞争。所以为此添加了Leader+Follower线程的逻辑,同一时间只有Leader线程等待头部任务的调度,其它线程进入无限期等待的状态。Leader线程拿到任务去执行时会让出Leader位置。
从队列中获取第一个的调度任务的逻辑如下:
代码实现:
public RunnableScheduledFuture<?> take() throws InterruptedException {
final ReentrantLock lock = this.lock;
/*1.获取锁,只有一个线程在队列中放入和取出任务*/
lock.lockInterruptibly();
try {
/*2. 无限循环等待直到获取到任务*/
for (;;) {
RunnableScheduledFuture<?> first = queue[0];
/*3. 队列为空则无限期等待,释放锁*/
if (first == null)
available.await();
else {
/*4. 获取第一个任务的执行时间*/
long delay = first.getDelay(NANOSECONDS);
/*5. 到达执行时间,返回任务给当前线程执行*/
if (delay <= 0)
return finishPoll(first);
first = null;
/*6. 如果不是Leader,则放弃执行第一个任务,无限期等待*/
if (leader != null)
available.await();
else {
/*7. 当前没有Leader,自己变成Leader线程,等待第一个任务到达执行时间 */
Thread thisThread = Thread.currentThread();
leader = thisThread;
try {
available.awaitNanos(delay);
} finally {
if (leader == thisThread)
leader = null;
}
}
}
}
} finally {
/* 拿到任务之后,如果当前没有Leader,则唤醒所有在第3或6步wait的线程*/
if (leader == null && queue[0] != null)
available.signal();
lock.unlock();
}
}
线程池从队列中获取header元素时,首先获取Lock防止对队列的并发操作。如果队列中的第一个任务已经到达执行时间,则执行并释放锁。如果未到达执行时间,则判断当前是否有别的线程已经是Leader线程,是的话则无限期等待,否的话当前线程变成Leader,并等待第一个任务到时间。
在等7步的wait中,线程有2种方式退出等待,一种是等待超时或者被中断,一种是新的任务被提交到队列中。
存在的问题
线程池的方案很好的解决了任务并发执行的问题,而且使线程数也对用户可控。但是,如下场景的问题仍然没有办法解决:
- Job持久化问题,
ScheduledThreadPoolExecutor
将任务保存在内存中,应用重启任务就会丢失。 - 如果提交的Job数过多,会占用大量的内存空间,而很多任务其实间隔很长时间才需要调度一次。
- 使用线程池方案很好的解决了任务在各个线程中均匀分布执行的问题,但是无法支持集群。
Quartz定时器原理
定时任务持久化和集群调度的需求催生了Quartz。Quartz支持任务的持久化(默认采用数据库)以及在分布式集群中调度。持久化操作相当于把ScheduledThreadPoolExecutor
的内存队列保存在数据库的一张表中,利用SQL的order by对任务按执行时间做排序。
调度原理
Quartz进程可以创建多个Scheduler,每个Scheduler持有一个QuartzSchedulerThread
线程,它负责从数据库中获取最近需要执行的任务(Quartz中一个触发设置称为一个Trigger),到了时间则放入线程池中执行。实现类似于Timer和ThreadPool的组合,Timer只负责触发,ThreadPool负责执行。
集群原理
Quartz服务集群各个节点间不存在相互通信,通过数据库来共享数据。任务在集群节点中的分配是通过获取数据库锁的方式来实现了,原理相当于把ScheduledThreadPoolExecutor
中的ReentrantLock
换成了数据库悲观锁,锁的超时控制也是通过数据库事务来实现的。同一时间只能有一个节点获取Trigger和更新Trigger状态。Quartz集群调度逻辑:
存在的问题
Quartz的调度算法本质上跟Timer
和ScheduledThreadPoolExecutor
没有什么不同,通过数据库来共享数据的方式既解决了任务的持久化又解决了集群调度的问题,而且降低了实现的复杂度。
但是当任务数量很大或者调度的频率很快的话,Quartz会面临数据库访问的瓶颈,数据库锁会导致所有集群节点串行访问数据库,这时候只是增加集群节点提升效果有限。如果让一个节点一次多拿几个任务,又会导致将未到时间的任务提前加载到本地执行。所以在面对类似于定时消息这种高密度、大数据量的定时任务场景,Quartz并不是一个很好的选择。
时间轮算法(Time Wheel)
时间轮算法由计算机科学家 George Varghese 等提出的一种软件定时器实现算法,它能够将时间和任务存储彻底分开, 从而提高调度效率。
算法原理
时间轮算法借鉴了现实生活中时钟的例子,首先设计一个表盘,表盘的刻度代表一个固定的时长,然后将任务挂载在每个刻度上,原理如下图所示:
上图中是一个1分钟转一圈的时间轮,每个刻度是1秒钟。current指针每秒变动一次,指向下一个刻度,这一刻度上要执行的任务通过链表方式挂在刻度上,实际上只需要将任务链的header关联到刻度上就可以了。
这种算法彻底将调度和任务存储分开,对于时钟轮来说它只要知道当前current的位置,然后保证按照固定的频率移动current。而对于任务存储,不需要知道任务何时调度,只需要能通过链表的头加载出整个链表就可以,结构简单非常有利于存储的优化。同时在新增任务时减少了排序的环节,可以使插入操作保持O(1)的时间复杂度。
下面通过实现一个简单的时间轮来更好的理解算法。
算法实现
时间轮的实现包含下面几个部分:
- 一个单独的线程用来转动时间轮,其实就是定时改变current指针指到下一个刻度。
- 任务列表的存储,保存任务列表的header对应到表盘的刻度上。
- 任务执行器,这里使用一个线程池来执行任务。
TimeWheel类定义
这个简单的时间轮定义为1分钟转一圈,最小调度粒度为1秒。属性如下:
- current,当前指针位置,初始化指向0。
- tick,负责每秒钟将指针往前跳一次的线程。
- slots, 60个槽的数组,每秒一个槽,用来保存要执行的任务。
- executor, 任务执行的线程池。
public class TimeWheel {
private volatile int current = 0;
private Thread tick;
private List<Job>[] slots;
private ThreadPoolExecutor executor;
}
时间轮类中,用户提交任务封装成一个Job存在slots中,Job类的定义如下:
public static class Job {
private int interval; //任务调度周期
private Runnable runnable; //任务业务逻辑
private volatile boolean running; //执行状态标志位
private int circle; //在时间轮的第几圈执行
}
circle属性用于在Job的调度周期大于1分钟时使用,比如一个任务是5分钟调度一次,circle的初始值为5,每转一圈减一,减到0的那一圈代表任务需要执行。
启动时间轮
启动时间轮其实就是让tick线程一直运行,每过1秒将任务提交执行,然后将current指针指到下一个槽位。
public void start() {
tick = new Thread(() -> {
for(;;) {
execute(); //执行当前时间点任务
Thread.sleep(1000); //等待一秒
if(current == 59) { //如果转完一圈
current = 0;
} else {
current = current + 1;
}
}
};
tick.start();
}
上面的Thread.sleep(1000)时间在实际项目中需要精确计算,并且需要处理线程异常中断的情况
任务执行
上面的tick线程在调用execute()时必须保证尽快返回,防止对定时器产生干扰。下面看下该方法的逻辑:
public void execute() {
List<Job> jobs = slots[current];//当前执行槽位的所有任务
for(Iterator<Job> iter = jobs.iterator(); iter.hasNext();) {
Job job = iter.next();
if(job.circle > 0) { //如果圈数大于0,圈数减1,直接跳过
job.circle--;
continue;
}
if(job.running == false) { //防止并发,上一周期还未执行完,则跳过
job.running = true;
executor.execute(() -> { //提交异步执行
job.runnable.run();
job.running = false;
});
}
iter.remove(); //将任务从该槽位移除
addJob(job.interval, job); //重新计算下次调度的槽位,使用调度周期作为delay时间
}
}
任务的提交过程无需额外加锁,无需再次取系统时间来对比是否到达执行时间,所以可以快速返回。
新增定时任务
从上面的执行逻辑可以看出,周期性任务下次调度和新增一个任务没有本质区别,要做的工作就是计算任务放置的槽位。
public void addJob(int delay, Job job) { int circle = delay / 60; //如果delay大于60s,计算圈数 job.circle = circle; int position = (current + delay) % 60; //根据当前指针位置计算放置的槽位 slots[position].add(job); //挂到指定槽位的链表末尾}
算法改进
- 应对大批量定时任务,比如消息队列中的定时消息
在大批量任务场景下,每个槽位的任务链表会很长,使用circle记录圈数的办法会造成太多不必要的计算,针对这种情况有两种主流的解决方案:
1)使用多层时间轮,当调度周期超过一分钟时,放入上层的1小时一圈的时间轮。
2)使用Delay队列,只有1分钟内的任务放入时间轮,超过1分钟的放入一个Delay队列中等待,Kafka的时间轮实现就采用了这种方式。 - 支持集群
从上面的Demo实现中可以得知,任务存储是一个非常简单的数据结构,当使用集群调度时,只需要将链表分成多段,保存在分库分表或者KV分布式数据库中,每个节点分配到其中的部分子列表,就可以很简单的获得无限水平扩展能力。
信也分布式任务调度
信也科技当前使用的分布式任务调度基于开源的xxl-job实现,整体架构分为Server端和Client端。Server端基于Quartz的集群调度实现,当任务到达执行时间后,服务端的Job Scheduler获取当前在线的客户端执行器,根据负载均衡算法下发至客户端执行。大体架构如下:
集群调度优化
由于开源的xxl-job实现Server端定时调度完全依赖于Quartz的集群调度,所以必然也面临前面提到的Quartz调度的短板。即在节点和任务数不断增加的情况下造成严重的数据库锁的争抢,影响集群扩容的效率。信也Job针对该问题对Quartz的任务集群分配做了如下优化:
- 将每次获取任务通过数据库锁的方式改为将任务分配到指定的节点上,只要该节点正常运行,任务就会一直在这个节点上调度,每次调度无需获取数据库锁。
- 任务分配采用一致性Hash算法,保证节点宕机到切换到Slave期间受影响的任务降到最少。
优化后的架构如下:
优化后的架构中,只有在master节点上下线触发重新选举时才会使用数据库锁,在日常的调度中任务只会属于master中的其中一个节点,开始调度前无需再获取锁。
总结
定时器是日常开发中经常用到的组件,通过对实现原理梳理可以发现每种定时器的优缺点,从而帮助我们具体场景下选择合适的调度器。Timer作为Java中最早的定时器实现虽然在简单场景下仍然可以使用,但是相对于ScheduledThreadPoolExecutor
已经没有任何优势,所以已经不再推荐。Quartz作为最流行的开源定时调度框架,在没有特殊要求的情况下,仍然是多调度器管理和集群分布式调度的很好的选择。在海量高并发任务调度场景下,时间轮算法有其独有的优势,Kafka和netty中都有时间轮算法的调度器实现,可以做很好的使用参考。
本文来自拍码场,经授权后发布,本文观点不代表信也智慧金融研究院立场,转载请联系原作者。