JavaScriptのsetInterval関数の意味を正確に理解するための1つの説明

ECMAScriptの言語仕様と組込関数の動作仕様は非常にきめ細かく規定されていますが、それに含まれていないJavaScriptの関数の仕様はどうもはっきりしないように思えます。そのためか、それを利用するコードも正確なものではないものが見受けられます。その1つがsetInterval関数。何をいまさら?という感じですが、ちょっとググッてみたところsetInterval関数を解説する記事は沢山見つかりますが、そのことについて注意している記事はほとんど見当たりません。

何のことかと言うと、setInterval関数は一定間隔で指定した関数を実行する、とだけ説明されていて、何が一定なの?ということについての説明が抜けているように思われます。例えば、もし、関数が一定間隔でコールされるなら、下のコードを実行した場合、duration=???はいくつと表示されるのが正しいのでしょうか?

function test() {
    var count = 100;
    var start = new Date().getTime();
    var timer = setInterval(function () {
        if (--count == 0) {
            var end = new Date().getTime();
            alert("duration=" + (end - start));
            clearInterval(timer);
        }
    }, 100);
}

タイマの間隔は100msecで、関数がコールされる度に初期値が100のcountがデクリメントされますから、100×100で10000msec つまり10秒後にalertが実行されて duration=10000 と表示されるのでしょうか。このような動作を想定した解説記事やサンプルコードをブログ等で多く見かけましたが、残念ながらJavaScriptはそのような動作はしません。

setInterval関数をタイマ割り込みのようなものと捕らえてしまうとこのような誤解が生じます。JavaScriptは完全なシングルスレッドモデルからなる言語です。1つの処理が継続している間に別の処理が割り込むということはありません。ここで、1つの処理というのが曲者ですが、1つの関数と定義してもよいでしょう。

setIntervalの第1引数に設定する関数に限らず、onloadやonclickなどに登録するコールバック関数、あるいは、XMLHttpRequestの非同期コールバック関数などがどのように実行されるのか、ということを理解することがJavaScriptをより正確に理解することにつながります。

ブラウザ中でのJavaScriptの実行モデルはシングルスレッドのタスクドリブン型です。タスクはJavaScriptエンジン以外の外部の機構によりキューに積まれます。Scriptエンジンはそのキューの先頭を見て、処理すべきタスクがあればタスクを処理し、なければ休止するという単純な単一ワーカーモデルです。1つのタスクを処理している間は、別のタスクを処理することはできません。1つのタスクを終了した後、キューの先頭を見て次のタスクがあればそれを処理します。タスクには時刻が書かれており、キューはその時刻順にソートされています。ほとんどのタスクの時刻は「即時」を意味するような時刻が書かれていますが、タイマのようなタスクには特定の時刻が書かれています。その時刻が現在時刻より未来のものは処理しないことになります。

このキューに積まれる最初のタスクが、グローバルコードを実行するタスクです。グローバルコードを実行した後は、マウスやキーボード、あるいはタイマなどのイベントがタスクとしてキューに積まれていきます。これらをキューに積むのはScriptエンジンとは別のワーカー・スレッドが行います。一見、JavaScriptがマルチスレッドのような動作をするように思えるのはこのためです。XMLHttRequestでサーバからの応答が帰ってくる前に、次の処理を並行して実行できる。あるいは、ImageオブジェクトのsrcプロパティにURLをセットして、イメージをロードしている間に別の処理を実行できる、などの機構は全てこのワーカーモデルによるものです。イメージをロードしているのは、Scriptエンジンではなく、ネイティブな別のスレッドです。そのネイティブなスレッドはイメージがロードし終わった後に、その旨を伝えるタスクをキューに積むという動作になります。

さて、話をsetInterval関数に戻しましょう。第1引数に与えた関数を実行するタスクには時刻がつきますが、その時刻がいつどのように付けられるかです。結論から言うと、setInterval関数を実行したときに、「現在時刻+第2引数で指定した時間」の時刻をタスクに付けてキューに積みます。時間が経ち、そのタスクがキューの先頭に来ると、Scriptエンジンはそのタスクを実行します。その後に、タスクの時刻を「現在時刻+第2引数で指定した時間」の時刻に更新して再びキューに積むという動作になります。従って、setInterval関数の第1引数で指定した関数の実行に関わる時間分だけ遅れることになります。さらに、もし、タイマのタスクを処理する時刻に別のタスクが実行されていたら、そのタイマタスクは前のタスクが終了するまで処理されないことになり、インターバルの間隔がますます延びることになります。ちょっとした数秒間のアニメーション程度で利用する場合には実用上問題ありませんが、短いインターバルを長時間にわたり繰り返すような処理で、実時間に同期するすることが重要な場合には、setIntervalの第2引数で指定したインターバル時間だけに頼ったコードを書くのは危険です。Dateクラスなどを利用して現在時刻を見ながら調整する必要があります。

なお、ここで説明したScriptエンジンのワーカーモデルはHTML5のWebWorkerとも関連しますが、それについては機会を改めて説明できたら、と思います。