埋め込みと C API¶
JavaScript エンジンの多くは、単体で動くインタプリタとしてだけでなく、他のアプリケーションに組み込まれる「部品」として使われます。ゲームのスクリプト層、データベースのストアドプロシージャ、ネットワーク機器の設定ロジック、あるいは JavaScript ランタイム全体の中核として、エンジンはホストアプリケーションに埋め込まれ、ホストの制御下で動きます。このときエンジンは、コンテキストの生成・スクリプトの評価・値の受け渡し・ホスト関数の登録といった操作を、外部から呼び出せる API として公開しなければなりません。この章では、埋め込み可能なエンジンが提供すべき API の形、ホストとエンジンの間で値をやり取りする際のライフタイム契約、信頼できないスクリプトを走らせるためのサンドボックス、そしてこの境界で陥りやすい罠を扱います。
なぜエンジンは埋め込み可能なのか¶
埋め込み可能なエンジンでは、主導権はエンジンではなくホストアプリケーションにあります。ホストが起動し、必要になったときにエンジンを初期化し、スクリプトを評価させ、結果を受け取り、不要になれば破棄する。エンジンはホストから呼ばれる受け身の部品です。この関係を成り立たせるために、エンジンは最低限次の操作を外部へ公開します。
- エンジン/コンテキストの生成と破棄: ヒープや実行状態を持つエンジンインスタンスを作り、その中に一つ以上の実行環境(グローバル環境=レルム)を用意する。
- スクリプトの評価: ソース文字列やコンパイル済みコードを与えて実行し、結果の値やスローされた例外を受け取る。
- 値の受け渡し: ホスト側のデータ(数値・文字列・配列など)を JS の値に変換し、逆に JS の値をホスト側で読み出す。
- ホスト関数の登録: ホスト(ネイティブ)側の関数を JS から呼べる形で公開し、JS コードからネイティブ機能を利用できるようにする。
こうした API は、多くのエンジンで C ABI(C 言語の呼び出し規約とデータ表現)として提供されます。C ABI は事実上あらゆる言語から呼び出せる最大公約数的なインタフェースであり、エンジン本体がどの言語で書かれていても、C の関数シグネチャとして公開すれば、C・C++・Rust・Go・その他多くの言語のホストから利用できます。このため、エンジンの実装言語とホストの実装言語が異なっていてもよく、広い相互運用性が得られます。C API を安定な境界として保てば、エンジン内部の表現(値の格納方式・オブジェクトモデル・GC など)を変えても、ホスト側のコードを壊さずに済みます。
C ABI 越しでは、JS の値やオブジェクトの内部構造を直接さらすことはできません。代わりに、値は不透明なハンドル(中身の分からない参照)として渡され、ホストは API 関数を通してのみその中身を読み書きします。この不透明さが、内部表現の自由と、次に述べるライフタイム管理の両方を支えます。
ホストとエンジンの境界 — 値のライフタイムとハンドル¶
埋め込み境界でもっとも神経を使うのが、値のライフタイム管理です。エンジンのヒープはガベージコレクタに管理され、到達不能になった値はいつ回収されてもおかしくありません。前章までで見たとおり、GC がオブジェクトを生かす根拠は「ルート集合から到達できること」です。第III部「ネイティブコードとの相互作用と GC 安全性」では、エンジン内部の組み込み関数が一時値を GC から守る話を扱いました。ここでは同じ問題をホスト側の視点で深めます。
ホストが JS の値を受け取り、それをローカル変数に保持しているとします。この参照はエンジンから見れば、ネイティブコードのスタック変数にすぎず、GC のルート集合には含まれません。したがって、ホストが値を保持している最中にエンジン側で GC が走ると、ホストが握っているはずの値が回収され、以後その参照をたどると解放済みメモリを触ってしまいます。ホストは、保持したい値を GC から守る=ルートとして登録する責任を負います。この登録の道具がハンドルです。ハンドルは、GC 可視の場所に置かれた間接参照であり、ハンドルが生きている間はそれが指す値も生き続けます。
flowchart LR
subgraph host["ホスト(ネイティブ)側"]
L["ローカル変数"]
H["ハンドル"]
end
subgraph engine["エンジン側"]
Root["ルート集合"]
Val["JSヒープ上の値"]
end
L -. "参照するが GC から守れない" .-> Val
H --> Root
Root --> Val
ハンドルはルート集合に登録され、指す値を GC から守る。生のローカル変数は GC から見えず、参照していても回収されうる。
ハンドルには、寿命の異なる二種類があります。
- ローカルハンドル(スコープ付きハンドル): 一回のホスト呼び出しや、明示的に開いたハンドルスコープの間だけ値を生かす。スコープを閉じると、そのスコープで作られたハンドルは一括で解除される。スクリプト評価の結果を受け取って処理する、といった短命な一時値に向く。個々に解放する必要がなく、解放し忘れによるリークが起きにくい。
- 永続ハンドル: ホストが明示的に解放するまで値を生かす。GC の周期をまたいで、あるいは複数のホスト呼び出しにわたって値を保持したいときに使う。たとえばホストがコールバック関数の JS 値を保存しておき、後のイベントで呼び出す、といった用途。明示的に解放しなければリークするため、所有と解放の責任がホストに移る。
| 観点 | ローカル/スコープ付きハンドル | 永続ハンドル |
|---|---|---|
| 生存期間 | ハンドルスコープを閉じるまで | ホストが明示的に解放するまで |
| 主な用途 | 呼び出し内の一時値 | GC 周期をまたぐ長期保持 |
| 解放 | スコープ終了で一括解除 | ホストが個別に解放 |
| リークの危険 | 低い(自動解除) | 高い(解放漏れでリーク) |
| コスト | 軽い(スコープ単位の管理) | 一つずつ登録・解除する管理費 |
使い分けの原則は単純です。呼び出しの間だけ必要な値はスコープ付きハンドルに載せ、呼び出しをまたいで保持したい値だけを永続ハンドルに昇格させる。永続ハンドルを乱用すると、解放漏れがそのままヒープのリークになり、GC がいくら回っても回収できない値が積み上がります。
所有権とライフタイムの契約¶
ハンドルで守るのは JS ヒープ上の値ですが、境界を越えて渡されるデータには、ヒープ外のバッファもあります。典型が文字列です。ホストが JS 文字列の中身を取り出すとき、エンジンは次のいずれかの形を返しえます。
- エンジンが管理するバッファへのポインタを貸す(ホストは読むだけで、解放してはならない。エンジン側の値が生きている間だけ有効)。
- ホスト用に新しくコピーしたバッファを渡す(ホストが使い終わったら解放する責任を負う)。
どちらの契約なのかが曖昧だと、二重解放・解放漏れ・解放済みバッファの参照のいずれかが必ず起きます。逆にホストからエンジンへ文字列を渡す場合も、「エンジンが内部にコピーするのか、ホストのバッファを参照し続けるのか」を決めておかなければなりません。したがって埋め込み API は、境界を越える各値について「誰がいつ解放するのか」「そのポインタはいつまで有効か」を契約として明文化する必要があります。これは第III部で述べた「値の所有権をホストとエンジンのどちらが持つか」の原則を、ヒープ外バッファにまで広げたものです。契約が明確なら、ホスト側のコードが一見正しく書けていても GC のタイミングで壊れる、という類のバグを設計段階で排除できます。
ホスト関数の登録¶
埋め込みの中心的な機能が、ホスト(ネイティブ)側の関数を JS から呼べるように登録することです。ホストは C ABI に沿ったコールバック関数を用意し、それを JS の関数オブジェクトとして公開します。JS コードがその関数を呼ぶと、エンジンは引数を整えてホストのコールバックへ制御を渡し、コールバックの戻り値を JS 側の呼び出し結果とします。この登録には、いくつか押さえるべき点があります。
- レシーバ(
this)の受け渡し: 関数がメソッドとして呼ばれた場合、呼び出し側のレシーバ(obj.method()のobj)をthisとしてコールバックに渡す必要があります。コールバックは引数列だけでなくレシーバも受け取れなければ、メソッドとして正しく振る舞えません。 - ユーザデータの関連付け: 同じコールバック関数を、異なる文脈で使い回したいことがあります。このため、関数登録時にホスト側の任意のポインタ(ユーザデータ)を関連付けておき、呼び出し時にそれを取り出せるようにします。これにより、一つのコールバック実装を、関連付けたデータで振る舞い分けできます。後述する「不透明ハンドルの型取り違え」は、このユーザデータの解釈をめぐって起こりがちです。
- 戻り値と例外: コールバックは通常の戻り値のほかに、JS 例外をスローする手段を持たなければなりません。C ABI には JS の
throwに相当する仕組みがないため、多くの API は「例外を設定して失敗を示す戻り値を返す」形をとります。エンジンはそれを検知し、保留中の例外として JS 側へ伝播させます。この伝播モデルは第IV部「例外処理」で扱ったスタック巻き戻しと保留中例外の話に接続します。
もっとも重要なのは、ホストコールバックはエンジンがヒープ状態を保持したまま実行されるという点です。コールバックの中でエンジン API を呼んでオブジェクトを確保したり、JS へ呼び戻したりすれば、その瞬間に GC が走りえます(割付点はセーフポイントです)。したがって、コールバック内でホストが握っている JS の値は、第III部で述べたとおりハンドルで守らなければなりません。コールバックは「ネイティブコードでありながら、内部は GC と例外の生きた領域」であり、単なる純粋関数ではないという意識が要ります。ここを油断すると、コールバック内の一時値がコールバック越しの再入で回収される、という定番の use-after-free に直行します。
サンドボックスと資源制限¶
エンジンを埋め込むホストは、しばしば信頼できないスクリプトを走らせます。ユーザが投稿したコード、プラグイン、外部から受け取った設定ロジックなどです。信頼できないコードは、無限ループで CPU を占有したり、巨大な配列を作り続けてメモリを食い潰したりしえます。ホストは、こうした暴走から自身を守るための境界を設ける必要があります。代表的な資源制限が、メモリ上限と実行時間の上限です。
メモリ上限¶
エンジンに、ヒープが確保できる総量の上限を設定します。GC は割付点で回収を試みますが、回収しても上限を超えてしまう確保要求は失敗させ、スクリプトにエラー(メモリ不足)として観測させます。これにより、一つのスクリプトがホストプロセス全体のメモリを食い潰すのを防げます。実装上は、確保のたびに現在の使用量と上限を比べる軽い判定を挟むだけで実現できます。判定コストは確保のコストに比べれば小さく、割付点にもともと分岐があるため追加負担は限定的です。
実行時間の上限と割り込み¶
より扱いが難しいのが、暴走した計算を止めることです。while (true) {} のようなタイトなループは、確保もコールバックも行わないため、割付点による GC の機会すら訪れません。放っておけば永久にホストへ制御が戻りません。これを止めるには、エンジンの外から実行に割り込む仕組みが要ります。
一般的な方法は、ウォッチドッグを使うことです。ホストは実行開始時に締め切り(デッドライン)や割り込みフラグを設定し、別スレッドのタイマー、あるいはエンジン自身が定期的にチェックする形で、締め切り超過を検知します。検知したらエンジンは実行を中断し、スクリプトに捕捉可能なエラーとして観測させる(あるいはホストへ制御を返す)。中断をスクリプトから捕捉可能にするか否かは設計判断で、try/catch で握り潰されては困る場合は捕捉不能な形で巻き戻すこともあります。
割り込みを効かせる鍵は、「どこでフラグをチェックするか」です。タイトなループでもフラグを見てもらうには、実行のセーフポイントでチェックを挟む必要があります。第II部「インタプリタ実行」で述べたとおり、インタプリタループのバックエッジ(ループが命令境界へ戻る箇所)はセーフポイントの好適地です。ここに「割り込みフラグが立っていないか」の判定を置けば、確保を一切しないループでも、一周するたびにフラグを確認でき、暴走を止められます。チェックを安価に保つことが肝心で、毎命令ごとに時刻を取得するような重い判定を置くと、全スクリプトが遅くなります。実務的には、単純なフラグ(1 語の読み取りと分岐)をバックエッジで見て、フラグが立っていたときだけ締め切りの精査や巻き戻しに入る、という二段構えにします。時刻の取得やデッドライン比較はフラグが立った稀な場合にのみ行えば、通常経路の負担はフラグ 1 個の読み取りで済みます。
| 観点 | メモリ上限 | 実行時間上限(割り込み) |
|---|---|---|
| 防ぐ暴走 | ヒープの食い潰し | CPU の占有(無限ループ) |
| チェック地点 | 割付点(確保のたび) | セーフポイント(ループのバックエッジ等) |
| 検知の契機 | 上限超過の確保要求 | 締め切り超過/割り込みフラグ |
| チェックの重さ | 使用量と上限の比較 | フラグ 1 語の読み取り(精査は稀時のみ) |
| スクリプトへの見え方 | メモリ不足エラー | 中断エラー(捕捉可否は設計次第) |
| 見落としやすい穴 | 内部確保経路の計上漏れ | 確保しないタイトループがチェックを通らない |
二つの上限は補い合う関係にあり、堅牢なサンドボックスには通常どちらも要ります。メモリ上限だけではタイトループを、時間上限だけでは短時間での大量確保を止めきれないからです。
レルム¶
一つのエンジンインスタンスの中に、独立したグローバル環境を複数持てるようにする単位がレルム(realm)です。各レルムは、それぞれ独自のグローバルオブジェクトと、独自の組み込みオブジェクト一式(Object・Array・Function などのコンストラクタと、それらの prototype)を持ちます。ホストは、互いに干渉させたくないスクリプト群を別々のレルムで実行することで、一方が汚したグローバル環境が他方に漏れないよう隔離できます。ブラウザの複数の文書、プラグインごとの実行環境などがレルムに対応します。
レルムはグローバル環境を分けますが、多くの場合ヒープや GC は同じエンジンインスタンスで共有します。したがって、あるレルムで作った値を別のレルムへ渡すこと自体は可能です。しかしここに落とし穴があります。レルムごとに組み込みオブジェクトの実体が別物なので、レルムをまたいで値を共有すると、素性検査(identity check)が期待どおりに働かないことがあります。典型例は次のようなものです。
- あるレルムで作った配列を別のレルムへ渡し、受け取った側が
instanceof Arrayで判定すると、Arrayコンストラクタがレルムごとに別実体のため、真にならないことがある。 - プロトタイプに基づく分岐(そのオブジェクトの
prototypeが「このArray.prototypeか」を見るような判定)が、レルム差で崩れる。 - 例外オブジェクトを別レルムへ渡すと、
e instanceof TypeErrorの類の判定がレルム境界で成立しないことがある。
このため、レルムをまたいで値を扱うコードは、コンストラクタ同一性に依存する判定を避け、値の種類そのものを問う堅牢な検査(内部的な種別判定に相当するもの)を使う必要があります。ホスト側の相互運用コードでも、複数レルムから来うる値を扱う箇所では、どのレルム由来かに依存しない判定にしておくのが安全です。エンジンの実装者から見ると、レルムは「グローバル環境と組み込みは分けるが、ヒープと値表現は共有する」という設計であり、値がレルム境界を自由に行き来する前提で、レルムに紐づく状態(組み込みの実体・グローバル)と、紐づかない状態(値そのもの・ヒープ)を明確に分けておくことが求められます。レルムやその集合体としての実行単位(エージェント)の正確な意味論は ECMA-262 が定めており、境界での挙動を設計する際の一次資料になります。
実装上の罠¶
ダングリングな内部ポインタ¶
ホスト関数を登録すると、エンジンはそのコールバックの情報(関数ポインタ・関連付けたユーザデータなど)を包んだラッパを作り、JS の関数オブジェクトからそれを参照します。このラッパをどこに置くかに、見落としやすい罠があります。
ホストが多数の関数をまとめて登録する際、ラッパを伸長しうる配列(可変長ベクタ)にまとめて格納し、JS 側からはその要素へのポインタで参照する設計にしたとします。一見効率的ですが、この配列は要素が増えると再確保され、全要素がメモリ上の別の場所へ移動しえます。移動が起きた瞬間、以前に配った「配列要素への内部ポインタ」はすべて、もう誰も使っていない古い領域を指すダングリングポインタになります。以後その関数を呼ぶと、解放済み領域をコールバック情報として読み、任意の場所へジャンプしかねません。
登録: wrappers.push(w1); ptr1 = &wrappers[最後] // 配列要素への内部ポインタを配る
登録: wrappers.push(w2) // ここで配列が再確保され全要素が移動
呼び出し: ptr1 を経由して w1 を読む // ダングリング — 古い領域を参照
根本対策は、エンジンが握る各ラッパを個別に安定させることです。ラッパを一つずつ独立に確保し(伸長で動かない)、そのアドレスを配るか、あるいは配列に安定なインデックスやハンドルを介して間接参照し、内部ポインタを外へ出さないようにします。要は「外に配ったポインタが指す先は、二度と動かない」ことを保証する設計にする、ということです。これは伸長するコレクションの要素アドレスを長期保持しないという、より一般的な原則の一例です。
型を取り違える不透明ハンドル¶
C ABI 越しの値やユーザデータは、中身の分からない不透明ハンドル(実体はポインタや整数)として渡されます。この不透明さは内部表現を隠す利点がある一方、種別の情報も一緒に隠してしまう危険があります。ある API が、受け取った不透明ハンドルのペイロードを、種別を確かめずに特定のラッパ型として解釈(reinterpret)すると、実際には別種のものだった場合に型取り違え(type confusion)を起こします。別レイアウトのメモリを誤ったフィールド定義で読むため、範囲外読み出しや不正なポインタの取り出しにつながります。
たとえば、ホスト関数のユーザデータと、別種のホストオブジェクトの内部ポインタが、どちらも「不透明なホストポインタ」として同じ入口を通る設計だとします。入口の側で「これはどちらの種別か」を判別する印(discriminator)を持たずに、常に一方の型として解釈すると、もう一方が来たときに構造体の途中を型の違うフィールドとして読み、成立しないポインタを有効なアドレスとして扱ってしまいます。これは第I部「値の表現」で触れた、外部由来のビット列を検査せず特定の意味に解釈するとポインタ偽造につながる、という構図とも通じます。
対策は、不透明ハンドルに種別を識別する情報を持たせ、解釈の前に必ず確認することです。ラッパの先頭に種別タグを置く、ハンドルを型ごとに別の API 空間で発行する、あるいは種別つきのハンドルテーブルを介して検証してから実体を取り出す、といった手段があります。共通するのは「不透明であっても、解釈する側が種別を検証できる」設計にすることで、判別子なしに一種類と決め打ちする実装を避けることです。
まとめ¶
- 埋め込み可能なエンジンでは主導権はホストにあり、エンジンはコンテキスト生成・スクリプト評価・値の受け渡し・ホスト関数登録を API として公開する。広い相互運用のため C ABI で提供されることが多く、値は内部表現を隠す不透明ハンドルとして渡される。
- ホストが保持する JS 値は GC のルート集合に含まれないため、ハンドルで守る責任がホストにある。呼び出し内の一時値はスコープ付きハンドル、GC 周期をまたぐ保持は永続ハンドルを使い分ける。永続ハンドルは解放漏れがリークになる。
- 境界を越える値やバッファは「誰がいつ解放するか」「ポインタはいつまで有効か」を契約として明文化する。曖昧さは二重解放・解放漏れ・解放済み参照を招く。
- ホスト関数の登録では、レシーバ(
this)の受け渡し、ユーザデータの関連付け、例外の伝播を扱う。コールバックはエンジンがヒープ状態を保持したまま走る GC/例外点であり、内部の一時値はハンドルで守る。 - 信頼できないスクリプトにはメモリ上限と実行時間上限の両方が要る。メモリ上限は割付点で、時間上限はセーフポイント(ループのバックエッジ等)で安価なフラグを見て検知する。確保しないタイトループもセーフポイントのチェックで中断できる。
- レルムはグローバル環境と組み込みを分けるがヒープは共有しうる。レルムをまたぐと組み込みの実体が別物になり、コンストラクタ同一性に依存する判定(
instanceof等)が崩れうる。 - 罠として、伸長する配列の要素への内部ポインタを外へ配るとダングリングになる(各ラッパを個別に安定化する)。不透明ハンドルを種別確認なしに特定型として解釈すると型取り違えを起こす(判別子を持たせ検証してから解釈する)。
参考文献¶
- ECMA-262: Realms — レルムが独自のグローバル環境と組み込み一式を持つことの一次資料。
- ECMA-262: Agents — 実行単位とレルムの関係を定める一次資料。