Springboot @Scheduled 定时原理简析

本文最后更新于:2024年3月18日 晚上

前情提要

众所周知,Springboot中可以用@Scheduled开启定时任务

这个时候有人就说了:定时任务有什么好讲的,不就是定时の任务嘛

有点道理,干讲没有什么意思,我们来试想这样一种场景:

  • 假如有一个定时任务,每小时触发一次,但是系统(Windows)休眠了6个小时

那么:这个定时任务会在开机后立刻被触发吗,触发几次呢?

正片叠底

好的,希望这个问题让你提起了一点点兴趣

@Scheduled一共有三种定时方式:

  • cron:定义了一个如何循环的时间模式,比如“每天凌晨1点执行”,当然也可以定义为“每个小时的0分执行”;

    cron表达式由六个字段组成:[秒 分 时 日 月 周]

    这么说可能比较抽象,我们来看个例子:

    • “0 * * * * *”:每分钟的0秒都执行一次
    • “0 0 9 * * *”:每天9点0分0秒执行一次
    • “0 0 9 * * MON”:每周一的上午9点整执行
    • “0 0 9 1 * *”:每月的第一天上午9点整执行

    总之就很强大,可以去了解一下

  • fixedRate:是指从每次执行开始到下一次执行开始的固定间隔

  • fixedDelay:是指一次执行完成到下一次执行开始之间的固定等待时间(会算上执行时间)

那么他们在面对系统休眠而错过下次执行时间的情况,会怎么处理呢?

  • corn:当系统唤醒后,cron 调度的任务会在下一个符合表达式的时间点执行。它不会因为错过了预定时间而尝试“补偿”执行

  • fixedRate:根据实验,系统唤醒后,并不会立即执行任务,而是继续等待,几十分钟后,几乎同时执行了多次任务(休眠中错过的那几次),补偿了失去的过去(可能是按照预计的时间计算了下一次的时间,没有根据实际系统时间校正)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #ScheduledTask.Clean 00:44:40.224923500
    ... //休眠中
    #ScheduledTask.Clean 12:25:07.292055800 //11点多唤醒的,过了一会儿才执行
    #ScheduledTask.Clean 12:25:07.293056400
    #ScheduledTask.Clean 12:25:07.293056400
    #ScheduledTask.Clean 12:25:07.293056400
    #ScheduledTask.Clean 12:25:07.293056400
    #ScheduledTask.Clean 12:25:07.293056400
    #ScheduledTask.Clean 12:25:07.293056400
    #ScheduledTask.Clean 12:25:07.293056400
    #ScheduledTask.Clean 12:25:07.293056400
    #ScheduledTask.Clean 12:25:07.293056400
    #ScheduledTask.Clean 12:25:07.293056400
    #ScheduledTask.Clean 12:44:10.973081
  • fixedDelay:推测,也是继续等待攒满Interval,但是不会补偿执行,因为间隔是从上一次任务结束后开始计算的

fixedRatefixedDelay的执行机制应该大差不大

让我们从fixedRate管中窥豹,看看其底层定时机制

定时机制

@Scheduled可以实现定时任务,这无可厚非,不必质疑,看起来理所当然,理应如此

从来如此,便接受了吗?

为什么呢,你有没有想过,定时,是如何实现的

有两种可能,固定时间点 & 固定时间间隔

第一种,假如我们要在每天的23:00提醒用户睡觉

那么,我们怎么才能在23:00执行特定任务

谁来通知我们,现在已经是23:00了

  • 一个简单的想法是:每秒轮询一次时间,如果正好是23时,0分,则执行任务

那么问题就是,任务会被多次触发(60次 maybe)

  • 那可以增加一个变量来记录是否执行过,
  • 或者把每秒轮询改为每分钟轮询(延迟大),
  • 或者检测23时0分0秒(可能错过,定时不一定精准)
  • 或者改为每毫秒执行(开销大),
  • 或者判断两次执行的间隔中包不包含特定时间点(23:00:00.0000)(最保险)

好的,好的,先别纠结这个了

你刚刚是不是说:每秒轮询一次时间

每秒,轮询,这不又是定时吗,你怎么又绕回来了,自依赖是吧(定时依赖定时)

我们怎么实现每秒轮询?

  • 一个简单的想法是:While循环 + Sleep(1000)

好的好的,很完美,就是有点简陋

其实,操作系统提供了定时器,例如linux的cron,Windows的定时任务

硬件时钟和时钟中断(by GPT-4)

  • 基础:大多数计算机系统都有一种硬件时钟(通常是实时时钟RTC和高精度事件定时器HPET),它们可以生成中断。这些时钟以固定频率运行,当达到预定时间时,它们会向CPU发送中断信号。
  • 工作原理:操作系统内核通过处理这些中断来维持系统时间和调度定时任务。当时钟中断发生时,操作系统的调度器会检查是否有需要执行的任务。

如此依赖,我们便可以实现每秒轮询了,固定点执行任务 GET

对于固定间隔,我们只要根据现在的时间计算出下一次预计执行的时间点即可,完美

小问题

就是有个问题哦,每秒轮询一次貌似有点非常不那么精确

还有一个问题,假如我需要每小时执行一次任务,那么每秒的这些轮询就极大地浪费了CPU

