Java: scheduleAtFixedRate() の正確さ

Java で一定時間ごとに何かを行いたい場合、java.util.Timer か java.util.concurrent.ScheduledThreadPoolExecutor のいずれかで、scheduleAtFixedRate() メソッドを使う。
後者の方が新しい API で機能的に充実しているんだけど、ある環境で試したら「指定した間隔できちんと実行してくれない」という現象が起きた。例えば毎分 0 秒丁度になった直後に動かそうとした時に、一見 1 分おきに動いているように見えるんだけど、実は間隔が微妙に長かったり短かったりした。どちらの場合も、長く動かしっぱなしにしていると、少しずつ時間がズレていく。
どういうわけか、Timer の方はちゃんと一定時間ごとに実行してくれるので、場合によっては Timer を使わざるをえないかも。
……というのを、帰宅してから再度試して記事にしようと思ったんだけど、自宅の PC では現象が再現しなかった。あれー?

以下は現状のまとめというか作業メモ。

試した環境

それぞれの環境で 1 回とか (多くても 3〜4 回) しか試してないんだけど、

  • Windows XP SP3 + Sun JDK 6 →何回か試した。後ろにズレていったり前にズレていったりした
  • Solaris 10 + Sun JDK 6 →1回だけ試した。前にズレていった
  • Windows XP SP3 上の VirtualBox 上の Ubuntu + Open JDK 6 →1回だけ試した。ズレなかった
  • Mac OS X 10.6 + Apple Java for Mac OS X 5/6 →何回か試した。ズレなかった
  • Windows Vista (Mac OS X と同じマシンで BootCamp) + Sun JDK 6 →何回か試した。ズレなかった
  • 最初のとは別の Windows XP SP3 + Sun JDK 6 →何回か試した。ズレなかった

なお、ズレなかったと書いた環境でも、遅延 (後述) が常に一定なわけではなく、少しずつズレていくんだけどある程度蓄積すると最小値に戻り、また少しずつズレていく、という動きを繰り返していた。この辺はハードウェアや OS が扱ってる時刻の精度に限りがあるからかな、と思ったので今回は「ズレなかった」ものとして扱った。

やったこと

Timer と ScheduledThreadPoolExecutor のそれぞれで scheduleAtFiexedRate() を呼び出して、毎秒 1 回、秒が切り替わった直後に処理が起動されるようにした。処理の中身は、現在時刻と遅延を標準出力に吐き出すだけの簡単なお仕事です。
ここでいう「遅延」は、計算上の「処理が起動されるはずの時刻」と「実際に処理が起動された時刻」の差を指している。いろいろな事情 (Java のスレッドモデルでは指定時間きっちりに必ず処理を開始することはできないとか、リアルタイム OS もリアルタイム Java も使ってないとか、処理が起動された後に現在日時を取得完了するまでに時間が経過しちゃってるとか) があるため遅延が 0 になることは期待できない。しかし、scheduleFixedRate() メソッドを使った場合、少なくとも一定の間隔で起動しようと努力はしてくれるはずだから、他に処理が動いているとかでなければ、長期間動かし続けても遅延はある程度の幅 (どの程度になるかは環境による) に収まるはずである。

まず、Timer 版。

import java.util.Date;
import java.util.Timer;
import java.util.TimerTask;

public class TimerDemo {
	private static final long INTERVAL = 1000;
	
	private static long roundup(long t) {
		return ((t / 1000 + 1) * 1000);
	}

	public static void main(String[] args) {
		long now = System.currentTimeMillis();
		final long t0 = roundup(now);
		
		Timer timer = new Timer("DemoTimer");
		timer.scheduleAtFixedRate(new TimerTask() {
			long tn = t0;
			
			@Override
			public void run() {
				long t = System.currentTimeMillis();
				System.out.printf("%tH:%<tM:%<tS.%<tL (%dms)%n", t, t - tn);
				tn += INTERVAL;
			}
		}, new Date(t0), INTERVAL);
	}
}

ScheduledThreadPoolExecutor 版。

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;


public class ExecutorDemo {
	private static final long INTERVAL = 1000;
	
	private static long roundup(long t) {
		return ((t / 1000 + 1) * 1000);
	}

	public static void main(String[] args) {
		long now = System.currentTimeMillis();
		final long t0 = roundup(now);
		
		ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
		executor.scheduleAtFixedRate(new Runnable() {
			long tn = t0;
			
			@Override
			public void run() {
				long t = System.currentTimeMillis();
				System.out.printf("%tH:%<tM:%<tS.%<tL (%dms)%n", t, t - tn);
				tn += INTERVAL;
			}
		}, t0 - now, INTERVAL, TimeUnit.MILLISECONDS);
		
	}
}