ここ最近 Promise についていろいろと調べていて、多くのサイトで Promise についての説明がされていたのですが、javascript のイベントループの仕組みについての解釈がそれぞれ微妙に違うものが多かったので自分なりにまとめてみました。
※ あくまでも自分なりの理解で書いた記事になります。
※ プラットフォームはNode.jsです。
イベントループとは
皆さまもご存じの通り、javascript はシングルスレッドです。
従来、多くのスレッドを必要としていた処理を、1つのスレッド上にタスクを賢く多重化させることで、1つのスレッドで処理を行うスレッドモデルが「イベントループ」でありイベントループモデルです。
対義的なものはマルチスレッド、マルチスレッドモデルです。
イベントループの仕組み
以下は javascript によるシュミレートです。
※ 以下オライリー本より抜粋したものに多少文面を加えています。
- JavaScript のメインスレッドは、XMLHTTPRequest、setTimeout、readFile などのネイティブな非同期 API を呼び出します。これらの API は、JavaScript プラットフォームによって提供されます。
- ネイティブな非同期 API を呼び出すと、(XMLHTTPRequest、setTimeout、readFileなどの非同期処理は JavaScript プラットフォーム側で行われるので)すぐに制御がメインスレッドに戻り、あたかもその API が呼び出されなかったかのように実行が継続されます。
- 非同期操作が完了すると、JavaScript プラットフォーム( Node.j s)は、自身のイベントキュー(タスクキュー)の中にタスク(コールバック)を格納します。それぞれのスレッドは独自のキューを持ち、それは、非同期操作の結果をメインスレッドに送り返すために使われます。
- メインスレッドのコールスタック(呼び出しスタック)が空になると、プラットフォームは自身のイベントキュー(タスクキュー)をチェックして、保留中のタスク(コールバック)を探します。待機しているタスクが存在していれば、プラットフォーム( Node.js )はそれを実行します。それによって関数の呼び出しがトリガーされ、そのメインスレッド関数に制御が戻ります。その関数呼び出しの結果であるコールスタックが再び空になると、プラットフォームは、再びイベントキューをチェックして、準備のできているタスクを探します。このようなループが、コールスタックとイベントキューの両方が空になるまで、およびすべてのネイティブな非同期 API 呼び出しが完了するまで、繰り返されます。
赤字の「それぞれのスレッドは独自のキューを持ち」の「それぞれのスレッド」についてですが、Node.js 自体はマルチスレッドで動作するようなので、JSメインスレッドから渡された複数のタスクは、プラットフォームではそれぞれのタスクのスレッドで処理されるのだと理解しています。
以下は他サイトの抜粋です。
Node.jsはシングルスレッドに設計されています。実用的な目的のために、Nodeで起動するアプリケーションは1つのスレッドで実行されます。しかし、Node.js自体はマルチスレッドで動作します。 I / O操作などはスレッドプールから実行されます。さらに、ノードアプリケーションのインスタンスはすべて別のスレッド上で実行されるため、マルチスレッドアプリケーションを実行するために複数のインスタンスが起動されます。
Node.js自体はマルチスレッド化されていますが(I/Oなどの操作はスレッドプールから実行されます)、Node.jsによって実行されるJavaScriptコードは、すべての実用的な目的のために、単一のスレッドで実行されます。これはNode.js自体の制限ではなく、V8 JavaScriptエンジンと一般的なJavaScript実装の制限です。
図を使って説明
上記のシュミレートを図を使ってより解りやすく説明してみます。
例題は他サイトの説明でもよく用いられている非同期処理でお馴染みの setTimeout 関数を用います。
function cb_0(){
console.log('cb_0!');
}
function cb_1(){
console.log('cb_1!');
}
function sam(){
console.log('sam!');
}
setTimeout(cb_0, 0);
setTimeout(cb_1, 1);
sam()
// 出力
// sam!
// cb_0!
// cb_1!
最初の setTimeout の呼び出しの第2引数が「0」なので、出力は「cb_0! → sam! → cb_1!」となるようにも思いますが、そうはなりませんでした。
それでは図を使ってなぜこの出力順になるのか、仕組みを見ていきたいと思います。
図1
まず、呼び出し順に CallStack にタスクが追加されます。
しかし CallStack では setTimeout をプラットフォームに対して呼び出すだけなので、呼び出した後は CallStack からすぐに削除されます。
ここでのプラットフォームは Node.js です。
【※ 以下オライリー本より抜粋】
- setTimeout(setTimeout(cb_0, 0))を呼び出します。これにより、ネイティブなタイムアウト API が、私たちが渡したコールバックへの参照、および引数1とともに呼び出されます。
- setTimeout(setTimeout(cb_1, 1))を再び呼び出します。これにより、ネイティブなタイムアウト API が、私たちが渡した2番目のコールバックへの参照、および引数2とともに再び呼び出されます。

