【実務・中級編】Workerスレッド間での変数共有:SharedArrayBufferとAtomicsによる並列処理時のスコープ管理 – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

Workerスレッド間での変数共有:SharedArrayBufferとAtomicsによる並列処理の極限最適化

テックリードの私が生コードのレビューをしていると、未だに「マルチスレッド=安全で速い」という素朴な信仰に基づいた、危険なコードに遭遇することがある。特に、メインスレッドとWeb Worker(あるいはNode.jsの`worker_threads`)の間でデータをやり取りする際、パフォーマンスを追求するあまり`SharedArrayBuffer`を導入し、競合状態(Race Condition)によって再現性の低い不具合を生み出しているケースだ。

JavaScriptはシングルスレッドのイベントループモデルを基本としているが、`SharedArrayBuffer`と`Atomics`の登場により、真の並列メモリ共有が可能になった。しかし、これはV8エンジンのヒープメモリ空間やCPUキャッシュの挙動を正しく理解していなければ、アプリケーションを致命的なクラッシュやデータ破損に導く「諸刃の剣」である。

今回は、スレッド境界を越えた変数の可視性とスコープの管理、そしてプロダクション環境で耐えうる堅牢な並列処理アーキテクチャの構築手法を、実務レベルのコードと共に徹底的に解説する。

—

1. なぜ通常のPostMessageでは足りず、SharedArrayBufferが必要なのか

通常、メインスレッドとWorker間は `postMessage()` を用いてデータを転送(あるいは構造化クローンによるコピー)する。しかし、数メガバイトから数ギガバイトに及ぶバイナリデータ(3Dグラフィックスの頂点バッファや大容量の時系列センサーデータなど)を毎フレーム転送・コピーし続けるとどうなるか?

コピーのたびにCPUサイクルが消費され、ガベージコレクター(GC)のヒープ領域に負荷がかかり、メインスレッドのフレームレートが低下(Jankの発生)する。

ここで登場するのが `SharedArrayBuffer` (SAB) だ。
SABは、メインスレッドと複数のWorkerスレッド間で同一のメモリ領域(RAM)を直接共有する。コピーは一切発生しない。ポインタが指す物理メモリを複数のコンテキストから同時に読み書きできるため、圧倒的なパフォーマンスを引き出せる。

しかし、ここに大きな罠がある。メモリが共有されるということは、スコープの境界が消滅し、変数の可視性が非同期的に変動することを意味する。

—

2. 変数共有のメカニズムと「可視性の罠」

V8エンジンおよびモダンCPUのキャッシュ機構において、あるスレッドが共有メモリ上の値を書き換えたとき、それが他のスレッドから即座に見えるとは限らない。CPUのコアごとにL1/L2キャッシュが存在し、メモリの読み書きの順序が最適化(リオーダー)されるためだ。

さらに、JavaScriptの変数宣言(`let`, `const`, `var`)はあくまで各スレッド内のレキシカルスコープに閉じたものである。SAB自体は単なる「生のバイト配列」であり、その上に構築される型付き配列(`Int32Array`など)のインデックスを「どのスレッドがどう解釈するか」は、完全にプログラマの責任となる。

ここで排他制御(Mutex)や同期を行わなければ、典型的な競合状態(Race Condition)やダーティリードが発生し、データは一瞬で破壊される。

—

3. Atomicsによるスレッドセーフな同期管理

メモリ上の値を安全に操作するために必須となるのが `Atomics` オブジェクトだ。
Atomics APIは、OSレベルの低レイヤー命令(Compare-And-Swapなど)をラップしており、共有メモリに対する操作をアトミック(不可分)に実行することを保証する。

これを用いることで、スレッド間のロック機構やシグナリングをJavaScriptで実装できる。

プロダクションコード例:スレッドセーフなタスクキューイングシステム

以下のコードは、メインスレッドから生成した複数のWorkerスレッドに対し、`SharedArrayBuffer`を介して安全にタスクを分散・処理させる堅牢なアーキテクチャの実装例である。

メインスレッド側 (`main.js`)

// 4つのWorkerで共有するメモリ領域を確保
// 領域の内訳:
// [0]: ロックフラグ (Mutex)
// [1]: 処理済みタスクカウンター
// [2…10]: タスクデータバッファ
const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT 10);
const sharedArray = new Int32Array(sab);

// ワーカーの初期化(ここでは2つのスレッドを生成)
const workers = [
new Worker(‘./worker.js’),
new Worker(‘./worker.js’)
];

