🧐

動画で分かる!ブラウザと Node.js の Event Loop 解説

2022/08/29に公開

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つの関数 multiplysquareprintが定義されます。その次に、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

になります!いかがでしょうか?

ここでまた疑問を持ってますか?promisesetTimeoutも非同期タスクですが、なぜpromiseが優先に実行されますか?
ここでmacrotask(マクロタスク)とmicrotask(マイクロタスク)というものが登場します。

種類
macrotask setTimeout・setInterval・UI rendering
microtask promise・requestAnimationFrame

そして、macrotaskmicrotaskの両方がタスクキューにある場合、microtaskの方がmacrotaskよりも優先度が高いです。つまり、microtaskが先に実行され、その後、macrotaskが実行されると規定されています。

そのため、上記のコードは、4が出力され、次に2が出力されます。

macrotaskmicrotaskが区別されているので、それらを保持するキューも、図のように、macrotask queuemicrotask queueの2種類に分けられています。

ルールとして、コールスタックが空の場合、これら2つのキューの実行順は以下の通り:

  1. microtask queueが空かどうかを確認し、キュー空でなければ、microtaskを一件取り出してCall Stackで実行されます。そしてステップ1をもう一度実行します。microtask queueが空になったら、ステップ2を実行する
  2. microtask queueが空かどうかを確認し、キューが空いていれば、ステップ1を実行します。キューが空でなければmacrotaskを一件取り出してCall Stackで実行されます。そしてステップ1を実行します。
  3. 循環する

フローチャットにするとこんな感じ:

それでは先の問題の実行を見てみましょう:
(長いのでGIFではなく動画にしました)

https://youtu.be/v-nkgloafIQ
いかがですか?先の問題を理解できたでしょうか?

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 callbackstimersでスケジュールされたもの、setImmediate を除くほとんどすべて)を実行します。
  • check: setImmediate のコールバックはここで実行されます。
  • close callbacks: socket.on('close', ...) のようなcloseコールバックを実行します。

各フェーズには、実行するコールバックのFIFOキューがあります。各フェーズの動作はそれぞれですが、一般に、Event Loop があるフェーズに入ると、そのフェーズの固有の操作を実行した後、キューがなくなるかコールバックの最大数が実行されるまでそのフェーズのキューにあるコールバックを実行します。キューが空になるもしくはコールバックの最大数に達すると、Event Loop は次のフェーズに移動して、以上の行動を繰り返します。

上記の6つのフェーズのうち、timerspollcheckclose 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 もmacrotaskmicrotaskの概念が存在します。

種類
macrotask setTimeout・setInterval・setImmediate
microtask promise・process.nextTick

ご覧のように、Node.jsではマクロタスクsetImmediateとマイクロタスクprocess.nextTickという2つのタスクが追加されています。

setImmediatecheck フェーズで処理されます。
process.nextTick は Node.js の特殊なマイクロタスクで、next tick queue と呼ばれる別のキューが提供され、他のマイクロタスクよりも高い優先度を持ちます。つまり process.nextTickpromise が両方存在すれば、前者が先に実行されることになります。

要するに、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 が懸念なく先に出力されます。
非同期コードを見ると、setTimeouttimers queueに、setImmediatecheck queueに、then()other microtask queueに、process.nextTicknext 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の順番で実行されます。
面接に聞かれてうまく解けなかったのでちゃんと学んでみました・・・
実際の業務中にはあんまり触れない話ですが、学んでしてみても損はないと思います。

参考

https://developer.mozilla.org/ja/docs/Web/JavaScript/EventLoop
https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/
https://dev.to/lydiahallie/javascript-visualized-event-loop-3dif
https://mp.weixin.qq.com/s/dFFkkozsQKzESyF_M_mchA

Discussion