【テクニカル・上級編】Workerスレッド間での変数共有:SharedArrayBufferとAtomicsのスコープ管理 – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

Workerスレッド間での変数共有:SharedArrayBufferとAtomicsによる極限のメモリ同期

JavaScriptは長年にわたり、「シングルスレッドの非同期イベント駆動ランタイム」という美しき(そして時として足かせとなる)制約の中で進化を遂げてきた。V8エンジンは、単一のメインスレッド上でイベントループを回し、マクロタスクとマイクロタスクの厳密なキュー消費メカニズムによって並行性を擬似的に実現してきた。

しかし、現代のWebアプリケーション、あるいはNode.jsサーバーサイドの境界領域において、CPUのマルチコアを完全に飽和させ、ガベージコレクション(GC)の停止時間を極限まで排除するアプローチは、もはや避けて通れない最適化のフロンティアである。ここで登場するのが、`SharedArrayBuffer`と`Atomics`による真の並行処理、すなわち「スレッド間でのメモリ共有」だ。

本稿では、メインスレッドとWeb Worker(あるいはNode.jsの`worker_threads`)の間でメモリを直結し、V8のヒープ空間とOSの物理メモリの境界で何が起きているのか、その低レイヤの真実を解き明かす。

—

1. V8ヒープの壁を越える:`SharedArrayBuffer`の物理的実体

通常、JavaScriptのWorker間でデータをやり取りする場合、`postMessage()`を用いる。これは構造化クローンアルゴリズム(Structured Clone Algorithm)によってデータを直列化(シリアライズ)し、メモリ空間をコピーして別スレッドへ送り込む。データサイズが数メガバイトを超えた瞬間、このシリアライズとコピーのコストはメインスレッドをフリーズさせ、イベントループのレイテンシを跳ね上げる。

これに対し、`SharedArrayBuffer`(SAB)は、V8の通常のJavaScriptヒープオブジェクトとは異なる領域に位置する。

+————————————————————-+
| メインスレッド (V8 Isolate A) |
| – JS Heap (Objects, Closures, Hidden Classes) |
| – Call Stack / Event Loop |
+————————————————————-+
| |
v (ポインタ参照) v (ポインタ参照)
+————————————————————-+
| 物理メモリ / OS Shared Memory (OS Kernel Level) |
| [ SharedArrayBuffer: 連続した生のバイト列バイナリ ] |
+————————————————————-+
^ ^
| (ポインタ参照) | (ポインタ参照)
+————————————————————-+
| Workerスレッド (V8 Isolate B) |
| – 独立したJS Heap |
| – 独自の Call Stack / Event Loop |
+————————————————————-+

SABの本質は、OSの仮想メモリ空間における「共有メモリ(Shared Memory)」のラッパーに過ぎない。V8の各Isolate(独立したJSエンジンインスタンス)は、独自のヒープを持つが、SABが保持する連続したバイナリバッファのポインタ(アドレス)を直接共有する。

ここで重要となるのが、スコープの制約である。SAB自体は単なる「生(ロー)のメモリブロック」であり、その上に構築される型付き配列(`Int32Array`など)のビューを各スレッドで生成することで初めて意味を持つ。しかし、メモリが共有されるということは、保護されていないデータ競合(Data Race)が、C/C++並みの未定義動作(Undefined Behavior)やメモリ破損を引き起こすリスクと表裏一体であることを意味する。

—

2. データの整合性を死守する:`Atomics`とCPUキャッシュの不可避な関係

複数のスレッドが同一のメモリ領域を同時に読み書きするとき、CPUのL1/L2キャッシュの非同期性、およびコンパイラやCPUによる「命令の並べ替え(Out-of-Order Execution)」が問題になる。

ここで`Atomics`オブジェクトが登場する。`Atomics`は、JavaScriptレベルのシンタックスシュガーではなく、V8を介してCPUのハードウェアレベルのアトミック命令(x86の`LOCK`プレフィックスなど)に直接マッピングされるプリミティブ群である。

以下のコードは、メインスレッドとWorkerの間で、SABを介してタスクキューの状態をロックフリーかつアトミックに制御する極限の同期パターンである。

// — メインスレッド側の初期化と制御 —

// 4バイトの整数を格納する配列を2つ用意する
// index 0: ミューテックス/フラグ (0 = ロック解除, 1 = ロック中)
// index 1: 共有カウンタ値
const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT 2);
const sharedArray = new Int32Array(sab);

// ワーカーの生成
const worker = new Worker(‘./heavy-worker.js’);

// ワーカーへSharedArrayBufferを転送(コピーではなく参照の共有)
worker.postMessage({ sab });

// メインスレッド側から安全にインクリメントを実行する関数
function incrementSafely() {
// スピンロックによる排他制御
// Atomics.compareExchangeは、期待値(0)と一致した場合のみ新しい値(1)にアトミックに書き換える
while (Atomics.compareExchange(sharedArray, 0, 0, 1) !== 0) {
// ロックが取得できるまでCPUを無駄に回さないよう、必要に応じてパースする
// 実際のプロダクションでは Atomics.wait をメインスレッド以外で使用する
}

try {
// — クリティカルセクション開始 —
let current = Atomics.load(sharedArray, 1);
Atomics.store(sharedArray, 1, current + 1);
console.log(`[Main] カウンタ更新: ${current + 1}`);
// — クリティカルセクション終了 —
} finally {
// ロックを確実に解放し、待機中のスレッドを起こす
Atomics.store(sharedArray, 0, 0);
Atomics.notify(sharedArray, 0, 1); // 1つの待機スレッドを起こす
}
}

