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

Workerスレッド間での変数共有:SharedArrayBufferとAtomicsによるスコープ管理の難しさ

コードレビューをしていて、メインスレッドとWeb Workerの間で大量のデータをやり取りする際に、何でもかんでも `postMessage` でシリアライズ・デシリアライズしているコードを見かけるたびに、私はこう言いたくなる。「V8の構造化クローン(Structured Clone)のコストを甘く見てはいけない」と。

数メガバイト、あるいは数ギガバイトに及ぶバイナリデータをスレッド間でコピーし続けるのは、ガベージコレクタ(GC)に不要な負荷をかけ、メインスレッドのメインループをブロックし、レイテンシの悪化(Jank)を招く最悪のアンチパターンだ。

ここで救世主となるのが `SharedArrayBuffer` (SAB) と `Atomics` APIである。これらを使えば、スレッドの境界線を超えて「同一のメモリ空間」を直接共有し、真の並行処理(Concurrency)を実現できる。

しかし、スコープの境界が消え去るこの低レイヤな世界への踏み込みは、従来のJavaScript開発における安全な砂場(サンドボックス)のルールを完全に破壊する。データ競合(Data Race)、メモリの視認性(Memory Visibility)、そしてデバッグ不能なデッドロックの魔窟が待ち受けているからだ。

今回は、この `SharedArrayBuffer` を用いたスレッド間メモリ共有のメカニズムをV8の挙動レベルから紐解き、プロダクション環境で絶対に破綻しない堅牢な設計パターンを伝授する。

—

1. V8ランタイムとメモリモデル:なぜスコープの境界線が消えるのか

通常のJavaScriptにおいて、変数やオブジェクトは関数スコープやブロックスコープ、あるいはクロージャによって厳格にカプセル化されている。メインスレッドとWorkerスレッドは、それぞれ独立したV8のアイソレート(Isolate)環境で動作し、メモリは完全に分離されている。データ共有は `postMessage` を介したメッセージパッシング(値のコピーまたはTransferable Objectsによる所有権の移転)のみに制限されていた。

しかし、`SharedArrayBuffer` を生成し、それを `postMessage` でWorkerに渡した瞬間、話は変わる。

// メインスレッド
const sab = new SharedArrayBuffer(1024); // 1KBの共有メモリ領域を確保
const int32View = new Int32Array(sab);
worker.postMessage({ sab }); // 参照(ポインタ)だけが共有される

この瞬間、メインスレッドのV8ヒープとWorkerスレッドのV8ヒープが、OSの物理メモリ上の同一の連続領域を指し示す。
もはや「どのスコープにどの変数が存在するか」という高級言語的な抽象化は意味をなさなくなる。両方のスレッドから、同じメモリインデックスに対して「同時に」読み書きが可能になるのだ。

データ競合(Data Race)の恐怖

もし、同期機構なしに両スレッドが同じメモリ領域を書き換えるとどうなるか?
CPUキャッシュとメインメモリ間の同期タイミング(メモリバリア)の不一致により、片方のスレッドが書き込んだ値がもう片方に即座に反映されなかったり、命令の再順序化(Reordering)によって予期せぬ順序でメモリが書き換わる「データ競合」が発生する。これは原因特定が極めて困難なサイレントバグの温床となる。

—

2. Atomicsによる低レイヤ同期とメモリの視認性(Memory Visibility)

この混沌としたメモリ共有空間を制御するために存在するのが `Atomics` オブジェクトだ。
`Atomics` は、不可分操作(Atomic Operations)を保証する。つまり、あるスレッドが `Atomics` を介してメモリを読み書きしている最中は、他のスレッドはその処理の途中の状態を一切観測できない。

さらに重要なのが 「メモリフェンス(Memory Barrier)」 の張られ方だ。
`Atomics` の操作は、CPUに対して「この操作の前後のメモリ書き込み・読み込み順序を入れ替えてはならない」「キャッシュを即座にメインメモリにフラッシュし、他コアのキャッシュを無効化せよ」という強力な指示を与える。これにより、スレッド間での「メモリの視認性」が完全に担保される。

