動画で分かる!ブラウザと Node.js の Event Loop 解説
Event Loopってみなさん聞いたことありますか?
「うん、なんか聞いたことありますが、よくわからないな・・・」や「分かりそうで、分からないな・・・」って思ってる方がいますかね?
長文ではありますが、Event Loop というものを改めて理解してみましましょう!
まずこのコードの実行結果を考えてみましょう。
console.log('1')
setTimeout(function callback(){
console.log('2')
}, 1000)
new Promise((resolve, reject) => {
console.log('3')
resolve()
})
.then(res => {
console.log('4');
})
console.log('5')
答えを分かりますでしょうか?正解はこの後記事内で公開します。
JavaScript 実行の仕組み
JavaScriptを学び始めた頃、「JavaScriptはシングルスレッドである」という言葉を聞いたことがありますでしょうか?(Web Workersもありますが、この記事では触りません。)
シングルスレッドとはJSコードが上から下へ順に実行されて、前の行が終了したときに次の行が実行されるのです。
そこでこのコードを見ましょう。
console.log('1')
setTimeout(function (){
console.log('2')
}, 1000)
console.log('3')
/* 実行結果:
1
3
2
*/
あれ、JSはシングルスレッドで、1行ずつコードを実行するんじゃなかったっけ? なぜこのコードは3がプリントされ、次に2がプリントされるのですか?
みなさんもご存じだと思いますが、ここで非同期の概念を簡単に話しましょう。
非同期とは、コードをブロックするのではなく、非同期コードの実行のために別のスペースを開け、残りの同期コードは普通に実行します。非同期コードの中に他のコードがあれば、後の「ある時点」で非同期コードの中の他のコードを実行することです。
例えば、上記のコードの setTimeout 関数は非同期ですが、その内部には同期的なコードがあります console.log('2')
ここで述べた「ある時点」が、この後の記事の重要ポイントとなるので、ここではちょっと略します。
非同期コード実行のための別のスペースはどこから来るのだろう? そのスペースはJSの実行環境が提供しています。JSの実行環境は主にブラウザとNodeの2つがあり、この2つの実行環境をベースにJSの実行の仕組みを解説していきます。
ブラウザ内の JavaScript
ブラウザでJSが動作できるのは、すべてのブラウザが JavaScript エンジン(Chromeの v8 エンジンなど)を提供し、JSの実行環境を提供しているからです。
JavaScriptエンジンはざっくりこんな感じです:

ブラウザ上のJSエンジン
左側はヒープ領域で、ブラウザがコードを実行するためのメモリを確保するために使用します。右側はコールスタックで、JSコードの一部が実行されるたびにコードをコールスタックに押し込み、実行が終了したらスタックから取り出します。
コールスタックをメインに説明します。
コールスタック
コールスタックとは何ですか? 以下のコードを用いてコールスタックの動作を分析してみましょう。
function multiply(a, b) {
return a * b
}
function calculate(n) {
return multiply(n, n)
}
function print(n) {
let res = calculate(n)
console.log(res)
}
print(5)
このコードをブラウザで実行しますと、まず3つの関数 multiply・square・printが定義されます。その次に、print(5)というコードが実行されますが、ほかの関数を依存していますので、square 関数と multiply 関数が順番に呼ばれます。
では、このコードの実行中にコールスタックの内部で何が起こっているのかを見てみましょう。

ここで、コールスタックの存在とその内容を確認するもう一つの方法として、次のようなコードを書きます:
function fn() {
throw new Error('I am an error!')
}
function foo() {
fn()
}
function main() {
foo()
}
main()
ブラウザ側で実行してみると、こんな結果が出てきます:

コードの実行中にエラーが投げられると、ブラウザはコールスタックの内容をすべて出力します。この時のコールスタックはこんな感じです:

