日本語

JavaScriptで真のマルチスレッドを解放。本ガイドでは、SharedArrayBuffer、Atomics、Web Worker、そして高性能Webアプリのためのセキュリティ要件を包括的に解説します。

JavaScript SharedArrayBuffer: Webにおける並行プログラミングの徹底解説

何十年もの間、JavaScriptのシングルスレッドという性質は、そのシンプルさの源であると同時に、重大なパフォーマンスのボトルネックでもありました。イベントループモデルはほとんどのUI主導のタスクで見事に機能しますが、計算量の多い処理に直面すると苦戦します。時間のかかる計算はブラウザをフリーズさせ、ユーザーにフラストレーションのたまる体験をもたらします。Web Workerはスクリプトをバックグラウンドで実行させることで部分的な解決策を提供しましたが、それ自体に大きな制限がありました。それは非効率なデータ通信です。

そこで登場するのがSharedArrayBuffer(SAB)です。これはWeb上でスレッド間の真の低レベルメモリ共有を導入することで、根本的にゲームのルールを変える強力な機能です。Atomicsオブジェクトと組み合わせることで、SABはブラウザで直接、新時代の高性能な並行アプリケーションを切り開きます。しかし、大きな力には大きな責任、そして複雑さが伴います。

このガイドでは、JavaScriptにおける並行プログラミングの世界を深く掘り下げていきます。なぜそれが必要なのか、SharedArrayBufferAtomicsがどのように機能するのか、対処しなければならない重要なセキュリティ上の考慮事項、そして実際に始めるための実践的な例を探ります。

旧世界:JavaScriptのシングルスレッドモデルとその限界

解決策の価値を理解する前に、問題を完全に理解しなければなりません。ブラウザでのJavaScriptの実行は、伝統的に「メインスレッド」または「UIスレッド」と呼ばれる単一のスレッドで行われます。

イベントループ

メインスレッドは、JavaScriptコードの実行、ページのレンダリング、ユーザーインタラクション(クリックやスクロールなど)への応答、CSSアニメーションの実行など、すべてを担当します。これらのタスクはイベントループを用いて管理され、メッセージ(タスク)のキューを継続的に処理します。あるタスクの完了に時間がかかると、キュー全体がブロックされます。他の何も実行できなくなり、UIはフリーズし、アニメーションはカクつき、ページは応答しなくなります。

Web Worker:正しい方向への一歩

Web Workerは、この問題を緩和するために導入されました。Web Workerは、本質的には別のバックグラウンドスレッドで実行されるスクリプトです。重い計算をワーカーにオフロードすることで、メインスレッドをユーザーインターフェースの処理に専念させることができます。

メインスレッドとワーカー間の通信は、postMessage() APIを介して行われます。データを送信すると、それは構造化複製アルゴリズムによって処理されます。これは、データがシリアライズされ、コピーされ、そしてワーカーのコンテキストでデシリアライズされることを意味します。これは効果的ですが、大規模なデータセットに対しては重大な欠点があります:

ブラウザ上のビデオエディタを想像してみてください。ビデオフレーム全体(数メガバイトになることもある)を処理のために毎秒60回ワーカーとやり取りするのは、法外にコストがかかるでしょう。これこそが、SharedArrayBufferが解決するために設計された問題なのです。

ゲームチェンジャー:SharedArrayBufferの導入

SharedArrayBufferは、ArrayBufferに似た、固定長の生のバイナリデータバッファです。決定的な違いは、SharedArrayBufferは複数のスレッド(例:メインスレッドと1つ以上のWeb Worker)間で共有できるという点です。postMessage()を使ってSharedArrayBufferを「送信」するとき、コピーを送っているのではなく、同じメモリブロックへの参照を送っているのです。

これは、あるスレッドがバッファのデータに加えた変更が、そのバッファへの参照を持つ他のすべてのスレッドから即座に見えることを意味します。これにより、コストのかかるコピーとシリアライズのステップが不要になり、ほぼ瞬時のデータ共有が可能になります。

このように考えてみてください:

共有メモリの危険性:競合状態

瞬時のメモリ共有は強力ですが、並行プログラミングの世界からの古典的な問題、競合状態(race conditions)も引き起こします。

競合状態は、複数のスレッドが同時に同じ共有データにアクセスして変更しようとし、最終的な結果がそれらの実行される予測不可能な順序に依存する場合に発生します。SharedArrayBufferに保存された単純なカウンターを考えてみましょう。メインスレッドとワーカーの両方がそれをインクリメントしたいとします。

  1. スレッドAが現在の値「5」を読み取ります。
  2. スレッドAが新しい値を書き込む前に、オペレーティングシステムがスレッドAを一時停止し、スレッドBに切り替えます。
  3. スレッドBが現在の値を読み取りますが、それはまだ「5」です。
  4. スレッドBは新しい値(6)を計算し、それをメモリに書き戻します。
  5. システムはスレッドAに切り替わります。スレッドAはスレッドBが何かをしたことを知りません。中断したところから再開し、新しい値(5 + 1 = 6)を計算して、メモリに「6」を書き戻します。

