JavaScript 実行モデル
このページでは、JavaScript ランタイム環境の基本的な仕組みについて解説します。このモデルは主に理論的かつ抽象的なものであり、特定のプラットフォームや実装に関する詳細は含まれていません。現行の JavaScript エンジンは、ここで説明する意味づけを大幅に最適化しています。
このページはリファレンスです。読者が C や Java などの他のプログラミング言語の実行モデルについてすでに理解していることを想定しています。また、オペレーティングシステムやプログラミング言語における既存の概念を多用しています。
エンジンとホスト
JavaScript を実行するには、JavaScript エンジンとホスト環境という 2 つのソフトウェアの連携が求められます。
JavaScript エンジンは、ECMAScript (JavaScript) 言語を実装し、中核となる機能を提供します。このエンジンはソースコードを受け取り、それを構文解析して実行します。しかし、意味のある出力を生成したり、外部リソースと連携したり、セキュリティやパフォーマンスに関連するメカニズムを実装したりするなど、外部とやり取りを行うためには、ホスト環境によって提供される環境固有の追加メカニズムが必要となります。例えば、ウェブブラウザー内での JavaScript の実行においては、HTML DOM がホスト環境となります。また、Node.js は、サーバーサイドで JavaScript を実行できるようにする別のホスト環境です。
このリファレンスでは主に ECMAScript で定義された仕組みに焦点を当てていますが、時折、HTML 仕様で定義された仕組みについても触れます。これらは Node.js や Deno といった他のホスト環境でも多くの場合採用されています。これにより、ウェブ上およびそれ以外の場面で使用される JavaScript の実行モデルについて、一貫した全体像をお伝えすることができます。
エージェント実行モデル
JavaScript 仕様書では、JavaScript のそれぞれの実行主体はエージェントと呼ばれ、コード実行のための機能を維持しています。
- (オブジェクトの)ヒープ: これは、メモリー内の大規模な(主に構造化されていない)領域を指すための単なる名称です。プログラム内でオブジェクトが作成されるにつれて、この領域にデータが格納されていきます。なお、共有メモリーの場合、それぞれのエージェントは自身のヒープと、自身のバージョンの
SharedArrayBufferオブジェクトを持ちますが、バッファーによって表される基盤となるメモリーは共有されています。 - (ジョブの)キュー: これは HTML の世界では(また一般的に)イベントループとして知られており、JavaScript がシングルスレッドであっても、非同期プログラミングをすることができます。一般的に先入れ先出し (FIFO) 方式であるため、キューと呼ばれています。つまり、先に追加されたジョブが、後に追加されたジョブよりも先に実行されます。
- (実行コンテキストの)スタック: これはコールスタックとして知られており、関数のような実行コンテキストへの進入や退出を通じて、制御フローを移行することができます。これは後入れ先出し (LIFO) であるため、スタックと呼ばれています。各ジョブは、(空の)スタックに新しいフレームをプッシュすることで開始され、スタックを空にすることで終了します。
これらは、それぞれ異なるデータを管理する 3 つの異なるデータ構造です。キューとスタックについては、後の章で詳しく説明します。ヒープメモリーの割り当てと解放の詳細については、メモリー管理をご覧ください。
各エージェントはスレッドに相当します(なお、その実装の基盤となるものが実際のオペレーティングシステムのスレッドであるかどうかは場合によります)。各エージェントは、互いに同期的にアクセスできる複数のレルム(これらはグローバルオブジェクトと 1 対 1 で対応します)を所有することが可能で、そのため単一の実行スレッド内で実行される必要があります。また、エージェントには単一のメモリーモデルがあり、リトルエンディアンであるかどうか、同期的にブロックされるかどうか、原子操作がロックフリーであるかどうかなどが示されます。
ウェブ上のエージェントには、次のようなものがあります。
- 類似オリジンウィンドウエージェント (Similar-origin window agent)。さまざまな
Windowオブジェクトが含まれているものであり、互いに直接的に、あるいはdocument.domainを使用して到達できる可能性があります。ウィンドウがオリジンキー付きの場合、同一オリジンのウィンドウ同士のみが相互に到達できます。 - 専用ワーカーエージェント (Dedicated worker agent)。単一の
DedicatedWorkerGlobalScopeが含まれています。 - 共有ワーカーエージェント (Shared worker agent)。単一の
SharedWorkerGlobalScopeが含まれています。 - サービスワーカーエージェント (Service worker agent)。単一の
ServiceWorkerGlobalScopeが含まれています。 - ワークレットエージェント (Worklet agent)。単一の
WorkletGlobalScopeが含まれています。
言い換えれば、それぞれのワーカーは独自のエージェントを生成しますが、1 つ以上のウィンドウが同じエージェント内に存在する場合があります。通常は、メイン文書と、それと同じオリジンを持つ iframe などが該当します。Node.js では、ワーカースレッドと呼ばれる同様の概念が利用可能です。
次の図は、エージェントの実行モデルを示しています。
レルム
それぞれのエージェントは、1 つ以上のレルム (realm) を所有しています。JavaScript コードは読み込まれた際に特定のレルムに関連付けられ、別のレルムから呼び出された場合でも、その関連付けは変わりません。レルムは以下の情報で構成されています。
ArrayやArray.prototypeなどの内在オブジェクトの一覧。- グローバル変数、
globalThisの値、グローバルオブジェクト - テンプレートリテラル配列のキャッシュ。同じタグ付きテンプレートリテラル式を評価すると、タグは常に同じ配列オブジェクトを受け取るためです。
ウェブ上では、レルムとグローバルオブジェクトは1対1で対応しています。グローバルオブジェクトは、Window、WorkerGlobalScope、WorkletGlobalScope のいずれかです。したがって、例えば、すべての iframe は、親ウィンドウと同じエージェント内にある場合でも、それぞれ異なるレルムで実行されます。
グローバルオブジェクトのアイデンティティについて言及する際、通常は「レルム」という言葉が登場します。例えば、Array.isArray() や Error.isError() といったメソッドが必要になるのは、別のレルムで構築された配列は、現在のレルムにある Array.prototype オブジェクトとは異なるプロトタイプオブジェクトを持つため、instanceof Array が誤って false を返すことになるからです。
スタックと実行コンテキスト
まず、同期的なコードの実行について考えます。それぞれのジョブは、関連付けられたコールバックを呼び出すことで実行されます。このコールバック内のコードは、変数の生成、関数の呼び出し、終了を行うことがあります。各関数は、自身の変数環境と、どこに戻るべきかを追跡する必要があります。これを処理するために、エージェントは実行コンテキストを追跡するためのスタックを必要とします。実行コンテキスト(一般にスタックフレームとも呼ばれます)は、実行の最小単位です。これでは以下の情報が追跡されます。
- コードの評価状態
- モジュールまたはスクリプト、関数(該当する場合)、このコードが含まれている現在実行中のジェネレーター
- 現在のレルム
- バインド、例えば次のもの
var、let、const、function、classなどで定義する変数#fooのように、現在のコンテキスト内でのみ有効なプライベート識別子this参照
次のコードで定義された単一のジョブからなるプログラムを想像してみてください。
function foo(b) {
const a = 10;
return a + b + 11;
}
function bar(x) {
const y = 3;
return foo(x * y);
}
const baz = bar(7); // 42 を baz に代入
- ジョブが始まると、まず最初のフレームが作成され、そこで変数
foo、bar、bazが定義されます。そして、barに引数7をつけて呼び出します。 barの呼び出しに対して 2 つ目のフレームが作成され、引数xとローカル変数yのバインドが作られます。まずx * yの乗算を実行し、その結果を用いてfooを呼び出します。fooの呼び出しに対して 3 つ目のフレームが作成され、引数bとローカル変数aのバインドを作られます。まずa + b + 11の加算演算を実行し、その結果を返します。fooから戻る際、スタックの一番上のフレーム要素がポップされ、呼び出し式foo(x * y)は返値として評価されます。その後、この結果を返すだけの処理が実行されます。barから戻る際、スタックの一番上のフレーム要素がポップされ、呼び出し式bar(7)は返値として評価されます。これにより、bazがその返値で初期化されます。- ジョブのソースコードの末尾に到達したため、エントリーポイントのスタックフレームがスタックからポップされます。スタックは空になったため、ジョブは完了したとみなされます。
ジェネレーターと再入
フレームがポップされたとしても、必ずしも永久に消えてしまうわけではありません。なぜなら、後でそのフレームに戻る必要がある場合もあるからです。例えば、ジェネレーター関数を考えてみましょう。
function* gen() {
console.log(1);
yield;
console.log(2);
}
const g = gen();
g.next(); // logs 1
g.next(); // logs 2
この場合、gen() を呼び出すと、まず実行コンテキストが作成されますが、これは一時停止された状態になります。つまり、gen 内のコードはまだ実行されません。ジェネレーター g は、この実行コンテキストを内部的に保存します。現在実行中の実行コンテキストは、引き続きエントリーポイントとして残ります。g.next() が呼び出されると、gen の実行コンテキストがスタックにプッシュされ、gen 内のコードが yield 式まで実行されます。その後、ジェネレーターの実行コンテキストは一時停止され、スタックから除去されます。これにより、制御はエントリーポイントに戻ります。g.next() が再度呼び出されると、ジェネレーターの実行コンテキストがスタックに再びプッシュされ、gen 内のコードは中断した箇所から再開されます。
テールコール
仕様書で定義されているメカニズムの一つに、プロパーテールコール (proper tail call, PTC) があります。関数呼び出しがテールコールであるとは、呼び出し側が呼び出し後に値を返す以外の処理を行わない場合を指します。
function f() {
return g();
}
この場合、g への呼び出しはテールコールとなります。関数呼び出しが末尾の位置にある場合、エンジンは g() の呼び出し用に新しいフレームをスタックにプッシュするのではなく、現在の実行コンテキストを破棄し、それをテールコールのコンテキストに置き換える必要があります。つまり、テール再帰はスタックサイズの制限を受けません。
function factorial(n, acc = 1) {
if (n <= 1) return acc;
return factorial(n - 1, n * acc);
}
実際には、現在のフレームを破棄するとデバッグ上の問題が発生します。なぜなら、g() がエラーを発生させた場合、f はスタックに残らず、スタックトレースに現れなくなるからです。現在、PTC を実装しているのは Safari (JavaScriptCore) のみであり、デバッグ性の課題を解決するために、いくつかの固有の仕組みを構築しています。
クロージャ
変数のスコープと関数呼び出しに関連するもう一つの興味深い現象が、クロージャです。関数が生成されるたびに、その関数は内部的に、現在実行中の実行コンテキストにおける変数のバインドを記憶します。その結果、これらの変数のバインドは、実行コンテキストが終了した後も存続することがあります。
let f;
{
let x = 10;
f = () => x;
}
console.log(f()); // logs 10
ジョブキューとイベントループ
エージェントはスレッドであり、つまりインタープリタは一度に1つの文しか処理できません。コードがすべて同期的であれば、常に処理を進めることができるため、問題はありません。しかし、コードが非同期処理を実行する必要がある場合、その処理が完了しない限り、処理を進めることはできません。しかし、それによってプログラム全体が停止してしまうと、使い勝手に影響を及ぼしてしまいます。ウェブスクリプト言語としての JavaScript の性質上、ブロックしないことが求められるのです。そのため、その非同期処理の完了を処理するコードは、コールバックとして定義されます。このコールバックは