普段JavaScriptを使用している皆さんは「どの順序で処理が実行されているんだろうか」と疑問を持ったことはありませんか?
そのためにはイベントループという仕組みの理解が重要になってきます。
実際に調べてみるとJavaScriptに対する理解がより深まったので、アウトプットとして本記事を執筆しました。
イベントループとは
そもそもイベントループとは何でしょうか。
イベントループを一言でいうと、
「シングルスレッドで効率的にタスクを処理する仕組み」 になります。
イベントループ自体はJavaScriptのランタイム環境が提供する仕組みです。例としてブラウザやNode.jsが挙げられます。
イベントループの構成要素
ではどのようにイベントループが成り立っているのでしょうか。概略図からみていきましょう。
┌──────────────────────┐
│ JavaScriptエンジン(V8など) │
│ ┌──────────┐ │
│ │ Call Stack │ │ ← 関数実行の積み上げ(同期処理)
│ └──────────┘ │
└──────────────────────┘
▲
│ (コールスタックが空なら次のタスクを取る)
▼
┌──────────────────────┐
│ Event Loop │ ← 仕組み(キューを監視して処理を管理)
└──────────────────────┘
▲
│ (優先度順に処理)
───────────┬────────────────────────
▼
┌──────────────────────┐
│ マイクロタスクキュー │ ← 優先度高(Promise.then など)
└──────────────────────┘
▲
│(マイクロタスクがすべて終わったらタスクキューへ)
▼
┌──────────────────────┐
│ タスクキュー(Callback Queue) │ ← setTimeout, fetch など
└──────────────────────┘
ここでの登場人物は以下になります。それぞれ解説していきましょう。
- コールスタック
- マイクロタスクキュー
- タスクキュー
コールスタック
JavaScriptの関数呼び出しを管理するデータ構造になります。
原則はLIFO(Last In, First Out)です。処理が積み上がっていき、上から処理されていくイメージですね。
ここでクイズです。
以下のコードは単純な同期関数です。 出力の順番はどうなるでしょうか?
function A() {
console.log('A');
}
function B() {
console.log('B');
}
A();
B();
正解はこちら👈
A -> B になります。
では次はどうでしょうか?
function A() {
console.log('before A');
B();
console.log('after A');
}
function B() {
console.log('before B');
C();
console.log('after B');
}
function C() {
console.log('C');
}
A();
正解はこちら👈
before A -> before B -> C -> after B -> after A になります。正解できましたか?
ここで第二問目が難しく感じたら、ぜひコールスタックの原則を思い出していただきたいです。LIFO、つまり最後に入ったやつから順番に処理していくという意味です。
実行順序を確認してみましょう。
ステップ | 処理 | 出力 |
---|---|---|
1 |
A() を呼び出し |
|
2 |
console.log('before A'); を実行 |
before A |
3 |
B() を呼び出し |
|
4 |
console.log('before B'); を実行 |
before B |
5 |
C() を呼び出し |
|
6 |
console.log('C'); を実行 |
C |
7 |
C() 実行完了 → B() に戻る |
|
8 |
console.log('after B'); を実行 |
after B |
9 |
B() 実行完了 → A() に戻る |
|
10 |
console.log('after A'); を実行 |
after A |
11 |
A() 実行完了 |
マイクロタスクキュー
イベントループ において優先的に処理される非同期タスクのキューです。
主に以下のものがマイクロタスクキューになります。
- Promise の .then() / .catch() / .finally()
- queueMicrotask()
- MutationObserver
例の如く、クイズ形式で理解していきましょう。
以下のコードで、出力順番はどうなるでしょうか?
console.log('A');
Promise.resolve()
.then(() => {
console.log('B');
})
.then(() => {
console.log('C');
});
console.log('D');
正解はこちら👈
A -> D -> B -> C になります。
さて、🧐となった方がいるかもしれません。
Promise
を使用していますが、その中のthen
関数のコールバック関数がマイクロタスクキューに登録されます。 一度同期関数を実行されたのち、優先的にコールスタックに移動しマイクロタスクキューが実行されます。
ステップ | 処理 | 出力 |
---|---|---|
1 |
console.log('A'); を実行 |
A |
2 |
Promise.resolve() を作成 |
|
3 |
.then(() => { console.log('B'); }) を登録 |
|
4 |
.then(() => { console.log('C'); }) を登録 |
|
5 |
console.log('D'); を実行 |
D |
6 | マイクロタスクキューから console.log('B'); を実行 |
B |
7 | マイクロタスクキューから console.log('C'); を実行 |
C |
タスクキュー
実行待ちのタスク(処理)を順番に管理するキューです。
主に以下のものがタスクキューになります。
- setTimeout / setInterval
- setImmediate(Node.js)
- I/O 処理(ファイル読み込み、ネットワーク通信など)
- requestAnimationFrame
例の如く...
以下のコードで、出力順番はどうなるでしょうか?
console.log('A');
setTimeout(() => {
console.log('B');
}, 0);
console.log('C');
正解はこちら👈
A -> C -> B になります。
setTimeout
がタスクキューに登録され、同期処理が行われた後にコールスタックに移動し処理が実行されます。
より深く知るために
イベントループの構成がわかったところで、より理解を深めるために以下の場合を考えてみましょう。
マイクロタスクキューとタスクキュー、どっちが早く処理されるのか
いまいち二つの違いがわからないなぁ、って思いましたか?ごもっともです。
では以下の例を見てみましょう。マイクロタスクキューとタスクキュー、両方を入れてみました。
実行順番はどうなるでしょうか?
console.log('A');
setTimeout(() => {
console.log('B');
}, 0);
Promise.resolve()
.then(() => {
console.log('C');
});
console.log('D');
正解はこちら👈
A -> D -> C -> B になります。
マイクロタスクキューの説明で、優先的に、というワードを出しました。まさにそこが違いです。
二つともキューに入るわけですが、コールスタックの同期処理が終わった後の順番には決まりがあり、優先されるのはマイクロタスクキューなのです。
ですので先に C が出力され、最後に B が出力されたというわけです。
async / await はどのような挙動をするのか
JavaScriptには async / await は非同期処理の構文です。
以下のコードをみてください。実行順番はどうなるでしょうか?
console.log('A');
async function example() {
console.log('B');
await Promise.resolve();
console.log('C');
}
example();
console.log('D');
正解はこちら👈
A -> B -> D -> C になります。
ステップ | 処理 | 出力 |
---|---|---|
1 |
console.log('A'); を実行 |
A |
2 |
example() を呼び出し |
|
3 |
console.log('B'); を実行 |
B |
4 |
await Promise.resolve(); を実行(ここで一時停止) |
|
5 |
console.log('D'); を実行 |
D |
6 | マイクロタスクキューから console.log('C'); を実行 |
C |
この理解をするためには await を理解しなければなりません。
- await は Promise.then() に変換 される。
- await によって その関数(async 関数内の処理)だけが一時停止 し、他の同期コードが先に実行される。
- await の後にあるコードは マイクロタスクキューに追加され、現在の同期コードが終わった後に実行される。
そうです、実はawait
はPromise.then()
に変換されます。
Promise.resolve().then(() => {
console.log('C');
});
それによりawait以降の処理はマイクロタスクキューに登録され、同期処理が終わった後に実行されるのです。
応用問題にチャレンジ
もっと理解を深めるために難しい問題にチャレンジしてみましょう。
以下の実行順がわかるでしょうか?
console.log('A');
setTimeout(() => {
console.log('B');
}, 0);
Promise.resolve().then(() => {
console.log('C');
}).then(() => {
setTimeout(() => {
console.log('D');
}, 0);
});
console.log('E');
正解はこちら👈
A -> E -> C -> B -> D になります。
まとめ
いかがでしょうか。
普段からあまり意識しない仕組みだからこそ、理解することでより深い知見が得られます。
この記事を通して、JavaScriptのイベントループについて少しでも理解が深まれば幸いです。
参考サイト
GoQSystemでは一緒に働いてくれる仲間を募集中です!
ご興味がある方は以下リンクよりご確認ください。