—

3. プロダクションコード:安全なタスクキューイング・排他制御の実装

言葉で説明するよりも、実際にプロダクション環境で通用する堅牢な実装を見せよう。
ここでは、メインスレッドからWorker群に対してタスクを安全に割り振り、結果を回収するための「ロックフリーに近いスレッドセーフなリングバッファ(タスクキュー)」の設計パターンを提示する。

共有メモリのレイアウト設計

先頭の数バイトを「メタデータ(ヘッド、テイル、ロック状態)」として使い、残りを実データ領域とする。

  • `Atomics` のインデックス構成:
  • `[0]`: 書き込みポインタ (Head)
  • `[1]`: 読み込みポインタ (Tail)
  • `[2]`: ミューテックスフラグ (Lock)
  • `[3…]`: データ領域

コピペで動く堅牢なプロダクションコード例

1. 共通モジュール / メインスレッド側(`index.js`)

/

  • スレッドセーフな共有リングバッファのラッパー

/
class SharedTaskQueue {
constructor(sab, capacity) {
this.sab = sab;
this.capacity = capacity;
// Int32Arrayとしてバッファをビューイング
// [0]: head, [1]: tail, [2]: mutex, [3…]: data
this.i32 = new Int32Array(sab);
}

/

  • タスクデータをキューにプッシュする(メインスレッド側)

/
enqueue(taskId, payload) {
const i32 = this.i32;
const capacity = this.capacity;

// スピンロックによる排他制御(簡易版ミューテックス)
// Atomics.compareExchangeで[2]番目のロックフラグを 0 から 1 にアトミックに変更を試みる
while (Atomics.compareExchange(i32, 2, 0, 1) !== 0) {
// ロックが解放されるまでCPUを過剰に消費しないようビジースピンを緩和
Atomics.wait(i32, 2, 1, 1);
}

try {
const head = i32[0];
const tail = i32[1];
const nextHead = (head + 1) % capacity;

// キューがいっぱいの場合の処理
if (nextHead === tail) {
throw new Error(“SharedTaskQueue is overflowed.”);
}

// データを書き込む(例として taskId と payload をシンプルに格納)
const dataIndex = 3 + head 2;
i32[dataIndex] = taskId;
i32[dataIndex + 1] = payload;

// ヘッドを更新
i32[0] = nextHead;

// 待機しているWorkerスレッドを起こす (目覚ましコール)
Atomics.notify(i32, 0, 1);
} finally {
// 必ずロックを解放する
i32[2] = 0;
Atomics.notify(i32, 2, 1);
}
}
}

// — 実行エントリポイント —
const SAB_BYTE_LENGTH = 1024;
const sab = new SharedArrayBuffer(SAB_BYTE_LENGTH);
const queueCapacity = 100;

const taskQueue = new SharedTaskQueue(sab, queueCapacity);

// Workerの起動
const worker = new Worker(‘./worker.js’);
worker.postMessage({ type: ‘INIT’, sab, capacity: queueCapacity });

// メインスレッドから定期的にタスクを投入
let taskIdCounter = 1;
setInterval(() => {
try {
const payload = Math.floor(Math.random() 1000);
taskQueue.enqueue(taskIdCounter, payload);
console.log(`[Main] Enqueued Task ID: ${taskIdCounter}, Payload: ${payload}`);
taskIdCounter++;
} catch (e) {
console.warn(e.message);
}
}, 100);

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

let queue = null;
let i32 = null;
let capacity = 0;

self.onmessage = (event) => {
const { type, sab, capacity: cap } = event.data;
if (type === ‘INIT’) {
queue = sab;
capacity = cap;
i32 = new Int32Array(sab);

// バックグラウンドでタスクのポーリング・処理ループを開始
processLoop();
}
};