カウンターは2回インクリメントされたにもかかわらず、最終的な値は7ではなく6です。これらの操作はアトミック(atomic)ではありませんでした。つまり、中断可能であり、データの損失につながったのです。これこそが、SharedArrayBufferをその重要なパートナーであるAtomicsオブジェクトなしでは使えない理由です。

共有メモリの守護者:Atomicsオブジェクト

Atomicsオブジェクトは、SharedArrayBufferオブジェクトに対してアトミックな操作を行うための静的メソッドのセットを提供します。アトミックな操作は、他のどの操作にも中断されることなく、完全に実行されることが保証されます。それは完全に実行されるか、全く実行されないかのどちらかです。

Atomicsを使用することで、共有メモリに対する読み取り-変更-書き込み操作が安全に実行されることを保証し、競合状態を防ぎます。

主要なAtomicsメソッド

Atomicsが提供する最も重要なメソッドのいくつかを見てみましょう。

同期:単純な操作を超えて

時には、安全な読み書き以上のことが必要になります。スレッド同士が協調し、お互いを待つ必要があるのです。一般的なアンチパターンは「ビジーウェイト」で、スレッドがタイトなループにとどまり、メモリ上の位置の変更を常にチェックし続けることです。これはCPUサイクルを無駄にし、バッテリー寿命を消耗させます。

Atomicsは、wait()notify()によって、はるかに効率的な解決策を提供します。

すべてをまとめる:実践ガイド

理論を理解したところで、SharedArrayBufferを使用したソリューションを実装する手順を見ていきましょう。

ステップ1:セキュリティの前提条件 - オリジン間分離

これは開発者にとって最も一般的なつまずきの石です。セキュリティ上の理由から、SharedArrayBufferオリジン間分離(cross-origin isolated)状態にあるページでのみ利用可能です。これは、共有メモリによって可能になる高精度タイマーを悪用してオリジン間でデータを漏洩させる可能性があるSpectreのような投機的実行の脆弱性を緩和するためのセキュリティ対策です。

オリジン間分離を有効にするには、メインドキュメントに対して2つの特定のHTTPヘッダーを送信するようにWebサーバーを設定する必要があります:


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

これは、特に必要なヘッダーを提供していないサードパーティのスクリプトやリソースに依存している場合、設定が難しいことがあります。サーバーを設定した後、ブラウザのコンソールでself.crossOriginIsolatedプロパティをチェックすることで、ページが分離されているか確認できます。これはtrueでなければなりません。

ステップ2:バッファの作成と共有

メインスクリプトで、SharedArrayBufferと、それに対する「ビュー」をInt32ArrayのようなTypedArrayを使って作成します。

main.js:


// まずオリジン間分離をチェック!
if (!self.crossOriginIsolated) {
  console.error("このページはオリジン間分離されていません。SharedArrayBufferは利用できません。");
} else {
  // 32ビット整数1つ分の共有バッファを作成。
  const buffer = new SharedArrayBuffer(4);

  // バッファのビューを作成。すべてのアトミック操作はビューに対して行われます。
  const int32Array = new Int32Array(buffer);

  // インデックス0の値を初期化。
  int32Array[0] = 0;

  // 新しいワーカーを作成。
  const worker = new Worker('worker.js');

  // 共有バッファをワーカーに送信。これはコピーではなく参照の転送です。
  worker.postMessage({ buffer });

  // ワーカーからのメッセージをリッスン。
  worker.onmessage = (event) => {
    console.log(`ワーカーが完了を報告しました。最終値: ${Atomics.load(int32Array, 0)}`);
  };
}

ステップ3:ワーカーでのアトミック操作の実行

ワーカーはバッファを受け取り、それに対してアトミックな操作を実行できるようになります。

worker.js:


self.onmessage = (event) => {
  const { buffer } = event.data;
  const int32Array = new Int32Array(buffer);

  console.log("ワーカーが共有バッファを受け取りました。");

  //いくつかのアトミック操作を実行してみましょう。
  for (let i = 0; i < 1000000; i++) {
    // 共有値を安全にインクリメントします。
    Atomics.add(int32Array, 0, 1);
  }

  console.log("ワーカーのインクリメントが終了しました。");

  // 完了したことをメインスレッドに通知します。
  self.postMessage({ done: true });
};

ステップ4:より高度な例 - 同期を伴う並列合計計算

より現実的な問題に取り組んでみましょう:複数のワーカーを使用して非常に大きな数値配列の合計を計算します。効率的な同期のためにAtomics.wait()Atomics.notify()を使用します。

私たちの共有バッファは3つの部分を持ちます:

main.js:


