Workerスレッド間での変数共有:SharedArrayBufferとAtomicsのスコープ管理
コードレビューをしよう。君が書いたメインスレッドとWeb Worker間でデータをやり取りするコードを見せてもらったが……おい、何を考えているんだ。
`postMessage` によるシリアライズとデシリアライズ、そして構造化クローンアルゴリズムのオーバーヘッド。数十メガバイトにおよぶバイナリデータやリアルタイムの演算結果を毎フレーム相手に投げつけておいて、「なぜUIがカクつくのか分からない」などと言わせない。
モダンWebフロントエンドの限界を突破したいなら、メッセージパッシングの呪縛から逃れろ。我々が使うべきは `SharedArrayBuffer` と `Atomics` だ。
だが、警告しておく。V8エンジンのヒープ空間を直に叩くこの低レイヤなアプローチは、一歩間違えばデータ競合(Data Race)という名の不可解なバグ、あるいはセキュリティの悪夢であるSpectre脆弱性対策(Cross-Origin Isolation)の壁に阻まれる。
今回は、プロダクション環境で絶対に事故を起こさない、スレッドセーフな変数共有とスコープ管理の極意を授けよう。
—
1. V8のメモリ空間とSharedArrayBufferの正体
通常、JavaScriptのオブジェクトや配列は、各スレッド(メインスレッドやWorker)が独立したV8アイソレート(isolate)とヒープを持り、`postMessage` によってデータのコピー(または転送)が行われる。
しかし、`SharedArrayBuffer`(SAB)の登場により、複数のスレッドが同一の物理メモリ領域を直接参照することが可能になった。
+————————————————-+
| SharedArrayBuffer |
| [ 0x00 ] [ 0x01 ] [ 0x02 ] [ 0x03 ] … (SAB) |
+———+—————————–+———+
| |
v v
+——————-+ +——————-+
| Main Thread | | Worker Thread |
| V8 Heap / Scope | | V8 Heap / Scope |
+——————-+ +——————-+
ここで重要なのは、「メモリは共有されているが、スコープと変数の参照は共有されていない」という点だ。
SAB自体はただのバイト列のハコに過ぎない。そのハコをどう解釈するかは、各スレッドが持つ `TypedArray`(例: `Float64Array`, `Int32Array`)のスコープ変数に委ねられている。
つまり、スレッドAが書き込んだバイナリは、スレッドBから即座に見える。ここで同期機構(Atomics)を怠れば、メモリアクセスの順序入れ替え(Out-of-Order Execution)やキャッシュの不整合により、アプリケーションは確実に沈没する。
—
2. セキュリティの壁:Cross-Origin Isolation
コードを書く前に、ブラウザのセキュリティ要件をクリアしていなければ話にならない。Spectre攻撃(サイドチャネル攻撃)を防ぐため、SABを利用するにはHTTPレスポンスヘッダで以下のクロスオリジン分離を有効にする必要がある。
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
この設定がない環境で `new SharedArrayBuffer()` を叩こうものなら、V8は容赦なく `TypeError` を吐いてクラッシュする。開発サーバーのミドルウェア設定もここを必ず通すこと。
—
3. 実践:競合を防ぐAtomicsと堅牢な設計パターン
では、メインスレッドとWorker間で「進捗状況(Progress)」と「計算結果のバッファ」を完全に同期させながら処理を行うプロダクションコードを見ていこう。
今回は、重い数値演算をWorkerにオフロードしつつ、メインスレッド側からその進捗をロックフリーかつ安全に監視する設計パターンを実装する。
ディレクトリ構成
- `main.js` (メインスレッドのコントローラー)
- `worker.js` (バックグラウンド演算プロセッサ)
—
メインスレッド側の実装 (`main.js`)
/
- テクニカルリードによるプロダクションコードレビュー基準適合
- メインスレッド:SharedArrayBufferの初期化とWorkerの統御
/
async function initParallelProcessor() {
// 1. メモリレイアウトの設計
// インデックス0: ステータスフラグ (0: アイドル, 1: 処理中, 2: 完了, -1: エラー)
// インデックス1: 進捗率 (0 – 100)
// インデックス2以降: 実際のデータ格納エリア (例: 1024要素のFloat64)
const META_SIZE = 2;
const DATA_SIZE = 1024;
// Int32Arrayで扱えるバイト数 + データ領域のバイト数
const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT META_SIZE + Float64Array.BYTES_PER_ELEMENT DATA_SIZE);
// ビューのスコープ定義(同じSABを異なる型でスライスする)
const metaView = new Int32Array(sab, 0, META_SIZE);
const dataView = new Float64Array(sab, Int32Array.BYTES_PER_ELEMENT META_SIZE, DATA_SIZE);
// Workerの召喚
const worker = new Worker(‘./worker.js’, { type: ‘module’ });
// 処理開始の合図と共にSharedArrayBufferを転送(SABは転送しても参照は切れない)
worker.postMessage({ sab, dataSize: DATA_SIZE });
// メインスレッド側での非同期監視ループ(UIブロックを防ぐためrequestAnimationFrameを活用)
function monitorProgress() {
// Atomics.loadによるアトミックな読み込み(CPUキャッシュの強制フラッシュ&可視性保証)
const status = Atomics.load(metaView, 0);
const progress = Atomics.load(metaView, 1);
console.log(`[Main] 現在の進捗: ${progress}% (Status: ${status})`);
if (status === 2) {
console.log(‘[Main] 演算完了。データを安全に読み込みます。’);
// データ領域から結果を取得(必要ならコピー、または直接DOMやCanvas描画へ)
processResults(dataView);
worker.terminate();
return;
} else if (status === -1) {
console.error(‘[Worker] 内部で致命的なエラーが発生しました。’);
worker.terminate();
return;
}
// 次の描画フレームで再度チェック
requestAnimationFrame(monitorProgress);
}
// 監視開始
requestAnimationFrame(monitorProgress);
}
function processResults(dataView) {
// 高速なバッファ処理
console.log(‘先頭5件の結果:’, dataView.slice(0, 5));
}
// 実行
initParallelProcessor();
—
Workerスレッド側の実装 (`worker.js`)
/
- バックグラウンドワーカー:高負荷演算とAtomicsによる排他制御
/
self.onmessage = (event) => {
const { sab, dataSize } = event.data;
// メインスレッドと同様のレイアウトでビューを再構築
const META_SIZE = 2;
const metaView = new Int32Array(sab, 0, META_SIZE);
const dataView = new Float64Array(sab, Int32Array.BYTES_PER_ELEMENT META_SIZE, dataSize);
try {
// ステータスを「処理中(1)」にアトミックに変更
Atomics.store(metaView, 0, 1);
// 重い演算のシミュレーション
for (let i = 0; i < dataSize; i++) {
// 擬似的な高負荷処理
let sum = 0;
for (let j = 0; j < 10000; j++) {
sum += Math.sin(i + j);
}
dataView[i] = sum;
// 10%ごとに進捗を更新
if (i % Math.floor(dataSize / 10) === 0) {
const percent = Math.floor((i / dataSize) 100);
// アトミックに進捗をストア
Atomics.store(metaView, 1, percent);
// 必要であればメインスレッドを起こす(Atomics.notify)
Atomics.notify(metaView, 1);
}
}
// 演算終了:進捗100%、ステータス「完了(2)」
Atomics.store(metaView, 1, 100);
Atomics.store(metaView, 0, 2);
Atomics.notify(metaView, 0);
} catch (err) {
console.error(err);
Atomics.store(metaView, 0, -1);
Atomics.notify(metaView, 0);
}
};
---
4. コードレビューの視点:なぜこの設計が「美しい」のか
シニアエンジニアの視点から、このコードの何が優れているのか、そして素人が書きがちなアンチパターンと比較して解説する。
1. メモリのアライメントとオフセット計算の正確性
SABは単なる生のメモリブロックであるため、型付き配列を重ねて配置(ビューイング)する際の位置計算を誤ると、メモリ破損(Segmentation faultに近い挙動や予期せぬ型汚染)を引き起こす。
コード内では `Int32Array.BYTES_PER_ELEMENT META_SIZE` を正確にオフセットとして指定し、メタデータ領域とデータ領域を完全に分離している。この規律の高さが保守性を担保する。
2. `Atomics.load` と `Atomics.store` の不可欠性
マルチスレッド環境において、通常の代入演算子(`metaView[0] = 1`)は、CPUの最適化(レジスタキャッシュやアウトオブオーダー実行)により、他スレッドからその変更が見えるタイミングが保証されない。
`Atomics.load` および `Atomics.store` を使うことで、ハードウェアレベルのメモリバリア(FENCE命令)が強制され、スレッド間の「変数の可視性(Visibility)」が完全に同期される。
3. ロックフリー(Lock-Free)かつノンブロッキングなスコープ監視
UIスレッド側で `Atomics.wait()` を使うのは厳禁だ。メインスレッドで `Atomics.wait()` を呼び出すと、ブラウザのメインイベントループが完全に凍結し、タブがフリーズする(「ページが応答していません」ダイアログのトリガーになる)。
代わりに `requestAnimationFrame` と組み合わせたポーリング、あるいは `Atomics.load` による非同期監視を採用することで、UIの滑らかさ(60fps/120fpsの維持)とマルチスレッドの恩恵を完全に両立させている。
—
結びにかえて
`SharedArrayBuffer` と `Atomics` は、JavaScriptを「単なるスクリプト言語」から「真のシステムプログラミング言語」へと昇華させる諸刃の剣だ。
変数のスコープ、メモリのオフセット、そしてCPUキャッシュの挙動。これらを完全に掌握した者だけが、ブラウザの限界を超える超高速なWebアプリケーション(動画・音声編集、リアルタイム3Dグラフィクス、暗号処理など)を構築できる。
君のコードレビューで、次に低レイヤを汚すコードを見かけたら、こう言ってやりたまえ。
「おい、メモリの可視性とアトミック性を担保しろ。V8が泣いているぞ」と。