function processLoop() {
while (true) {
const head = i32[0];
const tail = i32[1];

// キューが空の場合
if (head === tail) {
// Atomics.waitでCPUを無駄に消費せず、メインスレッドからの通知(Atomics.notify)を待つ
// メモリインデックス[0]の値が head のままであればスリープする
Atomics.wait(i32, 0, head);
continue;
}

// 排他制御ロックの取得
while (Atomics.compareExchange(i32, 2, 0, 1) !== 0) {
Atomics.wait(i32, 2, 1, 1);
}

try {
// 再度チェック(レースコンディション回避のため)
if (i32[0] === i32[1]) continue;

const currentTail = i32[1];
const dataIndex = 3 + currentTail 2;
const taskId = i32[dataIndex];
const payload = i32[dataIndex + 1];

// テイルポインタを進める
i32[1] = (currentTail + 1) % capacity;

// 重い計算処理のシミュレーション
console.log(`[Worker] Processing Task ID: ${taskId}, Payload: ${payload}`);

} finally {
// ロック解放
i32[2] = 0;
Atomics.notify(i32, 2, 1);
}
}
}

—

4. パフォーマンスと実務上のアーキテクチャ設計の注意点

このコードは美しく堅牢だが、テクニカルリードとしてプロジェクトに導入する際には、以下のアーキテクチャ上のトレードオフをチームメンバー全員に共通認識として持たせる必要がある。

1. `Atomics.wait` はメインスレッド(UIスレッド)で絶対に呼ぶな

`Atomics.wait()` は、指定されたメモリの値が変化するまで同期的にスレッドをブロック(停止)させる。これをメインスレッドで実行すると、ブラウザは完全フリーズし、ユーザー操作を受け付けなくなる(Not Responding)。
必ず `Atomics.wait` は Workerスレッド内でのみ 使用すること。メインスレッド側で非同期に待ち受けたい場合は、ポーリングにするか、Workerからの `postMessage` による通知イベント駆動に設計を切り替えるべきだ。

2. セキュリティ制約:Cross-Origin Isolation(クロスオリジン分離)

モダンブラウザにおいて、`SharedArrayBuffer` は Spectre などのサイドチャネル攻撃(高精度タイマーを利用した情報漏洩脆弱性)の緩和策として、強力なクロスオリジン分離(Cross-Origin Isolation) が有効化されている環境下でなければインスタンス化できない。
HTTPレスポンスヘッダに以下の設定が必須となる。これを忘れると本番環境で `TypeError: SharedArrayBuffer is not defined` が爆発する。

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

3. DOM操作や高レベルオブジェクトの共有は諦めろ

`SharedArrayBuffer` が扱えるのは、あくまで「生のカプセル化されていないバイト列(TypedArray)」だけだ。DOMノード、JavaScriptのオブジェクト、文字列、関数などを直接共有することは物理的に不可能である。
もし複雑なオブジェクト構造を高速にやり取りしたい場合は、`SharedArrayBuffer` の上に独自のシリアライザ(FlatBuffers や Protocol Buffers のJS実装など)を構築するか、処理結果のメタデータだけを共有し、実データの取得はインメモリキャッシュ層を設計して適切に分離する高度なアーキテクチャ設計が求められる。

結びにかえて

`SharedArrayBuffer` と `Atomics` は、フロントエンド開発の次元を「Webドキュメントの表現」から「真のネイティブ並行コンピューティング」へと引き上げる諸刃の剣だ。

スコープという優しい境界線が取り払われた低レイヤの世界では、コンパイラやランタイムはあなたを守ってくれない。すべてはエンジニア自身の設計美学と、メモリモデルへの深い理解にかかっている。コードレビューの現場において、この領域に踏み込む際は、データ競合とデッドロックの可能性をロジカルに証明させ、完璧にテストされた同期ロジック以外は決してマージしないという鉄の意志を持って臨んでほしい。

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