ScheduledThreadPoolExecutor易出现时钟漂移问题,不宜使用在UTC、系统时间或用户交互方面的定期调度,CronScheduler是用于与外部交互的可靠Java调度程序 - Leventov


ScheduledThreadPoolExecutor 容易出现无限的时钟漂移
最近,我意识到ScheduledThreadPoolExecutor容易出现无限制的时钟漂移,因此不能用于以UTCUnix时间系统时间 (例如,每小时一次)的特定时间戳或特定速率在长时间如超过几天的调度任务的场合,通常在后端(除非您必须每天强制重启服务器应用程序)以及桌面软件都是属于这种长时间调度这种情况。
正是StackOverflow上的这个问题使我开始思考这个问题,有人在使用ScheduledThreadPoolExecutor时观察到每天〜15分钟的漂移。

java.util.Timer 堵塞定期任务或在系统时间转移时导致任务堆积
JDK中有一个脏类:Timer,部分不受时钟漂移问题的影响(对于周期性任务而言是这样,但对于将来远期计划的单发任务则不适用)。这要归功于Timer使用System.currentTimeMillis(系统时间)作为时间源,而ScheduledThreadPoolExecutor使用System.nanoTime(CPU时间)作为时间源。
尽管系统时间也可能会相对于协调世界时(UTC)漂移,在某些情况下甚至可能比CPU时间更快,但我们假设计算机定期与NTP服务器同步,以在较小时纠正漂移。
Timer还有其他缺点:
首先,当系统时间偏移时Timer的行为异常。当系统时间向后移动时,周期性任务在该移动时间内停止运行。当系统时间向前移动时,Timer尝试通过快速连续触发许多周期性任务实例来赶上,这可能是不希望的。
尽管这在ScheduledThreadPoolExecutor上很少表现出来,但它也可能需要赶上定期任务。ScheduledThreadPoolExecutor仅当其中一项任务长时间阻塞执行程序的线程或由于长时间的GC暂停而使定期任务堆积时,才可能堆积这些任务。这两种类型的事件通常仅持续几秒钟,可能长达一分钟,而用户可能会手动将系统时间移动数小时甚至数天,从而导致在Timer上运行的计划任务大量爆发。
当使用Timer(但不使用ScheduledThreadPoolExecutor)时,TimerTask可以通过scheduledExecutionTime与此处建议的当前时间进行比较来手动检查运行的延迟,从而规避此问题。但是,显然,强迫用户自己编写这种讨厌的解决方法不是很好。
通常,Timer具有一些过时的API。任务不能是lambda,因为它们必须扩展TimerTask,它是抽象类,而不是功能接口。Timer的schedule方法不返回Future,这是可用于获得一次性任务执行结果或取消任务的对象。

根据UTC或壁钟时间而不是根据计算机的抽象时间(系统时间或CPU时间)进行调度时ScheduledThreadPoolExecutor(以及Timer)的另一个鲜为人知的问题是,ScheduledThreadPoolExecutor并不能在计算PC、笔记本电脑或平板电脑处于休眠模式花费的时间(例如睡眠或休眠)。
例如,如果某个任务提交延迟一小时执行,然后一分钟后用户关闭笔记本电脑的盖子一小时,那么当用户继续使用笔记本电脑时,该任务将不会在59分钟后再启动,尽管在某些情况下,在笔记本电脑的机盖打开后立即执行任务会是更合理的行为:考虑通知或检查某些Web服务的更新。

解决方案:CronScheduler
如果您之前没有讨论过时间问题,那么此时您可能会在协调UTC时间、挂钟时间(ZonedDateTime又名Java时间),Unix时间、系统时间和CPU时间之间旋转。好消息是CronScheduler现在可以为您解决这种复杂性。
CronSchedulercron实用程序命名,是因为它努力在Java进程中尽可能接近地匹配cron的调度精度和可靠性。
CronScheduler类似于单线程ScheduledThreadPoolExecutor,它类似于Timer,使用系统时间(通过 System.currentTimeMillis)作为时间源而不是CPU时间。如果有更可靠的时间提供者,也可以为CronScheduler实例配置它。
为了解决时钟漂移问题,并解决上述的机器暂停问题,CronScheduler定义了一个所谓的同步周期,它是CronScheduler线程的强制唤醒周期。当CronScheduler唤醒以运行某些任务时,或者因为它已睡眠了整个同步周期,它会检查系统时间并根据需要调整计划任务的剩余等待时间。这样,CronScheduler通过其同步周期有效地限制了机器暂停事件后的定期任务的延迟。
必须为每个实例的每个实例选择同步周期,CronScheduler取决于可容许的时钟漂移量,是否会发生机器挂起事件和重大的系统时间中断(通常在消费类计算机和设备上,但在服务器环境中),以及这些事情发生时,最大可忍受的任务延迟。
如果CronScheduler在某个时间点检测到系统时间已向后移,它还会检查所有计划的定期任务,以查看它们现在是否需要比预期的更快开始。它可以防止因系统时间倒退而导致定期任务冻结(至少不超过CronScheduler的同步时间)。

