SharedArrayBufferとAtomicsによる共有メモリ並行性
ワーカー間で本当のマルチスレッドを実現できる。共有メモリとAtomicsの不可分操作、メモリ順序、wait/notifyによる同期を原理から解説します。
- 1.SharedArrayBufferは複数スレッドが同じバイト列を同時に見る共有領域で、転送(detach)ではなく共有として渡る。同時アクセスにはAtomicsが要る。
- 2.Atomicsはread-modify-writeを不可分(割り込まれない)に行い、すべてのAtomics操作はシーケンシャルに一貫した順序で観測される。これがメモリ順序の保証になる。
- 3.Atomics.wait/notifyでスレッドを安全にブロック・起床でき、SharedArrayBufferの利用にはSpectre対策のcrossOriginIsolated(COOP/COEP)が前提。
なぜ共有メモリに「作法」が要るのか
Web Worker にデータを渡す通常の手段は postMessage によるコピーか転送ですが、どちらもメッセージのたびにシリアライズ/所有権移動が走り、頻繁な双方向のやり取りには向きません。SharedArrayBuffer(SAB)は発想が逆で、複数スレッドが文字通り同じバイト列を同時に見る共有領域を作ります。これでメッセージを介さずに状態を共有できますが、代償として「2つのスレッドが同じ場所を同時に書く」というネイティブのマルチスレッドと同じ問題が JavaScript に持ち込まれます。Atomics はその競合を制御する道具で、不可分操作・メモリ順序・スレッド同期を提供します。なぜ素朴な代入が壊れ、なぜ Atomics なら安全なのかを原理から押さえます。
SharedArrayBufferは「転送ではなく共有」される
SharedArrayBuffer は ArrayBuffer と同じく生のバイト列の記憶域ですが、スレッド境界での扱いが正反対です。通常の ArrayBuffer を postMessage で渡すとコピー(構造化複製)か転送(ゼロコピーだが元は detach され無効化)になります(structuredCloneと転送可能オブジェクト)。対して SharedArrayBuffer は detach されず、送信側も受信側も同じ物理メモリを指したままになります。
// main.js
const sab = new SharedArrayBuffer(1024); // 1KBの共有領域
const view = new Int32Array(sab); // ビューを被せて読み書き
worker.postMessage(sab); // 共有(コピーも転送もされない)
view[0] = 42; // メイン側の書き込みが…
// → worker側の同じ sab に被せた Int32Array からも 42 が見える
ビューの被せ方は通常の ArrayBuffer と同じで、Int32Array などを通してアクセスします(TypedArray・ArrayBuffer・DataViewのメモリレイアウト)。重要なのは、postMessage で渡したのはバイト列のコピーではなく同一領域への参照だという点です。両スレッドのビューは同じバイトを指し、片方の書き込みがもう片方から見えます。
同一領域を2スレッドが同時に触れると、ネイティブ言語と同じ**データ競合(data race)**が起きます。view[0]++ のような一見単純な操作も、内部では「読む→1足す→書く」の3手順で、間に他スレッドの書き込みが割り込めば更新が失われます。プレーンな代入や ++ にはスレッド間の順序保証も不可分性もありません。
Atomics:不可分なread-modify-write
Atomics は、共有領域への操作を不可分(atomic、割り込まれない)に行う静的メソッド群です。対象は整数 TypedArray(Int32Array・BigInt64Array など)に被せた共有領域です。Atomics.add(view, i, 1) は「読む→足す→書く」を一つの分割不能な操作として実行し、その途中で他スレッドが同じ位置を観測することはありません。これがプレーンな view[i]++ との決定的な違いです。
| 操作 | 意味 | なぜ必要か |
|---|---|---|
| Atomics.load / store | 不可分な読み出し/書き込み | 途中の中間状態を他スレッドに見せない |
| Atomics.add / sub / and / or / xor | 不可分なread-modify-write | ++ や |= の更新喪失を防ぐ |
| Atomics.exchange | 値を入れ替えて旧値を返す | ロック取得などの基本部品 |
| Atomics.compareExchange | 期待値と一致時だけ書き換え(CAS) | ロックフリー構造の中核 |
とりわけ Atomics.compareExchange(view, i, expected, replacement)(CAS: compare-and-swap)は並行制御の心臓部です。「位置 i が expected ならば replacement を書き、いずれにせよ旧値を返す」を不可分に行います。これにより「自分が読んだ後に誰も書き換えていなければ更新を確定する」というロックフリーのアルゴリズムが組めます。
// CASでスピンロックを取る(0=空き, 1=ロック中)
function lock(view) {
// 旧値が0のときだけ1を書ける。成功するまで回す
while (Atomics.compareExchange(view, 0, 0, 1) !== 0) {
// 取れなければ少し待つ(後述のwaitが望ましい)
}
}
function unlock(view) {
Atomics.store(view, 0, 0); // 不可分に解放
}
メモリ順序:なぜ「見える順」が保証されるのか
並行プログラムの最大の難所は、コンパイラやCPUが命令を並べ替えるため、あるスレッドの書き込みが別スレッドに「書いた順」で見えるとは限らないことです。JavaScript のメモリモデルは、この曖昧さを Atomics で取り除きます。仕様上、すべての Atomics 操作には全スレッドで一意な全順序(シーケンシャル一貫性)があり、各スレッドはその一貫した順序で他スレッドの atomic 書き込みを観測します。
実務的に効くのは、atomic 操作がメモリバリア(フェンス)として働く点です。あるスレッドが「データを普通に書く → 完了フラグを Atomics.store する」順で実行し、別スレッドが「フラグを Atomics.load で待つ → データを読む」とき、フラグが立ったのを観測できた側はそれより前の非 atomic な書き込みもすべて見えることが保証されます。
// producer(書く側)
data[0] = computeHeavy(); // 普通の書き込み
Atomics.store(flag, 0, 1); // ここが解放(release)点
// consumer(読む側)
while (Atomics.load(flag, 0) === 0) {} // ここが取得(acquire)点
const v = data[0]; // flag=1を見たので data[0] は確実に最新
共有データの全要素を atomic にする必要はありません。「データ本体は普通に書き、最後に1つの atomic 操作で公開する」という release/acquire の型を守れば、atomic 操作が境界となり、それ以前の通常書き込みも順序込みで相手に見えます。並べ替えがこの境界を越えないのが atomic の役割です。
Atomics.waitとnotify:忙しく回さない同期
CAS をループで回し続けるスピンロックは、待っている間 CPU を浪費します。Atomics.wait と Atomics.notify は、スレッドをOSレベルでブロックして眠らせ、必要なときだけ起こす効率的な同期を提供します。
Atomics.wait(view, index, expectedValue, timeout) は、「位置 index の値が今まさに expectedValue ならばスレッドを眠らせる」操作です。値の確認と待機が不可分なのが肝で、確認後・待機前に他スレッドが値を変えて notify しても取りこぼしません。起こす側は Atomics.notify(view, index, count) で、その位置で待っている最大 count 個のスレッドを起床させます。
// 待つ側(Worker)— index 0 の値が 0 の間ずっと眠る
Atomics.wait(view, 0, 0); // 戻り値: "ok" / "not-equal" / "timed-out"
// 起こす側(別スレッド)— 値を変えてから通知
Atomics.store(view, 0, 1);
Atomics.notify(view, 0, 1); // index 0 で待つ1スレッドを起床
Atomics.wait は呼び出しスレッドを同期的に停止させるため、UI を固める恐れがあるメインスレッドでは原則使えません(多くのブラウザで例外になります)。ブロッキング待機は Worker 内で行うのが前提です。メインスレッド側でノンブロッキングに待ちたい場合は、Promise を返す Atomics.waitAsync を使います。
crossOriginIsolatedという前提
SharedArrayBuffer は、過去に Spectre 系の投機的実行サイドチャネル攻撃の高精度タイマーとして悪用できたため、一度ブラウザから無効化された経緯があります。現在使うには、文書が**クロスオリジン分離(cross-origin isolation)**された環境であることが必須です。具体的には次の2つのレスポンスヘッダで、信頼できないクロスオリジン文書を同じプロセスから締め出します。
| ヘッダ | 値の例 | 役割 |
|---|---|---|
| Cross-Origin-Opener-Policy | same-origin | 他オリジンとブラウジングコンテキスト群を分離する |
| Cross-Origin-Embedder-Policy | require-corp | 明示許可のないクロスオリジン資源の読み込みを禁じる |
両ヘッダが揃うと globalThis.crossOriginIsolated が true になり、その文書とそこから生成した Worker でのみ SharedArrayBuffer が使えます。これはアドレス空間を信頼境界で隔離し、共有メモリ+高精度タイマーで他オリジンのメモリを盗み見られないようにする措置で、根底にあるのは同一オリジンポリシーの脅威モデルと同じ「プロセス/オリジンの隔離」の発想です。
COEP: require-corp を有効にすると、クロスオリジンの画像・スクリプト・iframe は Cross-Origin-Resource-Policy か CORS で明示的に許可されない限り読み込めなくなります。サードパーティ資源を多用するサイトでは、SAB を有効化した途端に既存の埋め込みが壊れることがあるため、影響範囲の確認が欠かせません。
まとめ
SharedArrayBuffer は転送(detach)ではなく共有として渡り、複数スレッドが同一バイト列を同時に見ます。同時アクセスはデータ競合を生むため、Atomics で不可分な read-modify-write(add・exchange・compareExchange 等)を行います。JavaScript のメモリモデルでは全 atomic 操作がシーケンシャル一貫性のある単一順序で観測され、atomic 操作が release/acquire の境界として働くので、その手前の通常書き込みまで順序込みで相手に見えます。Atomics.wait/notify(メインスレッドでは waitAsync)でスレッドを安全にブロック・起床でき、利用には Spectre 対策として COOP/COEP による crossOriginIsolated 環境が必須です。「共有か転送か」「不可分か否か」「何が順序を保証するか」を分けて考えれば、共有メモリ並行性の挙動と落とし穴は一貫して説明できます。この共有メモリと atomic はWebAssemblyのスレッド実行モデルの基盤でもあります。
Web/フロントエンド Article
SharedArrayBufferとAtomicsによる共有メモリ並行性を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
SharedArrayBuffer
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 6
導入後に効く点
Atomicsはread-modify-writeを不可分(割り込まれない)に行い、すべてのAtomics操作はシーケンシャルに一貫した順序で観測される。これがメモリ順序の保証になる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 6
判断チェックリスト
- 自社の用途が「SharedArrayBuffer / Atomics」に近いか確認する。
- 強みである「SharedArrayBufferは複数スレッドが同じバイト列を同時に見る共有領域で、転送(detach)ではなく共有として渡る。同時アクセスにはAtomicsが要る。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。