Open7

JavaScriptランタイムを完全に理解したい

msy.msy.

JavaScriptの並行モデルとイベントループについて理解する

JavaScriptは"イベントループ(event loop)"に基づく同時実行モデルを持つ。
どうやら、JavaScriptのランタイム(実行環境)に使用されるJavaScriptエンジンは視覚的に表示すると下記のようになっているらしい。

上記の画像を見てみると、多くいくわけて三つのエリアに分かれていることに気づく。

  1. スタック(stack
  2. ヒープ(heap
  3. キュー(queue

※1, 2はJavaScriptエンジンが持つ領域だが、3についてはJavaScriptエンジンの外側にある。
マジでどこにあるかはわからない。多分、実行環境の中にあって、JavaScriptエンジンの外にあるって認識で合ってると思う。

スタック(stack

下記の関数barを呼び出すと、関数barの引数とローカル変数を含んだ最初のフレーム(frame)が生成される。

関数barが内部で関数fooを呼び出すと、関数fooの引数とローカル変数を含んだ2番目のフレーム(frame)が生成され、最初のフレームのにプッシュされていく。

関数fooから値が返ると、先頭のフレーム(frame)要素はスタックからポップされていく。
(barのコールフレームのみが残ります)。

barから返るときスタックは空になる。
(先入れ、後出しかな)

→おそらくstackからframeを取り出す際は、最新のframeから取り出される。

要するに、以下のようなネストされた関数の場合、関数barのなかで呼び出されている関数fooが最初に返され完了し、その次に関数barが返され完了する。

function foo(b){
  var a = 10;
  return a + b + 11;
}

function bar(x){
  var y = 3;
  return foo(x * y);
}

console.log(bar(7)); // returns 42

ヒープ

ヒープには主にオブジェクトが割り当てられている。
ヒープは、メモリの大規模で大部分は構造化されていない領域を意味する名前らしい。

msy.msy.

JavaScriptはブラウザでどのように動くのか

JavaScriptの実行環境(ランタイム)には主要な以下の4つのコンポーネントがある。

  1. JavaScriptエンジン
  2. イベントループ
  3. web APIs
  4. コールバックキュー

上記で挙げたもの以外にもコンポーネントがある。

例えば......

ブラウザエンジン: ブラウザの UI を管理する
レンダリングエンジン: HTML と CSS を解析し、画面に描画する
ネットワーキング: 通信周りの機能を提供する
データストレージ: Cookie 等の各種ストレージ機能を提供する

ブラウザの種類ごとの使用コンポーネントの比較

各種ブラウザは独自のコンポーネントで構成されている。

Node.jsの実行環境

Node.jsはJavaScriptエンジンにv8を使って作られたJavaScriptの実行環境でイベントループにlibuvを使用し、コアモジュールとして Web APIs の一部機能(例: Console API、Promise等)を提供することで、ブラウザと類似した実行環境となった。

JavaScriptの処理(イベントループ)の全体像

ブラウザの実行環境でJavaScriptが処理される際に、以下の処理が行われる。

  1. ブラウザ上で読み込まれたJavaScriptがJavaScriptエンジンのコールスタック上で実行される。
  2. コールスタック上で実行中に非同期関数が呼び出されると、非同期関数の引数として渡されたコールバック関数は Web APIs に送られる
  3. Web APIs に送られたコールバック関数は、条件を満たすまでは Web APIs で待機する
  4. Web APIs で待機しているコールバック関数は、条件を満たすとコールバックキューに追加される
  5. コールバックキューに追加されたコールバック関数は、コールスタックで実行中の関数が空になるまで待機する
  6. コールスタックが空になると、コールバックキューで待機しているコールバック関数は、イベントループによってコールスタックに追加される
  7. コールスタックに追加されたコールバック関数が実行される

ポイント

JavaScriptの実行環境のなかでJavaScriptエンジンやWeb APIs、タスクキューなどのコンポーネントがイベントループによって密に関係している

この感覚が大事な気がする。

msy.msy.

※上のスクラップの続き

JavaScript Engine

JavaScriptエンジンとは、JavaScriptのコードを解析し、マシンコードにコンパイルし、そして実行するプログラムのこと。
スタック領域とヒープ領域の二つのメモリ領域を持つ。

スタック領域

JavaScriptエンジンはコンパイル時に、必要なメモリ領域のサイズが決まる値をスタック領域と呼ばれる領域に割り当てる。これを別名静的割り当てと呼ぶ。

コンパイル時にサイズが決まるデータ型には、プリミティブ型(String, Number, Boolean, Undefined, Null)、オブジェクトや関数の参照がある。
※スタック領域に割り当てられるのは、あくまでオブジェクトや関数の参照であるという点に注意

スタック領域のデータ構造はスタックであるため、LIFO (Last In First Out: 後入れ先出し)方式で処理される。

スタック構造のイメージ

ヒープ領域

JavaScriptエンジンは実行時に必要なメモリ領域のサイズが決まる値(動的なデータ)をヒープ領域と呼ばれる領域に割り当てる。これを別名動的割り当てと呼ぶ。

実行時にメモリ領域のサイズが決まるデータ型には、オブジェクト、関数、配列等がある。ヒープ領域のデータ構造は、スタック領域のデータ構造と異なり、構造化されていない。

メモリ割り当て

JavaSccriptエンジンのスタック領域にあるデータのメモリ領域は変わらないため、JavaScriptエンジンは各データに対して固定のメモリサイズを割り当てる。

一方でヒープ領域では、データのメモリ領域のサイズが可変なため、固定のメモリサイズを割り当てることはなく、必要に応じた多めのメモリサイズを割り当てる

オブジェクトや関数については、その実体がヒープ領域に割り当てられ(動的割り当て)、実体の参照がスタック領域に割り当てられる(静的割り当て)

JavaScriptのコードとスタック領域とヒープ領域との関係

※スタック領域は後入れ先出しになっている。

コールスタック

コールスタックとは、JavaScriptのランタイムで実行されている処理の履歴を格納する仕組みで、「現在どの関数が実行されていて、その関数の中でどの関数が呼び出されたかをスタックのデータ構造で記録」する。

コールスタックも、JavaScriptエンジンが持つスタック領域と同様にデータ構造がスタックであるためLIFO(後入れ先出し)になっている。

コールスタックには関数が追加・格納されていく。
コールスタックに追加することをpush、コールスタックから取り出すことをpopと呼ぶ。

Web APIs

Web APIsはブラウザによって提供されているウェブのAPI群と、その実行環境を指す。
Web APIsで提供されている非同期関数を呼び出すと、そのコールバック関数がWeb APIs Containerに追加され、条件を満たすまで待機する。

このコールバック関数は待機している時点では実行されることはなく、条件を満たした時にコールバックキューに追加され、JavaScriptエンジンが実行可能(コールスタックに空きが出たら?)になったタイミングで、JavaScriptエンジンに渡され実行される。

【Web APIsの例】

  • console
  • setInterval, setTimeout
  • Promise
  • Fetch
  • XMLHttpRequest
  • DOM
  • Mouse Event

非同期処理の実行

setTimeout関数を例に挙げる。

setTimeout(function callback() {
  console.log('fire after 1000ms.');
}, 1000);

上記のコードを実行したタイミングで、まずコールスタックにsetTimeout関数が追加され、実行される。
この時点で、第一引数に渡したコールバック関数は実行されていなくて、Web APIs Containerに追加され、条件(今回だと一秒間待つ)を満たすまで待機させられる。

1秒が経過すると、このコールバック関数は、コールバックキューに追加される。

コールバックキューに追加された関数はコールスタックの空きを見て、随時コールスタックに追加されて(JavaScriptエンジンに渡されて)実行されていく。

このsetTimeout関数のコールバック関数はJavaScriptエンジンのコールスタックに先に追加された関数の実行を待つため、厳密には1秒後に実行されることは保証されない。

このことから、setTimeout 関数は第2引数で指定したミリ秒後に第1引数で渡したコールバック関数をコールバックキューに追加する関数と言える。

msy.msy.

上のスクラップの続き

コールバックキュー

JavaScriptエンジンでは、同時に一つの処理しか実行できない。いわゆるシングルスレッド。
JavaScriptエンジンが何らかの処理をしている間に、次に処理されるものが待機する場所こそがコールバックキューである。

コールバックキューのデータ構造は FIFO (First In, First Out)方式で、コールバックキューに格納した順に取り出しが行なわれる。

ブラウザの実行環境では、コールバックキューにタスクキューマイクロタスクキューとよばれる二種類のコールバックキューがある。
※Node.js ではこのコンポーネントをイベントキューと言い、仕組みとしては似ているが、コールバックキューと完全に同じではない

これは、キューに渡されたコールバック関数がタスクなのかマイクロタスクなのかによって、どちらのキュー渡されるか別れる。

(マクロ)タスク

タスクは、次の種類のコールバック関数を指し、タスクキューに格納される。

  • script タグで読み込んだ JavaScript ファイル
  • setTimeout / setInterval のコールバック関数
  • UI イベント(クリック、マウス移動等)のコールバック関数
  • requestAnimationFrame のコールバック関数

タスクは、マクロタスク(Macrotask)と呼ばれることもある。

マイクロタスク

マイクロタスクは、次の種類のコールバック関数を指し、マイクロタスクキューに格納される。

  • Promise の then / catch / finally のコールバック関数
  • queueMicrotask のコールバック関数
  • MutationObserver のコールバック関数

マイクロタスクはジョブ(Job)、マイクロタスクキューはジョブキュー(Job queue)と呼ばれることもある。

タスクキューとマイクロタスクキューの違い

タスクキューとマイクロタスクキューの違いは、JavaScript の1つの処理サイクル内で優先して実行されるかどうかである。

具体的には、「1つの処理サイクル内で、1つのタスクキューが実行された後、マイクロタスクキューにあるマイクロタスクは、空になるまで全て実行」される。

タスクキューとマイクロタスクキューで実行された結果を受けて、最後にレンダーキューの描画タスクが実行されます。

マイクロタスクキューは格納されている全てのコールバック関数が終わるまで処理が行われるため、Promise.then 等の連結された大量の処理があると、それらのタスクの処理が終わるまで他のタスクの実行が行われなくなる

→ レンダーキューによる描画の実行が遅くなる??????
そういうことじゃなさそう(Reactで試したけど描画はthenの処理の完了前に行われた)
renderが同期関数だからか?

msy.msy.

※上のスクラップの続き

イベントループ(Event loop)

イベントループは、コールスタックが空になるたびにコールバックキューからタスク(コールバック関数)を取り出しJavaScriptエンジンのコールスタックに追加していく。

  • コールバックキューに実行待ちの関数があるかどうか
  • コールスタックに実行中の関数がないかどうか

イベントループはこの2つの条件が満たされるのを待ち、満たされた時にコールバックキューの実行待ち関数をコールスタックに追加するという処理を無限に繰り返す。

msy.msy.

JavaScriptの処理のまとめ

最後にまとめると、JavaScript エンジンは、コールスタックとメモリ領域という2つの概念の理解が必要になる。

JavaScriptが評価(解析?)され、メモリ上に展開され、コールスタックで実行される。

Web APIs から提供されている APIを呼び出すと、Web APIsの実行環境で処理が実行される。
その時に非同期関数の呼び出しの場合、Web APIs の実行環境内で、条件を満たすまで待機する。

そして、条件を満たすと、コールバックキュー内のタスクキュー、もしくはマイクロタスクキューのいずれかのキューに格納される。

コールバックキューに格納されたタスクはコールスタックで処理可能になるまで待機する。

コールスタックが処理中かどうかはイベントループによって監視され、コールスタックが空になるとキューで待機している先頭のタスクから取り出され、コールスタックで実行される。