メモを揉め

お勉強の覚書。

setTimeoutの挙動について

out!

JavaScriptによるアニメーションの制御について調べていたところ、本筋から離れてしまいそうだったのでここに。

ここまでの流れをおおまかに書くと、

  • JavaScriptにはタスクのキューがあり、頭から順に登録されたタスクが処理される。
    ここで言うタスクとはほぼ関数と考えていい。
  • setTimeoutは指定時間後にそのキューにタスクを追加する。」
    という概念が多くの説明で使われているけど本当?と感じる様な挙動があった。
  • ブラウザによる画面の再描画はタスクとタスクの間にしか起こらない、
    タスク(関数)の実行中には再描画は起こらない。
  • 追加されるタスクは必ずキューの一番後ろに追加されるのか?
    イベントに紐付けられたタスク等の場合、割り込む事はあり得るのか?

このあたりのことが気になったので実験をしました、そしてその結果から仮説を立てました。

setTimeoutは指定時間後に本当にタスクをキューに登録してる?

再描画について調べていると、キューが空になったタイミングでとか、キューがアイドル状態になった時にといった表現が多い。
キューが空になるというのは登録されたタスクが全て処理され、キューに何もない状態のことだと思う。
アイドル状態も同じ表現だと思う。

では以下のコードではどうのようなことが起こっているのだろう。

for (var i = 0; i < 10; i++) {
    setTimeout(emptyFn, 0);
}

function emptyFn() {}

指定時間後にタスクをキューに追加しているという言葉をそのまま解釈すれば、0ミリ秒後に10個のタスクがキューに登録されることになる。
空のタスクなので追加されるやいなや一瞬で処理され、再描画の確認をすることは出来無い。

では次のコードの場合はどうなるだろう。

// 重い処理を10個連続で登録する
for (var i = 0; i < 10; i++) {
    setTimeout(heavyFn, 0);
}

var cnt = 0;
function heavyFn() {
    // 0.6〜0.8秒位かかる重い処理がここに書いてある...
    console.log(++cnt + ' done');
}

for文でタスクを10個追加している点は同じだが、そのタスクは0.6〜0.8秒ほど掛かる重い処理になっていて、最後に'done'と出力する。

documentのクリックイベントには'click'と出力する処理を登録しておく。

document.addEventListener('click', function() { console.log('click'); });

(実験ではこれに加えて視覚的に分かりやすいように、heavyFnにはdivのサイズを変える処理を、クリックイベントにはbody要素の色を変える処理を追加している。
これによりブラウザの再描画のタイミングも同時に観察する。
さらにheavyFnの1個目から10個目が終わるまでの間だけrequestAnimationFrameの呼び出しをカウントする。)

もし、指定時間後にキューにタスクが10個追加されて、クリックイベントよって発生したタスクもキューの一番最後に追加されるのであれば、setTimeoutよって登録されたheavyFnが全て終了してから、クリックにハンドルされたタスクが走ることになる。
上のコードの場合だと'done'が10回出力される前にクリックをしても、即座に'click'は出力されず、'done'が10回出力された後で'click'が出力されるはずだ。

ブラウザによる差異

実際に実験をしてみるとブラウザによって挙動が違った。
ChromeSafariでは、divが大きくなり始めてからクリックをすると直ぐにbodyの色が変化し、その後もdivは大きくなり続けた。
Firefoxdivのサイズが変化する間に再描画が起こらず、divのサイズが最大になった状態がbody要素の色の変化とともにまとめて再描画された。

この挙動は一体どのように考えればいいのだろうか?
ちなみに、ここから先の話は実装を見てきたわけでは無いので、あくまで説明上つじつまの合う仮説を立てている。

ChromeSafariの場合

Safariスクリーンショット

Safariのスクリーンショット

あくまでキューはキュー

10個のタスクがsetTimeout(task, 0)によって10回繰り返し登録された時、すぐさまキューにタスクが10個全て登録されるわけでは無い、という考え方。

setTimeoutがブラウザのタイマースレッドへ、
alert('D')0ミリ秒後にキューへ登録するよう、依頼します。
blog.mouten.info: JavaScriptのsetTimeoutを理解する

この記事の説明に出てくるタイマースレッドというものに一度積まれた後で0ミリ秒後にキューに追加といった処理を行っているとすると、必ず一つずつキューに追加していて、同時に10個のタスクがキューに放り込まれるようなことは無い、ということになる。

setInterval関数を実行したときに、「現在時刻+第2引数で指定した時間」の時刻をタスクに付けてキューに積みます。
時間が経ち、そのタスクがキューの先頭に来ると、Scriptエンジンはそのタスクを実行します。
風と宇宙とプログラム: JavaScriptのsetInterval関数の意味を正確に理解するための1つの説明

タイマースレッドに登録されたタスクは一つずつキューに追加され、そのタスクが終わると設定された時間が早いものから先に再びタイマースレッドからキューへタスクが送られる。

優先度付きキュー

もうひとつの考え方としては、クリックイベントによって発生したタスクがsetTimeoutよって登録されたタスクの間に割り込み優先的に実行された、という考え方。

タスクには時刻が書かれており、キューはその時刻順にソートされています。
ほとんどのタスクの時刻は「即時」を意味するような時刻が書かれていますが、タイマのようなタスクには特定の時刻が書かれています。
その時刻が現在時刻より未来のものは処理しないことになります。
風と宇宙とプログラム: JavaScriptのsetInterval関数の意味を正確に理解するための1つの説明

この考え方の場合、for文によって10個のタスクはすでにキューに登録されているが、時刻によってソートされるため、0ミリ秒後に実行と書かれたタスクよりも即時と書かれたクリックイベントが優先的に実行されたということになる。

Firefoxの場合

Firefoxスクリーンショット

Firefoxのスクリーンショット

Firefoxの場合setTimeout(task, 0)で登録された10個のタスクの間に再描画は起こらなかった、さらにクリックイベントの発火も10個のタスクが終わった後だ。

この挙動の場合、
0ミリ秒語に10個のタスクがキューに追加され、クリックイベントによって発生したタスクが一番最後に追加された
という考え方で問題無さそう。

まとめ

まとまらない。

でも、ほとんど場合この挙動の違いが問題になるような場面は無いという言い伝えがある。