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

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アプリケーションのパフォーマンスは真の極限へと到達します。設計の厳格さを保ち、バグのない堅牢なマルチスレッドアーキテクチャを築き上げてください。

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