//好吧 其实对CPU来讲是小case啦,但是听着就很不优雅好吧

当然,我们也可以计算出距离时间点的间隔,然后使用系统的定时器,使其一小时后通知我们

@Scheduled定时机制

说了那么多,我们还是来看看@Scheduled的定时机制是怎么样的

呆——

没错你卡了

经过一下午的搜索和源码阅读(反编译的.class)

我觉得,我们还是抓重点吧,毕竟讲那么细也没什么用,对啊,啊hahaha

> <

借一下大佬的图吧

Scheduling模块的工作流程

反正就是

  • ScheduledAnnotationBeanPostProcessor会解析@Scheduled注解,并且把方法(任务)封装为不同类型的Task实例,缓存在ScheduledTaskRegistrar
  • 其钩子方法afterSingletonsInstantiated()在所有单例初始化完成之后回调触发,在此方法中设置了ScheduledTaskRegistrar中的任务调度器(TaskScheduler或者ScheduledExecutorService类型)实例,并且调用ScheduledTaskRegistrar#afterPropertiesSet()方法添加所有缓存的Task实例到任务调度器中执行

任务调度器

Scheduling模块支持TaskScheduler或者ScheduledExecutorService类型的任务调度器,而ScheduledExecutorService其实是java.util.concurrent的接口,一般实现类就是调度线程池ScheduledThreadPoolExecutor

ScheduledThreadPoolExecutor

好的好的,其实这里讲了一大堆,我看那源码真的是跳来跳去,云里雾里,还都是反编译的,还都没有索引,还都找不到被谁调用了

好好好

总之呢,任务被放到了一个特殊的数据结构(延迟队列 DelayQueue)里

内部是一个优先队列,按照预计执行时间排列,头部是最先执行的,take获取头部时,如果时间还没到,就会休眠等待

不过呢,看源码的话,貌似是自己实现了一个类似的结构,而不是直接用DelayQueue

// 内部的RunnableScheduledFuture继承了Delayed

1
static class DelayedWorkQueue extends AbstractQueue<Runnable> implements BlockingQueue<Runnable>

ScheduledThreadPoolExecutor中的一个静态内部类

来看一下核心的take方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public RunnableScheduledFuture<?> take() throws InterruptedException {
ReentrantLock lock = this.lock;
lock.lockInterruptibly();

try {
while(true) {
while(true) {
RunnableScheduledFuture<?> first = this.queue[0];
if (first != null) {
long delay = first.getDelay(TimeUnit.NANOSECONDS);
if (delay <= 0L) {
RunnableScheduledFuture var14 = this.finishPoll(first);
return var14;
}

first = null;
if (this.leader != null) {
this.available.await();
} else {
Thread thisThread = Thread.currentThread();
this.leader = thisThread;

try {
this.available.awaitNanos(delay); //
} finally {
if (this.leader == thisThread) {
this.leader = null;
}

}
}
} else {
this.available.await();
}
}
}
} finally {
if (this.leader == null && this.queue[0] != null) {
this.available.signal();
}

lock.unlock();
}
}

采用了Leader-Follower模式

比较复杂,先不管那个,我们的目的是搞明白为什么系统休眠唤醒后,超时不会立即执行任务

1
this.available.awaitNanos(delay);

如果第一个任务的时间还没到的话,就会等待,采用ConditionawaitNanos方法,进入休眠

而这个方法的实现内部又调用了LockSupport.parkNanos(this, nanos);

1
2
3
4
5
6
7
8
public static void parkNanos(Object blocker, long nanos) {
if (nanos > 0L) {
Thread t = Thread.currentThread();
setBlocker(t, blocker);
U.park(false, nanos); //
setBlocker(t, (Object)null);
}
}

最后是调用了Unsafepark方法

这是一个native方法,从Java层面无法继续往下了,应该是在虚拟机中调用了C++代码,然后进行系统调用

那么,这个Unsafe.park所等待的时间在Windows上,有可能,在休眠时暂停计时,唤醒时继续计时,休眠时间不计入

这是推测

不过@Scheduled-fixedRate确实在休眠时暂停了计时,这是实验结果

Conclusion

好的,以上都是乱查查乱看看 + 推测

仅做抛砖引玉,不保证正确性,因为这代码也太绕了,而且还是反编译的!

Peace

Ref

Thread.sleep、synchronized、LockSupport.park的线程阻塞区别 - 知乎 (zhihu.com)

LockSupport中的park与unpark原理_locksupport park-CSDN博客

springBoot中@Scheduled执行原理解析_在springboot中执行定时任务有先后顺序-CSDN博客

通过源码理解Spring中@Scheduled的实现原理并且实现调度任务动态装载 - throwable - 博客园 (cnblogs.com)

Java定时调度机制 - ScheduledExecutorService - 简书 (jianshu.com)

DelayQueue延迟队列原理剖析 - BattleHeart - 博客园 (cnblogs.com)

DelayQueue 源码分析 | JavaGuide

Java 延迟队列 DelayQueue 的原理 - 掘金 (juejin.cn)


Springboot @Scheduled 定时原理简析
https://mrbeancpp.github.io/2024/03/18/Springboot-Scheduled-定时原理简析/
作者
MrBeanC
发布于
2024年3月18日
许可协议