動画で分かる!ブラウザと 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