if (self.crossOriginIsolated) {
  const NUM_WORKERS = 4;
  const DATA_SIZE = 10_000_000;

  // [ステータス, 完了ワーカー数, 結果の下位, 結果の上位]
  // 大きな合計値のオーバーフローを避けるため、結果に2つの32ビット整数を使用します。
  const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4つの整数
  const sharedArray = new Int32Array(sharedBuffer);

  // 処理するためのランダムデータを生成
  const data = new Uint8Array(DATA_SIZE);
  for (let i = 0; i < DATA_SIZE; i++) {
    data[i] = Math.floor(Math.random() * 10);
  }

  const chunkSize = Math.ceil(DATA_SIZE / NUM_WORKERS);

  for (let i = 0; i < NUM_WORKERS; i++) {
    const worker = new Worker('sum_worker.js');
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, DATA_SIZE);
    
    // ワーカーのデータチャンク用に非共有のビューを作成
    const dataChunk = data.subarray(start, end);

    worker.postMessage({ 
      sharedBuffer,
      dataChunk // これはコピーされます
    });
  }

  console.log('メインスレッドはワーカーの終了を待機しています...');

  // インデックス0のステータスフラグが1になるのを待つ
  // これはwhileループよりずっと良い方法です!
  Atomics.wait(sharedArray, 0, 0); // sharedArray[0]が0の場合に待機

  console.log('メインスレッドが起こされました!');
  const finalSum = Atomics.load(sharedArray, 2);
  console.log(`最終的な並列合計は: ${finalSum}`);

} else {
  console.error('ページはオリジン間分離されていません。');
}

sum_worker.js:


self.onmessage = ({ data }) => {
  const { sharedBuffer, dataChunk } = data;
  const sharedArray = new Int32Array(sharedBuffer);

  // このワーカーのチャンクの合計を計算
  let localSum = 0;
  for (let i = 0; i < dataChunk.length; i++) {
    localSum += dataChunk[i];
  }

  // ローカルの合計を共有の合計値にアトミックに加算
  Atomics.add(sharedArray, 2, localSum);

  // 「完了したワーカー」カウンターをアトミックにインクリメント
  const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;

  // これが最後に終了するワーカーの場合...
  const NUM_WORKERS = 4; // 実際のアプリでは渡されるべきです
  if (finishedCount === NUM_WORKERS) {
    console.log('最後のワーカーが終了しました。メインスレッドに通知します。');

    // 1. ステータスフラグを1(完了)に設定
    Atomics.store(sharedArray, 0, 1);

    // 2. インデックス0で待機しているメインスレッドに通知
    Atomics.notify(sharedArray, 0, 1);
  }
};

実世界でのユースケースと応用

この強力だが複雑な技術は、実際にどこで違いを生むのでしょうか? それは、大規模なデータセットに対する、重く、並列化可能な計算を必要とするアプリケーションで優れています。

課題と最終的な考慮事項

SharedArrayBufferは変革をもたらしますが、万能薬ではありません。慎重な取り扱いを必要とする低レベルのツールです。

  1. 複雑さ:並行プログラミングは非常に難しいことで有名です。競合状態やデッドロックのデバッグは信じられないほど困難になることがあります。アプリケーションの状態がどのように管理されるかについて、異なる考え方をする必要があります。
  2. デッドロック:デッドロックは、2つ以上のスレッドが、それぞれが他方がリソースを解放するのを待って、永遠にブロックされたときに発生します。これは、複雑なロックメカニズムを誤って実装した場合に起こり得ます。
  3. セキュリティのオーバーヘッド:オリジン間分離の要件は大きなハードルです。サードパーティのサービス、広告、決済ゲートウェイが必要なCORS/CORPヘッダーをサポートしていない場合、それらとの統合が壊れる可能性があります。
  4. すべての問題に適しているわけではない:単純なバックグラウンドタスクやI/O操作には、postMessage() を使った従来のWeb Workerモデルの方がシンプルで十分な場合が多いです。大量のデータが関わる明確なCPUバウンドのボトルネックがある場合にのみ、SharedArrayBufferに手を出すべきです。

結論

SharedArrayBufferは、AtomicsやWeb Workerと連携して、Web開発のパラダイムシフトを象徴しています。それはシングルスレッドモデルの境界を打ち破り、新世代の強力で、高性能で、複雑なアプリケーションをブラウザに招き入れます。これにより、Webプラットフォームは、計算量の多いタスクにおいて、ネイティブアプリケーション開発とより対等な立場に立つことができます。

並行JavaScriptへの道のりは挑戦的であり、状態管理、同期、セキュリティに対する厳格なアプローチが要求されます。しかし、リアルタイムの音声合成から複雑な3Dレンダリング、科学計算まで、Webで可能なことの限界を押し広げようとする開発者にとって、SharedArrayBufferを習得することはもはや単なる選択肢ではなく、次世代のWebアプリケーションを構築するための必須スキルとなっています。