Bootstrap

Quartz是如何到期触发定时任务的

Quartz中核心的调度类是QuartzScheduler,任务的调度和任务的管理都是QuartzScheduler实现的,然后通过一个静态代理类StdScheduler提供出来。所以要理解定时任务是如何触发的,我们只需要理解透QuartzScheduler即可。

QuartzScheduler类

public class QuartzScheduler implements RemotableQuartzScheduler {
   
    private QuartzSchedulerResources resources;

    private QuartzSchedulerThread schedThread;

    private SchedulerSignaler signaler;

    private boolean signalOnSchedulingChange = true;

    private volatile boolean closed = false;
    private volatile boolean shuttingDown = false;
    
    private Date initialStart = null;
    
    /** Update timer that must be cancelled upon shutdown. */
    private final Timer updateTimer;
    ......

上面是QuartzScheduler比较重要的几个成员,从字面上都很好理解,我们重点关注QuartzSchedulerThread这个成员,它就是负责处理触发定时任务的线程类!

QuartzSchedulerThread

public class QuartzSchedulerThread extends Thread {
   
    private QuartzScheduler qs;

    private QuartzSchedulerResources qsRsrcs;

    private final Object sigLock = new Object(); // 信号锁,任何对scheduler的操作都要上锁

    private boolean signaled; // 是否已通知
    private long signaledNextFireTime; // 定时任务下次触发时间

    private boolean paused;// 是否进入standby模式

    private AtomicBoolean halted;// 是否停止

    private Random random = new Random(System.currentTimeMillis());

    // When the scheduler finds there is no current trigger to fire, how long
    // it should wait until checking again...
    private static long DEFAULT_IDLE_WAIT_TIME = 30L * 1000L;

    private long idleWaitTime = DEFAULT_IDLE_WAIT_TIME;

    private int idleWaitVariablness = 7 * 1000;
    ......

我们重点看它的run方法,run方法一来就是一个巨大的while循环:

 while (!halted.get()) {
 ......
 }

halted是一个原子boolean类,它指示scheduler任务调度是否停止,初始化时为false,只有shutdown的时候才会设置为true。
紧接着是一个同步块:

// check if we're supposed to pause...
synchronized (sigLock) {
   while (paused && !halted.get()) {
        try {
              // wait until togglePause(false) is called...
              sigLock.wait(1000L);
        } catch (InterruptedException ignore) {
          }
   }

   if (halted.get()) {
      break;
   }
}

如果scheduler没有停止任务调度,当处于standby模式(通过调用standby()方法,paused=true)时,就会wait 1秒钟,直到调用了shutdown()、start()等方法,改变了scheduler状态。当Scheduler创建的时候,最初是处于standby模式,我们集成quartz到spring的时候,会有一个手动调用start()启动Scheduler任务调度的一个步骤,具体可以参考之前写过的spring集成quartz的文章。
接下来是获取触发器,

 triggers = qsRsrcs.getJobStore().acquireNextTriggers(
                                now + idleWaitTime, 
                                Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()), 
                                qsRsrcs.getBatchTimeWindow());

这里面三个参数非常重要,有必要做一下解释:
now+idelWaitTime:这是一个现在的时刻加上了一个空闲时间,默认30秒,它和后面的timeWindow(默认0秒)组成了我们要获取的待触发的触发器的时间范围,即下次触发时间在now~now+30+0的时间范围内的触发器。
Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()):这是我们要从数据库获取的触发器个数,取线程池可用线程数和配置的定时任务批量处理个数的最低值。maxBatchSize默认值是1,因此一般情况下我们取到的trigger只有一个。
batchTimeWindow:这是一个时间窗口,默认值0。解释参考idleWatiTime。
上面几个参数都可以在quartz.properties中进行配置。

关于获取触发器