// 定期的に実行
setInterval(incrementSafely, 1000);

// — heavy-worker.js (Workerスレッド側) —

self.onmessage = (event) => {
const { sab } = event.data;
const sharedArray = new Int32Array(sab);

// バックグラウンドで無限にロックを取得して処理を行うループ
function workerTask() {
// 排他制御の取得
while (Atomics.compareExchange(sharedArray, 0, 0, 1) !== 0) {
// アトミックな待機:ロックが解放されるまでOSレベルでスレッドをサスペンドする
// メインスレッドではAtomics.waitはブロックするため使用できないが、Workerでは有効
Atomics.wait(sharedArray, 0, 1);
}

try {
// — クリティカルセクション開始 —
let current = Atomics.load(sharedArray, 1);
console.log(`[Worker] 現在のカウンタ値: ${current}`);
Atomics.store(sharedArray, 1, current + 1);
// — クリティカルセクション終了 —
} finally {
Atomics.store(sharedArray, 0, 0);
Atomics.notify(sharedArray, 0, 1);
}

// 次の処理へ
setTimeout(workerTask, 500);
}

workerTask();
};

この実装におけるキモは、`Atomics.compareExchange` によるスピンロックと、`Atomics.wait` / `Atomics.notify` によるOSカーネルを巻き込んだウェイクアップ機構の組み合わせだ。これにより、ビジーウェイト(CPU使用率が100%に張り付く現象)を防ぎつつ、スレッド間の厳密な順序保証(Sequential Consistency)を担保している。

—

3. セキュリティの防壁:Meltdown/Spectre対策とSABの制約

`SharedArrayBuffer`はその圧倒的なパフォーマンスと引き換えに、Webセキュリティの歴史上最大の脆弱性ベクターの一つとなった。2018年のSpectre脆弱性発覚の際、攻撃者は高精度タイマー(`performance.now()`など)とSABを組み合わせることで、CPUの投機的実行(Speculative Execution)を利用したサイドチャネル攻撃(キャッシュリーク)を成立させ、他のオリジンのメモリ空間から機密データを不正に読み取ることができた。

そのため、モダンブラウザにおけるSABの利用には、厳格なセキュリティヘッダーによる「クロスオリジン分離(Cross-Origin Isolation)」が必須となっている。

HTTPレスポンスヘッダーに以下が設定されていなければ、V8は`SharedArrayBuffer`のコンストラクタへのアクセスをブロックし、`TypeError`を送出する。

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

チーフアーキテクトとして警告しておくべきは、この制約が単なる「ブラウザの仕様」にとどまらず、Node.js環境やElectronなどのデスクトップランタイム、さらにはWebAssembly(Wasm)のマルチスレッド実行基盤(Pthreadsなど)の設計全体に直結しているという点だ。サプライチェーンを狙う攻撃者は、このオリジン分離の不備や、サードパーティ製ライブラリによるプロトタイプ汚染(Prototype Pollution)を足がかりに、グローバルスコープやプロトタイプチェーン経由でSABの生成ロジックやポインタ操作関数をハイジャックしようと試みる。

オブジェクトのプロトタイプチェーンに細工を施され、`ArrayBuffer`や型付き配列の内部メソッドが書き換えられた状態でSABを操作した場合、V8の隠しクラス(Hidden Classes / Maps)のインラインキャッシュ(Inline Caches)が汚染され、型推論が崩壊。最悪の場合、ランタイムのメモリ安全性が破綻し、リモートコード実行(RCE)への扉が開かれる。

—

4. チーフアーキテクトからの提言:プロダクション環境での設計指針

SharedArrayBufferとAtomicsをプロダクションコードに導入する際は、以下の鉄則を遵守せよ。

1. スコープの最小化とカプセル化:
SABの生ポインタや`Int32Array`のインスタンスをグローバル変数として露出させないこと。必ず専用のマネージャーモジュール内に閉じ込め、外部からは抽象化されたメソッド(例: `queue.push()` や `state.get()`)経由でのみアクセスさせよ。
2. デッドロックの静的・動的解析:
複数のAtomics変数をロックする場合、ロックを取得する順序(Lock Ordering)を全スレッドで完全に統一すること。順序がバラバラである場合、高負荷時に確率的デッドロック(Deadlock)が発生し、プロセスが沈黙する。
3. 環境差異の吸収:
Node.js (`worker_threads`) とブラウザ (Web Workers) では、SABの有効化条件やセキュリティ要件(COOP/COEP)の強制力が異なる。ランタイムの起動時に必ず `typeof SharedArrayBuffer !== ‘undefined’` および `crossOriginIsolated` の評価を行わせるフォールバック機構を構築すること。

JavaScriptはもはや「おもちゃのスクリプト言語」ではない。V8エンジンの深淵を覗き、メモリの物理配置とスレッドの同期メカニズムを掌中に収めた者だけが、真にスケーラブルで堅牢な次世代アプリケーションのアーキテクチャを構築できる。

タイトルとURLをコピーしました