console.log(‘[Main] WorkerスレッドへSharedArrayBufferを送信します’);

// 各ワーカーに同じSABをブロードキャスト
workers.forEach((worker, index) => {
worker.postMessage({ id: index, sab });
});

// メイン側から初期データをダミーとして書き込む(スレッドセーフに)
Atomics.store(sharedArray, 2, 100); // タスクID 100
Atomics.store(sharedArray, 3, 200); // タスクID 200

// 同期的にワーカーからの処理完了を待機する例(実際は非同期イベント駆動が望ましい)
setTimeout(() => {
console.log(‘[Main] 現在の共有メモリ状態:’, Array.from(sharedArray));
}, 1000);

Workerスレッド側 (`worker.js`)

self.onmessage = function(e) {
const { id: workerId, sab } = e.data;
const sharedArray = new Int32Array(sab);

// スピンロックとAtomics.compareExchangeを用いた排他制御の実装
function acquireLock() {
// 0がロックフリー、1がロック中とする
while (Atomics.compareExchange(sharedArray, 0, 0, 1) !== 0) {
// ロックが解放されるまでCPUを無駄に回さないよう、必要に応じてAtomics.waitを使用するか、
// 短いスピンであればそのままループする
Atomics.wait(sharedArray, 0, 1, 10); // 10ms待機
}
}

function releaseLock() {
Atomics.store(sharedArray, 0, 0);
// 待機している別のスレッドを起こす
Atomics.notify(sharedArray, 0, 1);
}

// ワーカーのメイン処理ループ
try {
acquireLock();
console.log(`[Worker ${workerId}] ロックを取得しました。メモリを操作します。`);

// 共有メモリ上のデータを安全にインクリメント(アトミック加算)
const currentCount = Atomics.add(sharedArray, 1, 1);
console.log(`[Worker ${workerId}] 処理済みカウンターを更新: ${currentCount + 1}`);

} finally {
releaseLock();
console.log(`[Worker ${workerId}] ロックを解放しました。`);
}
};

—

4. チーフアーキテクトが教える:実務における設計上の注意点

このアーキテクチャを現場に導入するにあたり、以下の「エンジニアの知見」を必ずコードレビューの基準として心に留めておいてほしい。

1. メモリリークと解放のライフサイクル

`SharedArrayBuffer`はガベージコレクションの対象であるが、参照がどこかに残っている限り(Workerや配列ビューが保持し続けている場合)、ヒープ領域は解放されない。特にSPA(Single Page Application)においてコンポーネントの破棄時にWorkerを終了 (`worker.terminate()`) しない場合、メモリリークの温床となる。

2. メインスレッドでの `Atomics.wait` の絶対禁止

Workerスレッド内では `Atomics.wait()` を用いてメインスレッドからのシグナルを安全に待機(ブロック)させることができるが、メインスレッド自身で `Atomics.wait()` を実行してはならない。
メインスレッドがブロックされると、ブラウザのイベントループが完全に停止し、画面描画(UIレンダリング)やユーザーインタラクションが一切フリーズする(「ページが応答していません」ダイアログのトリガーになる)。メインスレッドは常に非同期(MessageChannelやPromise)で協調動作させること。

3. セキュリティ要件(Spectre攻撃対策)

モダンブラウザにおいて、`SharedArrayBuffer`の利用はクロスオリジン分離(Cross-Origin Isolation)が有効化されている環境に厳格に制限されている。HTTPヘッダーに以下の設定が必須となる。

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin

この設定を怠ると、本番環境で `SharedArrayBuffer` のコンストラクタが `ReferenceError` や `TypeError` を投げ、アプリケーションが起動すらしなくなるため注意が必要だ。

—

結びにかえて

`SharedArrayBuffer` と `Atomics` は、フロントエンドの限界を突破するための強力な武器である。しかし、それは「手動のメモリ管理」という、C言語やRustの領域に足を踏み入れることを意味する。

スコープの境界を超えてデータを共有する際は、「誰が書き込み、誰が読み取るのか」「競合はどのように防がれているのか」を常にコードの行間に証明し続けなければならない。

テクニカルリードとして、安易なパフォーマンスチューニングに走るのではなく、スレッドセーフティと保守性のバランスが完璧に保たれた美しいコードベースを維持してほしい。君たちの書くコードの背後には、ユーザーの快適なブラウジング体験という重みが常にかかっているのだから。

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