在壁钟时间安排定期任务
CronScheduler具有ScheduledExecutorService除了的所有方法的等效项scheduleWithFixedDelay。
另一方面,CronScheduler提供了其他scheduleAtRoundTimesInDay方法来安排一天中某个时段的定期任务(例如,在每个3小时周期的开始:00:00、03:00、06 :00等)。 )在给定的时区中,要处理计算初始触发时间并考虑夏令时变化的复杂性。
无论何时,如果坚持夏令时更改(或永久性时区偏移更改),则始终坚持指定时区的壁钟时间,这意味着在物理时间或系统时间方面,任务的最佳周期性运行可能会受到干扰在时钟改变的时刻。使用scheduleAtRoundTimesInDay方法之前,请确保考虑此折衷。

跳至最新的定期任务运行
CronScheduler还提供了等效scheduleAtFixedRate,以及scheduleAtRoundTimesInDay,考虑系统时间可以前移,并跳过所有中断,在时间偏移发生时“任务连发运行”的问题在Timer中是很容易出现的。
建议:何时使用哪个调度程序?

  1. 仅ScheduledThreadPoolExecutor,用于与Java流程的内部业务有关的任何事情。但是,请仔细观察,进程内交互在语义上并未与某些外部交互相关联,并且它不会以某种微妙的方式影响高层系统(机器或集群)的动态。例如,如果存在一个查询,并且用户指定了5秒钟的超时,则在流程内安排协调中断,实际上并不是纯粹的内部问题。(这并不是说ScheduledThreadPoolExecutor在这种情况下不应该使用,它仍然在此列表的下一个项目中涵盖。)
  2. 使用ScheduledThreadPoolExecutor用于单触发超时,到期,驱逐,延迟的重试,清理,杀死,通知,或任何其它类似的动作,在机器内或远程的,只要该延迟是相对短的(比方说,不到一天的时间)和机器不应进入挂起模式,即在服务器上。考虑CronScheduler是否满足以下条件之一,即是否以周为单位计算延迟(示例:身份验证令牌或cookie过期),或者用户的计算机或设备可能进入睡眠状态。
  3. 采用ScheduledThreadPoolExecutor定期清理,冲洗,刷新,配置重新装载,转储,日志旋转,心跳,健康检查,状态检查,或其他任何类似的动作,机器内或远离,只要时间不是语义参与行动和行动是幂等的。
  4. 如果机器内或分布式系统中的周期性动作与时间概念有关,请考虑CronScheduler。一个示例是Java进程每分钟一次将指标发送到某个外部监视系统。如果使用ScheduledThreadPoolExecutor,则过程和监视系统不能简单地假设每个发送都对应于下一分钟:时钟漂移最终将使度量仪表板误导相关的分布式系统上不同节点上的事件。或者,您可以将当前的系统时间(每分钟的截断时间)附加到分钟,但是,缺席的时间或两次发送将很常见。使用CronScheduler会更简单,更可靠,并产生更平滑的指标。其他例子可能会纠缠时间成分的周期性操作包括备份,预写日志循环,复制,节点间同步和检查点。
  5. 为了在机器内或分布式系统中按优先顺序生成时间推移事件,调度数据处理作业或定期执行数据保留规则(业务规则,法律策略)(如果我们仅考虑调度精度和可靠性),使用:—您的云提供商可以使用的计划功能;- systemd或cron实用程序—可从您的群集管理或执行框架(如Kubernetes或Mesos)使用的调度工具;—通过使用无GC或低中断GC(例如C ++,Rust或Go)语言编写的程序进行计划;— CronScheduler,最好在具有低暂停GC的JVM中运行,例如Shenandoah GC或ZGC。这些事件总是根据UTC,Unix时间或系统时间来定义的,因此您永远不应将其ScheduledThreadPoolExecutor用于这些目的。
  6. 对于与人的任何交互(例如警报,通知,计时器或任务管理),以及用户计算机或设备与远程服务之间的交互(例如检查新电子邮件或消息,小部件更新或软件更新):—开Android,请使用Android专用的API。查看此帖子以获取更多详细信息。— CronScheduler,如果您正在编写香草Java应用程序。
  7. 永远不要使用Timer:所有有效的用例都被ScheduledThreadPoolExecutor或取代CronScheduler。

实码
以下是Apache Druid的生产代码库中的两个具体示例,ScheduledThreadPoolExecutor应该将其替换为CronScheduler(两个都包含在第4条建议中):

我在哪里可以得到CronScheduler以及如何开始?
请参阅Github上的自述文件。