イベントループとは?
JavaScriptはシングルスレッドです。一度に実行できるタスクは1つだけです。つまり、
- 2つ以上の処理を並行して実行できない
- 2つ以上の関数を同時実行できない
ということになります。
例えば、10秒掛かるタスクを実行すると、他のタスクは10秒間待機しなければなりません。
JavaScriptはデフォルトでブラウザのメインスレッドで実行されるため、UI全体が動かなくなります。
では、イベントループについて解説していきます。
まず、V8などのJavaScriptエンジンやブラウザには、下記の画像の様にコールスタック(Call Stack)、ヒープ(Heap)、タスクキュー(Task Queue)、Web APIと呼ばれる4つのメカニズムを備えています。
まずは、このメカニズムを簡単に説明していきます。
Web API
Web API
にはDOM Event, setTimeout, Ajaxなどが含まれます。これらの機能は非同期(ノンブロッキング)に実行されます。
ブラウザが提供する機能です。
ヒープ(Heap)
動的に確保と解放を繰り返せるメモリ領域です。
オブジェクトはヒープに割り当てられています。
JavaScriptエンジンの内部に実装されています。
コールスタック(Call Stack)
関数は呼び出されるとコールスタックに追加されます。
コールスタックはLIFO(後入れ先出し)で機能します。
JavaScriptエンジンの内部に実装されています。
タスクキュー(Task Queue)
コールバック関数はタスクキューで待機します。
タスクキューはFIFO(先入れ先出し)の配列で、イベントループは、コールスタックが空になる度に、タスクキューからコールバック関数を取り出して実行します。
JavaScriptエンジンの外部に実装されています。
MDNでは、なぜ「ループ」という名前が付いたか、下記の様な仮想コードで説明しています。
while(queue.waitForMessage()){
queue.processNextMessage();
}
上記のwaitForMessage()
は、現在実行中のタスクが存在しない場合、次のタスクがタスクキューに追加されるまで待機します。このようにイベントループは、「現在実行中のタスクがないこと」と「タスクキューにタスクがあるか」を繰り返し確認します。簡単にまとめると次のようになります。
- すべての非同期APIは作業完了後、コールバック関数をタスクキューに追加する。
- イベントループは、現在実行中のタスクがない(コールスタックが空になった)場合、FIFOでタスクキューから取り出して実行する。
- 現在実行中のコールタスクがある(コールスタックに関数がある)場合、LIFOでコールスタックから取り出して実行する。
これらがイベントループの簡単な説明になります。
では、このメカニズムを実際のコードを踏まえて理解していきましょう。
イベントループをコードで理解する
プログラム1
まずは簡単に下記のコードの実行結果を考えてみましょう。
const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 1000);
const baz = () => console.log("Third");
foo();
bar();
baz();
実際に実行してみましょう。実行結果は下記の様になったと思います。
"First"
"Third"
"Second"
このプログラムの流れとしては
-
foo
がコールスタックに追加されます。そして、"First"
を返し、コールスタックからポップされます。 -
bar
がコールスタックに追加されます。 - コールバック関数の
() => console.log("Second")
がWeb APIに追加され、1000ms待機します。 - その瞬間
setTimeout
はコールスタックからポップされ、値を返します。 - 1000ms待機している間に、
baz
がコールスタックに追加さます。そして、"Third"
を返し、コールスタックからポップされます。 - 1000ms後、
() => console.log("Second")
は直ぐにコールスタックに追加されるのではなく、タスクキューに渡されます。 - そして、イベントループで、コールスタックが空なことを確認し、タスクキューにある
() => console.log("Second")
がコールスタックに渡されます。 -
() => console.log("Second")
が"Second"
を返し、コールスタックからポップされ、プログラムは終了します。
となります。視覚的に理解したい方は、こちらで上記のプログラムを実行してみると面白いと思います。
では、次のプログラムにいきましょう。少し難易度が上がります。
プログラム2
下記のプログラムを実行し、ボタンをクリックしてみましょう。
<!DOCTYPE html>
<head>
<title>event loop</title>
</head>
<body>
<button id="heavyCount">click</button>
<div id="counter">0</div>
<script>
const button = document.getElementById('heavyCount');
const counter = document.getElementById('counter');
button.addEventListener('click', function() {
let count = 0;
let times = 1000;
function loop() {
if (count++ < times) {
counter.innerHTML = count;
loop();
} else {
alert("Done");
}
}
loop();
});
</script>
</body>
</html>
恐らく、アラートが表示された後、下記の様にページ内の0が1000に変化したと思います。
では、このプログラムを下記の様に動かしたいときはどうでしょう。ソースコードを少し修正してあげるだけで、この動きは実現できます。
その前に、イベントループにおける、最初の動きの流れを追っていきたいと思います。
- まず、
addEventListener
をコールスタックに追加。 -
addEventListener
のコールバック関数をWebAPIに追加。 - ボタンをクリックすると、
loop
がコールスタックに追加されます。 -
count
が++されます。 -
counter.innerHTML
がタスクキューに追加されます。 - 次に、
loop
の中で更にloop
を再帰的に呼び出しています。 -
loop
がコールスタックに追加されます。 -
count
が++されます。 -
counter.innerHTML
がタスクキューに追加されます。 - 次に、
loop
の中で更にloop
を呼び出します。
…という風に、今回のプログラムでは、コールスタックにcount
が1000になるまで、つまり、1000個のloop
がコールスタックに追加されます。 - そして、
count
が1000になったところで、else
内のalert("Done")
が実行されます。 - コールスタックが空になったところでタスクキューで待機している
counter.innerHTML
達を実行していきます。 - 最後に1000が表示されてプログラムは終了します。
プログラム2-2の解答
では、プログラム2-2の解答です。
// ...省略
function loop() {
if (count++ < times) {
counter.innerHTML = count;
setTimeout(loop, 0); // setTimeoutを追加
} else {
alert("Done");
}
}
setTimeout
のコールバックとしてloop
を渡していますね。
では、何故これだけで上記の動きが実現できるのでしょうか。
今までは、コールスタックにloop
がどんどん追加されていき、その間にinnerHTML
がタスクキューにどんどん追加されていきました。
これは、タスクにあるloop
を全て処理仕切ったとき、つまり、count
が1000になった後、タスクキューで待機していたinnerHTML
でレンダリング処理をするという流れになります。ここはさっき説明したので、問題ないと思います。
しかし、再帰呼び出しでsetTimeout
を挟むことによってloop
はコールスタックではなく、WEB APIを経由した後、タスクキューに追加されていきます。
つまり、タスクキューには、innetHTML
→ loop
→ innetHTML
→ loop
→ ... という風に、追加されていきます。では、この部分の処理の流れを追っていきましょう。
- まず、
innerHTML
が実行されます。count
は1なので、1が画面にレンダリングされます。 -
loop
をコールスタックに追加します。count
を++します。 - コールスタックが空になりましたのでタスクキューで待機している
innerHTML
を実行します。count
は2なので、2が画面にレンダリングされます。 - これを
count
が1000になるまで実行します。
という風に、setTimeout
を使用することで、loopの間にinnerHTMLによるレンダリング処理を追加することができます。これで、2枚目の動きが実現できる訳ですね。
スタックオーバーフロー
このコールスタックは、無限に関数を積み重ねることはできません。何事にも限界はあります。コールスタックの許容量を超えてしまうと、スタックオーバーフロー(StackOverflow)
というエラーが発生します。これは関数の再帰的な実行を行うときによく出てきます。
今回話せなかったこと
- マイクロタスク(micro task)
- ウェブワーカー(Web Worker)
- setTimeoutの誤差
この3つもイベントループに関係してきますので、気になる方は勉強してみると面白いですよ。
僕も機会があればまた記事を書こうと思います。