JavaScriptを完全掌握する:SharedArrayBufferとAtomicsで踏み込むマルチスレッド並列処理の深淵
フロントエンド開発において、パフォーマンスの極限を追求する場面——巨大な画像・動画処理、3Dグラフィックスの行列計算、あるいは複雑な音声信号処理(DSP)——に直面したとき、我々は単一のイベントループという「JavaScriptの呪縛」から脱却しなければなりません。
Web Workerの導入は容易です。しかし、レビューの場で散見されるのは、`postMessage` による重厚なデータ転送コストでメインスレッドを死なせているコードか、さもなければ `SharedArrayBuffer` を生で触り、CPUキャッシュ同期と命令再配置(Instruction Reordering)の恐ろしさを知らずにデータレースを爆発させているコードです。
本記事では、V8エンジンのメモリモデルとハードウェアレベルの挙動に踏み込み、JavaScriptの語彙的スコープ(Lexical Scope)を超えた「スレッド間共有メモリ空間」の制御理論と実践パターンを解説します。
—
1. 語彙的スコープの限界とV8における「Isolate」の分離
通常のJavaScript開発において、変数の可視性と寿命は「スコープ(Lexical Scope)」によって静的に決定されます。
// 単一スレッド(Isolate)内のクロージャとヒープ領域
function createCounter() {
let count = 0; // V8 Contextに保持され、クロージャから参照される
return () => ++count;
}
V8エンジン内部において、JavaScriptの実行単位は Isolate という概念で独立しています。単一のIsolateは独自のヒープメモリ(Young/Old Generation)とガベージコレクタ(GC)、そして単一のコールスタックを持ちます。
Web Workerを起動すると、V8はまったく別のIsolateを起動します。したがって、Worker間でJavaScriptの「変数(オブジェクト参照)」を共有することは言語仕様上不可能です。
[Main Thread Isolate] [Worker Thread Isolate]
+———————+ +———————+
| V8 Heap | | V8 Heap |
| let x = { a: 1 }; | | (No Access) |
+———————+ +———————+
| ^
+— postMessage(Structured Clone) —-+ (メモリコピーが発生)
通常の `worker.postMessage(data)` では、構造化複製アルゴリズム(Structured Clone Algorithm) が作動します。V8はオブジェクトグラフをシリアライズし、Worker側のヒープに全く新しいオブジェクトをアロケーション(割り当て)します。ギガバイト級のデータをミリ秒単位でやり取りする超高パフォーマンスアプリケーションにおいて、このコピーオーバーヘッドとGC圧迫は破滅的なボトルネックとなります。
この限界を打ち破り、異空間にある2つのIsolateから「同一の物理メモリ領域」を直接指し示す仕組み、それこそが `SharedArrayBuffer`(SAB)です。
—
2. SharedArrayBufferが生み出すデータレースとCPUキャッシュの脅威
`SharedArrayBuffer` は、V8のヒープ管理(GC)の枠外に確保される、C言語の `malloc` に近い生のバイナリバッファです。これを `TypedArray`(例: `Int32Array`)でラップすることで、複数のスレッドから同一のメモリを読み書きできます。
しかし、ここに極めて危険な罠が存在します。
罠:JavaScriptの代入演算子は「アトミック(不可分)」ではない
以下のコードをメインスレッドとWorkerの両方で同時に実行したとしましょう。
// 共有メモリ上の 0 番目の要素をインクリメントしたい(危険な例)
const sharedArray = new Int32Array(sharedBuffer);
// 一見、1行の処理に見えるが…
sharedArray[0]++;
高精度のコードレビューの視点から言えば、この `sharedArray[0]++` は単一の操作ではありません。コンパイル後のマシンコードレベルでは以下の3ステップに分解されます。
1. Read: メモリ位置 `0` から値をレジスタにロードする
2. Modify: レジスタの値を `+1` する
3. Write: レジスタの値をメモリ位置 `0` に書き戻す
2つのスレッド(Thread A, Thread B)が同時にこのコードを通過すると、次のようなインターリーブ(割り込み) が発生します。
[Thread A] [Thread B] [Memory Value]
Read (値: 0) | 0
| Read (値: 0) 0
Modify (0 -> 1) | 0
| Modify (0 -> 1) 0
Write (値: 1 を書き込む) | 1
Write (値: 1 を書き込む) 1 (本来は 2 になるべき!)
これがデータレース(Data Race)です。
さらには、現代のマルチコアCPUはパフォーマンス最大化のためにL1/L2キャッシュの保持や命令の再配置(Out-of-Order Execution)を行います。Thread Aがメモリに書き込んだ結果が、L1キャッシュに留まり、Thread Bが参照するL3キャッシュや主記憶(RAM)に即座に反映されない事態が発生するのです。
—
3. `Atomics` オブジェクト:ハードウェアメモリバリアの展開
このカオスを制御するのが `Atomics` グローバルオブジェクトです。`Atomics` は単なる便利ライブラリではありません。V8エンジンを通じて、CPUの不可分操作命令(例: x86の `LOCK` プレフィックス付き命令)とメモリバリア(Memory Barrier)を直接発行する命令群です。
`Atomics` を使用すると、CPUは以下を保証します。
- アトミック性: Read-Modify-Writeの操作中に他のコアの介入を排除する。
- 可視性(Visibility): 書き込み結果を他コアのL1/L2キャッシュに対して即座に無効化・同期させる。
- 順序性(Ordering): コンパイラやCPUによる命令再配置を越えて前後のメモリ操作の順序を強制する。
主要な `Atomics` API
- `Atomics.add(typedArray, index, value)`: 不可分な加算。
- `Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)`: CAS (Compare-And-Swap) 操作。値が `expectedValue` ならば `replacementValue` に置き換える。並行プログラミングの基礎基盤。
- `Atomics.wait(typedArray, index, value, timeout)`: 指定位置の値が `value` である間、スレッドをサスペンド(ブロック) して待機する(※ メインスレッドでは実行不可)。
- `Atomics.notify(typedArray, index, count)`: `Atomics.wait` でブロックされているWorkerスレッドを起こす。
—
4. プロダクション系設計パターン:スレッド安全な Mutex(排他制御)の実装
理論を理解したところで、実務に応用可能なプロダクションコードを構築しましょう。
以下は、`SharedArrayBuffer` 上にMutex(スピンロック+セマフォ待機) を構築し、複数Worker間での共有リソース(クリティカルセクション)への安全なアクセスを保証するクラス設計例です。
共有メモリのレイアウト設計
- `Int32Array[0]` : ロック状態フラグ(`0`: 解放中, `1`: ロック取得済み)
- `Int32Array[1]` : タスクデータ(共有カウンタなど)
実装:`mutex.js`(排他制御モジュール)
/
- SharedArrayBufferベースの軽量Mutexロック
- スレッド間の排他制御とサスペンド/レジュームを高速に行う
/
export class SharedMutex {
/
- @param {Int32Array} typedArray – SharedArrayBufferから生成されたInt32Array (最低2要素)
- @param {number} lockIndex – ロック状態を管理するインデックス (デフォルト: 0)
/
constructor(typedArray, lockIndex = 0) {
if (!(typedArray.buffer instanceof SharedArrayBuffer)) {
throw new Error(“SharedMutex requires a SharedArrayBuffer-backed TypedArray.”);
}
this.typedArray = typedArray;
this.lockIndex = lockIndex;
}
/
- ロックを取得する。取得できるまでスレッドを効率的にブロックする。
/
lock() {
// 1. CAS操作でロック(0 -> 1)を試みる
// 成功(返り値が 0)したら即座にロック取得完了
while (Atomics.compareExchange(this.typedArray, this.lockIndex, 0, 1) !== 0) {
/
- 2. ロック取得に失敗した場合:
- typedArray[lockIndex] が 1(ロック中)である限り、スレッドをウェイト状態で待機させる。
- CPUを空回りさせるスピンロックと異なり、OSカーネルレベルでスレッドを休眠させるため省電力。
/
Atomics.wait(this.typedArray, this.lockIndex, 1);
}
}
/
- ロックを解放し、待機中の他スレッドへ通知する。
/
unlock() {
// 1. ロック状態を 0 に戻す(アトミックなストア)
if (Atomics.compareExchange(this.typedArray, this.lockIndex, 1, 0) !== 1) {
throw new Error(“Mutexの解放に失敗しました: 保持されていないロックを解除しようとしました。”);
}
// 2. 待機中のスレッドを1つ起こす (notify)
Atomics.notify(this.typedArray, this.lockIndex, 1);
}
}
実装:`worker.js`(Workerスレッド側)
import { SharedMutex } from ‘./mutex.js’;
self.onmessage = (event) => {
const { sharedBuffer } = event.data;
// SharedArrayBuffer から View を作成
const sharedInt32 = new Int32Array(sharedBuffer);
const mutex = new SharedMutex(sharedInt32, 0);
const DATA_INDEX = 1; // 共有データのインデックス
console.log(`[Worker ${self.name}] 処理開始。ロック取得を試みます…`);
// — クリティカルセクション開始 —
mutex.lock();
try {
console.log(`[Worker ${self.name}] ロック取得成功。共有データを更新します。`);
// 安全にデータ操作が可能(データレースは発生しない)
let currentValue = sharedInt32[DATA_INDEX];
// 重い計算処理をシミュレート
const start = performance.now();
while (performance.now() – start < 50) {}
sharedInt32[DATA_INDEX] = currentValue + 1;
console.log(`[Worker ${self.name}] データを更新しました: ${currentValue} -> ${sharedInt32[DATA_INDEX]}`);
} finally {
// 例外が発生しても確実にロックを解放する
mutex.unlock();
console.log(`[Worker ${self.name}] ロックを解放しました。`);
}
// — クリティカルセクション終了 —
self.postMessage({ status: ‘done’ });
};
実装:`main.js`(メインスレッド側)
// 1. SharedArrayBufferの初期化 (8バイト = Int32 2つ分)
// Index 0: Mutex Lock Flag
// Index 1: Shared Counter Data
const sharedBuffer = new SharedArrayBuffer(8);
const sharedInt32 = new Int32Array(sharedBuffer);
// 初期値の設定
sharedInt32[0] = 0; // Lock Unlocked
sharedInt32[1] = 0; // Initial Counter Value
console.log(‘— 並列処理プログラムの起動 —‘);
// 2. 2つのWorkerスレッドを生成
const worker1 = new Worker(new URL(‘./worker.js’, import.meta.url), { type: ‘module’, name: ‘A’ });
const worker2 = new Worker(new URL(‘./worker.js’, import.meta.url), { type: ‘module’, name: ‘B’ });
let completedWorkers = 0;
const onWorkerDone = () => {
completedWorkers++;
if (completedWorkers === 2) {
console.log(`— 全Worker完了 —`);
console.log(`最終共有カウンタ値: ${sharedInt32[1]}`); // 正確に 2 になっていることが保証される
}
};
worker1.onmessage = onWorkerDone;
worker2.onmessage = onWorkerDone;
// 3. 同時に SharedArrayBuffer への参照を送信
worker1.postMessage({ sharedBuffer });
worker2.postMessage({ sharedBuffer });
—
5. 実務におけるパフォーマンス上の極限注意事項とセキュリティ制約
`SharedArrayBuffer` と `Atomics` は強力な武器ですが、銀の弾丸ではありません。プロダクションに投入する前に、以下の3つの制約と特性を頭に刻み込む必要があります。
(1) メインスレッドにおける `Atomics.wait` の禁忌
ブラウザのUIスレッド(メインスレッド)で `Atomics.wait` を呼ぶと、V8は `TypeError` を投げます。メインスレッドをブロックすることは、ブラウザの描画パイプライン(Render Pipeline)およびイベントループを停止させ、画面フリーズを引き起こすため仕様レベルで禁止されています。
- メインスレッド: 非ブロック操作(`Atomics.add`, `Atomics.compareExchange`, `Atomics.notify` 等)のみ許可。
- Workerスレッド: ブロック操作(`Atomics.wait`)が許可される。
(2) セキュリティ要件:Cross-Origin Isolation
2018年に発覚したCPUの脆弱性「Spectre」に対する攻撃ベクトルを遮断するため、現在のブラウザ環境では厳しいセキュリティヘッダーが必須です。以下の2つのHTTPレスポンスヘッダーがサーバー側から付与されていない場合、`window.SharedArrayBuffer` は `undefined` となり動作しません。
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
(3) 過度な排他制御(ロック粒度)による逆効果
`Atomics` の乱用、特に広範囲なクリティカルセクションを伴う Mutex の多用は、コンテキストスイッチのオーバーヘッドを生み出し、並列処理のメリットを著しく減殺します。
- アンチパターン: 細粒度すぎるアトミック操作の連続呼び出し(ルックアップコストの増大)。
- ベストプラクティス: 可能な限りWorkerごとにバッファ領域を分割(Write領域の分離)し、最終的な集計フェーズのみ `Atomics.add` などで同期する「ロックフリー(Lock-free)設計」を目指すこと。
—
結論:マルチスレッドJavaScriptの真髄をつかむ
JavaScriptのフロントエンド開発は、「シングルスレッド上の非同期処理(Promise/async-await)」の時代から、「真のマルチコア並列処理」を選択できる時代へと進化しました。
`SharedArrayBuffer` と `Atomics` を理解することは、単にWorkerを高速化する手法を得るにとどまりません。V8のメモリ構造、CPUキャッシュコヒーレンシ、ハードウェアレベルの命令同期といったコンピュータアーキテクチャの真理に触れることと同義です。
スコープの壁を越え、メモリを直接掌握する術を手にすれば、Webアプリケーションのパフォーマンスは真の極限へと到達します。設計の厳格さを保ち、バグのない堅牢なマルチスレッドアーキテクチャを築き上げてください。