获取触发器的实际操作中,其实是有一个配置(例如创建CronScheduleBuilder的时候,指定了过期策略,默认是立即触发一次)。那么获取的触发器除了将要触发的触发器,还包括过期的触发器。但是这个过期有个时间范围,默认是10秒,也就是过了下次触发时间10秒之内的触发器我们也算作合法的触发器。综上,我们获取的触发器就包括在将来一段时间内要触发的触发器,和刚过期一小会儿的触发器。

获取到的触发器是按照触发时间升序排序,然后按照优先级降序排序。然后就是以第一个触发器的触发时间为准开始处理触发器

 now = System.currentTimeMillis();
 long triggerTime = triggers.get(0).getNextFireTime().getTime();
 long timeUntilTrigger = triggerTime - now;
 while(timeUntilTrigger > 2) {
     synchronized (sigLock) {
         if (halted.get()) {
             break;
         }
         if (!isCandidateNewTimeEarlierWithinReason(triggerTime, false)) {
             try {
                 // we could have blocked a long while
                 // on 'synchronize', so we must recompute
                 now = System.currentTimeMillis();
                 timeUntilTrigger = triggerTime - now;
                 if(timeUntilTrigger >= 1)
                     sigLock.wait(timeUntilTrigger);
             } catch (InterruptedException ignore) {
             }
         }
     }
     if(releaseIfScheduleChangedSignificantly(triggers, triggerTime)) {
         break;
     }
     now = System.currentTimeMillis();
     timeUntilTrigger = triggerTime - now;
 }

如果等待时间较长,而且没有新的定时任务加入、重启等等,线程就会wait一段时间。然后继续判断是否有新的情况(新任务加入,重启等等),如果有则释放掉已经获取到的触发器,包括将状态重新置为WAITING,然后在FIRED_TRIGGERS表中删除该记录,然后重新开始循环。如果没有则直到等待时间完毕,开始进行触发操作:

List<TriggerFiredResult> res = qsRsrcs.getJobStore().triggersFired(triggers);

触发操作主要包括更新FIRED_TRIGGERS和TRIGGERS两张表:
1-更新FIRED_TRIGGERS,更新相关触发时间,并将状态置为EXECUTING。
2-更新TRIGGERS,保存相关更改。
3-另外,如果不允许并发执行,则将原来等待,已经获取,暂停的触发器状态置为阻塞状态,表明不能再次对其进行触发。

总结

定时任务的触发,基本上都是对各种trigger进行操作,更新trigger数据和状态。

为什么获取触发器会有一个时间范围

更好的一个提问方式是:为什么时间范围内触发器都会被触发?

我们获取的触发器是一个时间范围内的触发器列表,而且是按照最早的一个触发器的触发时间来执行任务的。那么在这个范围内(一般是30秒),而又不在这个时间点的触发器被触发了,那不是不符合要求了?

其实,我们在获取触发器的时候,有一个maxBatchSize的参数,这个参数默认值是1,它就保证了我们最终获取到的只有一个触发器,恰好就是那个最先触发的触发器(大家可以试着将它设置的大一点,然后几个定时任务之间间隔不超过30秒,然后测试一下)。其它时间范围内的触发器都不会被处理。也就是说,一个循环内最多只会有一个trigger被处理,即使它们的触发时间是同一个时间点。如果需要支持多个trigger,而且对时间点要求的又不是那么高的话,我们可以通过两个参数设置:

org.quartz.scheduler.idleWaitTime : 3000
org.quartz.scheduler.batchTriggerAcquisitionMaxCount:5

我们可以把idleWaitTime设置得小一些来缩小时间误差,然后我们就可以将batchTriggerAcquisitionMaxCount设置的大一点,让schedulerThread可以同时处理更多的trigger。

需要注意的是,如果你的定时任务需要在准确的时间点触发(精确到秒,不过这样的定时任务很少,谁知道呢?),那么最好这两个参数都不要动,特别是batchTriggerAcquisitionMaxCount。因为它等于1,才能确保定时任务在准确的时间点触发。

;