上記の処理にはすべて同期コードです。ではどうやって新しいスペースを開いて、非同期コード走らせるのでしょうか?
ここで、Event Loopの概念を紹介します。
Event Loop
Event Loopはイベントループと訳されていますが、一体何のイベントがループしていますか? ブラウザのイベントループはこんな感じ:

ブラウザの各種 Web API は、非同期コード用に独立した実行空間を提供します。 非同期コードの実行が終了すると、callback(コールバック)が Task Queue (タスクキュー)に送られ、Call Stack(コールスタック)が空になると、キュー内のcallback関数がCall Stackに押されて実行されます。
このプロセスがイベントループです。
簡単な例を使って、イベントループのイメージを掴んでみましょう:
console.log('1')
setTimeout(function callback(){
console.log('2')
}, 1000)
console.log('3')
このコードの実行順を見てみましょう:

Macrotasks(マクロタスクス) と Microtasks(マイクロタスクス)
Event Loop を簡単に説明したので、先の問題をもう一回考えてみましょう。
console.log('1')
setTimeout(function callback(){
console.log('2')
}, 1000)
new Promise((resolve, reject) => {
console.log('3')
resolve()
})
.then(res => {
console.log('4');
})
console.log('5')
// 実行結果は何でしょう?
答えは
1
3
5
4
2
になります!いかがでしょうか?
ここでまた疑問を持ってますか?promiseもsetTimeoutも非同期タスクですが、なぜpromiseが優先に実行されますか?
ここでmacrotask(マクロタスク)とmicrotask(マイクロタスク)というものが登場します。
| 種類 | 例 |
|---|---|
| macrotask | setTimeout・setInterval・UI rendering |
| microtask | promise・requestAnimationFrame |
そして、macrotaskとmicrotaskの両方がタスクキューにある場合、microtaskの方がmacrotaskよりも優先度が高いです。つまり、microtaskが先に実行され、その後、macrotaskが実行されると規定されています。
そのため、上記のコードは、4が出力され、次に2が出力されます。
macrotaskとmicrotaskが区別されているので、それらを保持するキューも、図のように、macrotask queueとmicrotask queueの2種類に分けられています。

ルールとして、コールスタックが空の場合、これら2つのキューの実行順は以下の通り:
-
microtask queueが空かどうかを確認し、キュー空でなければ、microtaskを一件取り出してCall Stackで実行されます。そしてステップ1をもう一度実行します。microtask queueが空になったら、ステップ2を実行する -
microtask queueが空かどうかを確認し、キューが空いていれば、ステップ1を実行します。キューが空でなければmacrotaskを一件取り出してCall Stackで実行されます。そしてステップ1を実行します。 - 循環する
フローチャットにするとこんな感じ:

それでは先の問題の実行を見てみましょう:
(長いのでGIFではなく動画にしました)
いかがですか?先の問題を理解できたでしょうか?
Node.js 環境の JavaScript
Node.js はブラウザ以外の場所(サーバー)に JavaScript コードを実行させるための環境です。Node.js 環境の Event Loop はブラウザ側実装と若干違います。Node.js の方はさらにプロセスを細分化しています。ちなみに、Node.js の Event Loop は libuv を基づいて実装されています。
Node.js 環境の Event Loop
Node.js が起動すると、Event Loop を初期化し、提供された入力スクリプトを処理します。
Node.js 環境の Event Loop のプロセスはこんな感じ:

各ボックスは、Event Loop の「フェーズ」と呼ぶことにします。
-
timers:
setTimeoutおよびsetIntervalによってスケジュールされたコールバックを実行します。 - pending callbacks: TCPエラーなどシステム操作のためのコールバックを実行します。
- idle,prepare: Node.js 内部でのみ使用されます。
-
poll: 新しいI/Oイベントを取得し、I/O関連のコールバック(
close callbacks、timersでスケジュールされたもの、setImmediateを除くほとんどすべて)を実行します。 -
check:
setImmediateのコールバックはここで実行されます。 -
close callbacks:
socket.on('close', ...)のようなcloseコールバックを実行します。
各フェーズには、実行するコールバックのFIFOキューがあります。各フェーズの動作はそれぞれですが、一般に、Event Loop があるフェーズに入ると、そのフェーズの固有の操作を実行した後、キューがなくなるかコールバックの最大数が実行されるまでそのフェーズのキューにあるコールバックを実行します。キューが空になるもしくはコールバックの最大数に達すると、Event Loop は次のフェーズに移動して、以上の行動を繰り返します。
上記の6つのフェーズのうち、timers・poll・check・close callbacksの4つだけに注目します。
この4つのフェーズにはそれぞれの macrotask queue があり、このステージのmacrotask queueにあるタスクがなくなるかコールバックの最大数が実行されるまでコールバックを実行したあと、次のフェーズに進めます。実行中は常に microtask queue に保留タスクがないかチェックし、存在すれば microtask queue 内のタスクを実行し、microtask queue が空になれば macrotask queue 内のタスクを実行する(これはブラウザの動作と非常に似ているが、Node 11.x以前はそうではなく、現在のフェーズのキュー内のmacrotaskがすべて実行されてからmicrotask queueがチェックされていた。 11.x以降のバージョンについては、その旨を記載した文章をウェブサイトで見つけていませんが、何度も実行した結果、暫定的にそうだと言えるので、見つけた方はコメントをいただけると幸いです。)
そのため、Node.js もmacrotaskとmicrotaskの概念が存在します。
| 種類 | 例 |
|---|---|
| macrotask | setTimeout・setInterval・setImmediate |
| microtask | promise・process.nextTick |
ご覧のように、Node.jsではマクロタスクsetImmediateとマイクロタスクprocess.nextTickという2つのタスクが追加されています。
setImmediate は check フェーズで処理されます。
process.nextTick は Node.js の特殊なマイクロタスクで、next tick queue と呼ばれる別のキューが提供され、他のマイクロタスクよりも高い優先度を持ちます。つまり process.nextTick と promise が両方存在すれば、前者が先に実行されることになります。
要するに、Node.jsでは、図のようにイベントループ内に4つのmacrotask queueと2つのmicrotask queueが存在します。

水色は microtask queue、紫色は macrotask queue
以上の説明を基づいて、もう1問見てみましょう:
setTimeout(() => {
console.log(1);
}, 0)
setImmediate(() => {
console.log(2);
})
new Promise(resolve => {
console.log(3);
resolve()
console.log(4);
})
.then(() => {
console.log(5);
})
console.log(6);
process.nextTick(() => {
console.log(7);
})
console.log(8);
/* 出力:
3
4
6
8
7
5
1
2
*/
同期コードが最初に実行されるので、3 4 6 8 が懸念なく先に出力されます。
非同期コードを見ると、setTimeoutがtimers queueに、setImmediateがcheck queueに、then()がother microtask queueに、process.nextTickがnext tick queueにそれぞれ押し込まれます。
上のプロセスのように、microtask queueにタスクが存在するので、先にチェックされます。その上、説明した通りnext tick queueの優先度がother microtask queueより高いので、7が出力されて、その次に5が出力されます。
microtask queueが空になったので、macrotask queueを順番にチェックします。最初はtimersフェーズに入り、1が出力されます。timers queueが空になったので、次のpollフェーズに入ります。そしてpoll queueが空なので、checkフェーズに入って、2が出力されます。
まとめ
ブラウザ環境とNode.js環境の Event Loop をそれぞれ解説してみましたが、いかがでしたか?
同期コードが順番に実行され、非同期コードはmicrotask->macrotaskの順番で実行されます。
面接に聞かれてうまく解けなかったのでちゃんと学んでみました・・・
実際の業務中にはあんまり触れない話ですが、学んでしてみても損はないと思います。
参考
Discussion