図2
setTimeout 関数が呼び出されるとプラットフォームの Timers モジュールがタイマー処理をします。処理後、それぞれ渡されたコールバック関数(cb_0、cb_1)が順に TaskQueue に追加されます。

図3
CallStack では setTimeout の呼び出しタスクが削除され、sam 関数が JS スレッドで実行されるので、このタイミングで「sam!」が出力されます。
TaskQeueu にはプラットフォームからコールバック関数が追加されます。
【※以下オライリー本より抜粋】
- バックグランドでは、少なくとも0ミリ秒後に、プラットフォームが自身のタスクキューにタスクを追加し、最初の setTimeout のタイムアウト時間が経過したこと、およびそのコールバックの呼び出し準備が整っていることを示します。
- もう1ミリ秒後に、プラットフォームは、2番目の setTimeout のコールバックのための2番目のタスクをタスクキューに追加します。

図4
JSスレッドで sam 関数が実行し終わり、CallStack は空になっています。
CallStack が空になったので、TaskQueue は CallStack にコールバック(cb_0)関数を追加します。
【※以下オライリー本より抜粋】
コールスタックが空なので、プラットフォームは自身のタスクキューを調べて、その中にタスクがあるかどうかをチェックします。

図5
CallStack にコールバック(cb_0)関数が追加されたので、JS スレッドで実行されます。
このタイミングで「cb_0! 」が表示されます。
プラットフォームは自身のタスクキューを調べて、その中にタスクがあった場合はそのタスクを順にコールスタックに追加します。

図6
cb_0関数が実行し終え、CallStask が空になったのでプラットフォームは TaskQueue をチェックし、コールバック(cb_1)のタスクが見つかったので、Callstack に追加します。

図7
これで TaskQueue は空になります。
Callstack にコールバックが追加されたので JS スレッドで cb_1関数が実行されます。
このタイミングで「cb_1! 」が表示されます。

図8
cb_1関数が実行し終えたので、Callstack は空になります。
TaskQeueu と Callstack が空になると、プログラムは終了します。

まとめ
イベントループは、現在実行中のタスクがなく、Callstack が空になったとき、TaskQeueu の最初のタスクを取り出し、実行します。そして TaskQeueu も空であった場合は、イベントループは新しいタスクが TaskQeueu に追加されるまで待機することになります。
図では解り易いように別々に書きましたが、TaskQueue はプラットフォームに含まれるものと理解しています。
なので説明の文面では TaskQueue が CallStack にタスクを追加するという風に表現していますが、おそらく正しくはプラットフォームがタスクが格納されている TaskQueue をチェックし、「プラットフォームが CallStack にタスクを追加する」だと思います。
実際もっと厳密に説明するためには「libuvライブラリ」の説明なども必要になると思うの、ここではざっくり解り易く説明をしてみました。
※ この記事はいろいろなサイトの記事を参考にして書かせて頂きました。
ありがとうございました。
また、より詳しく知りたい方は以下の記事がとても解り易かったのでご参考にどうぞ。