创建一个精确的时间间隔,在长时间内没有漂移



我需要运行一个长期运行的任务,该任务仍处于循环中。循环中的代码只能在相同的时间间隔执行。可能最常见的处理方法是使用间隔计时器。间隔计时器的问题在于它不准确。如果我在计时器启动时绘制出绝对时间,那么在很长一段时间内会有少量漂移。如果间隔时间相当大,例如数百毫秒或更长,这就不是问题。但是,如果间隔只有几十毫秒,这确实会成为一个问题。

每当出现时间间隔时,该时间间隔必须相对于进入循环之前的起点发生。它不能是从最后一个结束时开始的时间间隔,否则你可能会随着时间的推移而累积漂移。

还有一个问题是,如果计时器块中的代码在时间间隔内完成的时间太长,那么一旦时间间隔到期,我不确定,但我相信计时器会立即启动。这将导致从一个循环到下一个循环的时间间隔不均匀。

我能想到的唯一解决方案是在循环开始之前记录系统时钟。我将称之为";开始时间";。它只存在一次,永远不会被覆盖。然后,在循环的代码完成后,测量当前时间和开始时间之间的时间差,并通过将差除以我们希望循环执行的速率来确定我们所处的时间间隔。如果还没有达到当前时间间隔,要么休眠到时间到期,要么继续循环并测量时间差,直到当前时间间隔到期。

也许有更好的解决方案吗?

ScheduledExecutorSerivce

如果我理解你的问题,但我很可能不理解,你只是在问如何按照严格的时间表安排重复任务的时间,因为任务的执行需要时间,因此可能会打乱时间表。

如果我理解正确,那么您正在讨论两种ScheduledExecutorService方法之间的有效差异:

  • scheduleAtFixedRate
  • scheduleWithFixedDelay

如果您希望任务按分钟开始,并每分钟运行一次,您应该首先计算一个初始延迟,直到下一分钟开始。然后,您将再次调用第一个方法scheduleAtFixedRate,以计划从第一分钟开始经过的下一分钟,而不是任务执行完成时经过的时间。

如果要等待任务执行完成后再重新开始执行任务,请调用第二个方法scheduleWithFixedDelay

漂移是不可避免的

正如ScheduledExecutorService的Javadoc中所指出的,在使用传统操作系统的传统硬件上运行的传统Java应用程序中,您不可能期望完美的定时而不发生漂移。

如果你想要一个几十毫秒的节奏,并且不会随着时间的推移而漂移,我想你会失望的。进程和线程通常会以不可预测的方式被操作系统和硬件挂起,持续时间不可预测,从而中断您精心安排的运行任务。在JVM中,垃圾收集和其他事件也可能以不可预测的方式暂停您精心安排的任务。

如果您确实需要这样的精度,那么您应该研究实时Java实现。

示例代码

我尝试了以下代码作为实验。

我试着通过运行一次任务来预热executor服务,然后等待几秒钟。

为了更容易地阅读时间戳,我的意图是在下一分钟的顶部开始重复任务。在这一点上,我的代码运行不好,启动延迟了6毫秒。例如:Now: 2021-04-30T07:39:00.006474Z

之后,定时执行器服务在保持10毫秒的节奏方面做得很好。在下面的例子中,我们达到了12毫秒、23毫秒、33毫秒、43毫秒、52毫秒、63毫秒、73毫秒、83毫秒、92毫秒、103毫秒等等。

// Prepare the background thread.
final long fixedRateInNanos = Duration.ofMillis( 10 ).toNanos();
Runnable runnable = ( ) -> System.out.println( "Now: " + Instant.now() );
ScheduledExecutorService ses = Executors.newSingleThreadScheduledExecutor();
ses.submit( runnable );  // Warm the executor service and backing thread pool.
try { Thread.sleep( Duration.ofSeconds( 2 ).toMillis() ); } catch ( InterruptedException e ) { e.printStackTrace(); }
// Calculate the schedule.
ZoneId z = ZoneId.of( "America/Edmonton" );
ZonedDateTime now = ZonedDateTime.now( z );
Duration d = Duration.between( now , now.truncatedTo( ChronoUnit.MINUTES ).plusMinutes( 1 ) );
long initialDelayInNanos = d.toNanos();
ses.scheduleAtFixedRate( runnable , initialDelayInNanos , fixedRateInNanos , TimeUnit.NANOSECONDS );
// Wait a while for app to run.
try { Thread.sleep( Duration.ofMinutes( 2 ).toMillis() ); } catch ( InterruptedException e ) { e.printStackTrace(); }
ses.shutdown();
try { ses.awaitTermination( Duration.ofSeconds( 10).toMillis() , TimeUnit.MILLISECONDS ); } catch ( InterruptedException e ) { e.printStackTrace(); }
System.out.println( "INFO - `main` done running. " + Instant.now() );

当在macOS 10.14.6 Mojave上使用Java 16从IntelliJ 2021.1.1 RC运行时,在具有6个真正英特尔内核且没有超线程的Mac mini上。

Now: 2021-04-30T07:38:51.444455Z
Now: 2021-04-30T07:39:00.006474Z
Now: 2021-04-30T07:39:00.012990Z
Now: 2021-04-30T07:39:00.023869Z
Now: 2021-04-30T07:39:00.033129Z
Now: 2021-04-30T07:39:00.043817Z
Now: 2021-04-30T07:39:00.052992Z
Now: 2021-04-30T07:39:00.063646Z
Now: 2021-04-30T07:39:00.073201Z
Now: 2021-04-30T07:39:00.083842Z
Now: 2021-04-30T07:39:00.092991Z
Now: 2021-04-30T07:39:00.103702Z
Now: 2021-04-30T07:39:00.113141Z
Now: 2021-04-30T07:39:00.123775Z
Now: 2021-04-30T07:39:00.132995Z
Now: 2021-04-30T07:39:00.143679Z
Now: 2021-04-30T07:39:00.153165Z
Now: 2021-04-30T07:39:00.163813Z
Now: 2021-04-30T07:39:00.172826Z
Now: 2021-04-30T07:39:00.183647Z
Now: 2021-04-30T07:39:00.193121Z
Now: 2021-04-30T07:39:00.203783Z

我通常这样做的方法是记录下一个间隔的时间,并进行睡眠或其他尝试。当循环恢复时,将当前时间与目标时间进行比较:

val difference = targetTime - currentTime
if (difference > 0) sleep(difference)
else {
// do thing
val newTarget = targetTime + interval
// get current time again because you've spent time doing thing
sleep(newTarget - newCurrent)
// or check it's not < 1 i.e. you overshot, so just loop again
}

因此,每个目标时间只是多次添加interval的结果,与循环实际运行的时间无关。你只需要依靠一个准确的时钟。

另一件需要注意的事情是你的实际间隔——如果这是一个很好的整数,那么你就没事了,如果你把它计算成一个分数(例如1/30秒),你的浮点数学就会不准确,而且随着你不断加上这个稍微错误的数字,这些数字会变得复杂。因此,您可能需要每隔一段时间从开始时间重新计算一个目标时间;里程碑";,所以你有点重置了准确性。

最新更新