Article

SharedArrayBuffer実装で並列処理を高速化

高田晃太郎
SharedArrayBuffer実装で並列処理を高速化

最新のブラウザ利用環境では、cross-origin isolation(クロスオリジン分離)を満たしたセッションでSharedArrayBuffer(共有メモリ)が広く利用できるようになり、Web WorkersとAtomicsを用いたJavaScriptの並列処理が現実解となってきました。Chrome、Firefox、Safariはいずれも条件付きでSharedArrayBufferをサポートし[1]、Atomics APIにより排他制御や待機通知といったマルチスレッドの基本をブラウザ上で安全に扱えます[4]。画像処理や圧縮、物理シミュレーションのようなデータ並列タスクでは、環境によっては単一スレッド対比で数倍の短縮が報告されており[8]、CPUコア数に比例したスケールアウトが期待できます。

Spectre以降、研究コミュニティとブラウザベンダはサイト分離と共有メモリの安全性を議論してきましたが、WebプラットフォームはCOOP/COEP(Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy)でプロセス分離を強化し、SharedArrayBufferを段階的に再解禁しました[2,3]。つまり、適切にレスポンスヘッダーを設定し、クロスオリジンリソースの取り扱いを整理すれば、JavaScriptだけでブラウザ内のデータ並列ワークロードを高速化できます。フロントエンドの前処理やエンコード、統計集計の一部などをクライアント側に寄せることで、体感性能とバックエンド負荷の両面でメリットが見込めます[7]。

本稿では、前提となるセキュリティ設定、メモリモデルの要点、実装パターン、完全なコード例、そしてベンチマークとチューニングの勘所までを一気通貫で解説します。CTOやエンジニアリーダーが投資効果を判断しやすいよう、開発工数の目安やフォールバック戦略にも触れながら、Web WorkersやWebAssemblyとの組み合わせによるパフォーマンス最適化の実践知をまとめます。

SharedArrayBufferを安全に使うための前提

SharedArrayBufferの有効化には、ページをクロスオリジン分離(cross-origin isolated)にすることが鍵です。ブラウザはCross-Origin-Opener-Policy(同一オリジン間でウィンドウを分離)とCross-Origin-Embedder-Policy(埋め込みリソースの取り扱い制限)の組み合わせで安全性を担保し、要件を満たすとwindow.crossOriginIsolatedがtrueになります[2,1]。実運用では静的配信のヘッダー設定が最大のハードルになりがちなので、まずはここを確実に通すことが第一歩です。

クロスオリジン分離の実装と配信設定

アプリ配信での最低限の設定例を示します。Node.js/Expressで静的配信する場合、COOP/COEPヘッダーを付与し、必要に応じてCORS許可済みリソースか同一オリジンのみを埋め込む方針に切り替えます。CDNを使う場合も同等のレスポンスヘッダーが出るように調整してください[2]。

// server.mjs
import express from 'express';

const app = express();

app.use((req, res, next) => {
  // SharedArrayBufferの安全要件
  res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
  res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
  next();
});

// .wasm などを同一オリジンから配信する場合の参考(任意)
app.use(express.static('public', {
  setHeaders: (res, path) => {
    if (path.endsWith('.wasm')) {
      res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
    }
  }
}));

app.listen(3000, () => {
  console.log('http://localhost:3000');
});

同様にNginxやCloudflare/CDNでもレスポンスヘッダーを揃えれば機能します。third-partyのスクリプトやiframeを多用している場合は、COEP: credentiallessの採用やリソースのホワイトリスト化といった移行計画が必要です[5]。背景や設定の全体像は、web.devのCOOP/COEPガイドがわかりやすい参考になります[2]。

メモリモデルとAtomicsの要点

SharedArrayBufferは複数のWeb Worker(バックグラウンドスレッド)から同一のメモリ領域を同時参照できる仕組みです[1]。このメモリはTypedArray(Int32ArrayやUint8Arrayなどの型付き配列)を介してアクセスします。整合性はAtomics(アトミック操作)で確保し、Atomics.addやAtomics.compareExchangeで競合を制御、Atomics.wait/notifyで効率よくスレッド間同期が可能です[4]。共有メモリの可視性と順序性を常に意識し、busy loopによるスピンは避け、必要な箇所のみwait/notifyで眠らせると、消費電力とスケジューリング効率のバランスが取りやすくなります[4]。

// atomics-basics.mjs
// 共有カウンタの安全なインクリメント例
const counterSAB = new SharedArrayBuffer(4);
const counter = new Int32Array(counterSAB);

function nextChunk(size) {
  // 現在値を返しつつ size を加算。戻り値が担当する開始インデックスになる
  return Atomics.add(counter, 0, size);
}

// 別スレッドから
const start = nextChunk(128);
// [start, start+128) を担当する、という取り決めで競合をなくす

ブラウザの対応状況は成熟しており、主要ブラウザの現行版で条件付きながら実用可能です[1]。機能検出は条件分岐で十分に表現でき、window.crossOriginIsolatedがfalseならSharedArrayBufferベースの経路を無効化し、従来のコピー型メッセージング(postMessageでバッファを複製)にフォールバックする設計にします。

実装パターンと完全なサンプル

ここからは実装パターンを具体化します。最も効果が出やすいのは、独立な要素に同型の計算を適用するデータ並列です。画像フィルタやレイトレーシング、圧縮、シミュレーションなどが典型例です[8]。例としてマンデルブロ集合の描画をSABで並列化し、測定まで行うためのサンプルを示します。

並列マンデルブロ計算(メインスレッド)

メイン側は共有バッファと制御用カウンタを生成し、モジュールワーカーを起動して処理を配分します。仕事の割り当てはAtomics.addを用いたグローバルカウンタにするとシンプルでスケールします。

// main.mjs
import { WorkerPool } from './worker-pool.mjs';

const WIDTH = 2048;
const HEIGHT = 2048;
const MAX_ITER = 100;
const WORKERS = Math.max(2, navigator.hardwareConcurrency || 4);

if (!crossOriginIsolated || typeof SharedArrayBuffer === 'undefined') {
  console.warn('SAB未対応のためフォールバック経路を使用します');
  // コピー型メッセージングの実装へ退避(後述)
}

// 出力ピクセルと同期用カウンタ
const pixelsSAB = new SharedArrayBuffer(Uint32Array.BYTES_PER_ELEMENT * WIDTH * HEIGHT);
const pixels = new Uint32Array(pixelsSAB);
const counterSAB = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const counter = new Int32Array(counterSAB);
counter[0] = 0; // 処理する行の先頭インデックスを共有

// ワーカープール起動
const pool = new WorkerPool(new URL('./mandelbrot-worker.mjs', import.meta.url), WORKERS);

const params = { width: WIDTH, height: HEIGHT, maxIter: MAX_ITER, pixelsSAB, counterSAB, chunkRows: 4 };

console.time('mandelbrot-parallel');
await pool.broadcast('start', params);
await pool.whenAll('done');
console.timeEnd('mandelbrot-parallel');

// OffscreenCanvas が使えればワーカー側で描画まで行える。ここではメインでImageData化する
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(WIDTH, HEIGHT);
for (let i = 0; i < pixels.length; i++) {
  const p = pixels[i];
  imageData.data[i * 4 + 0] = (p >> 24) & 0xff;
  imageData.data[i * 4 + 1] = (p >> 16) & 0xff;
  imageData.data[i * 4 + 2] = (p >> 8) & 0xff;
  imageData.data[i * 4 + 3] = p & 0xff;
}
ctx.putImageData(imageData, 0, 0);

並列マンデルブロ計算(ワーカー)

ワーカー側は共有カウンタから行のチャンクを取得し続け、範囲外に達したら終了します。各ピクセルの色計算は純粋関数に閉じ込め、メモリ帯域を抑えるために4行程度をひとかたまりに処理すると良好なバランスになります。

// mandelbrot-worker.mjs
// モジュールワーカー(type: 'module')

self.addEventListener('message', (ev) => {
  const { type, payload } = ev.data;
  if (type === 'start') {
    compute(payload).then(() => {
      self.postMessage({ type: 'done' });
    }).catch((e) => {
      self.postMessage({ type: 'error', error: String(e) });
    });
  }
});

async function compute({ width, height, maxIter, pixelsSAB, counterSAB, chunkRows }) {
  const pixels = new Uint32Array(pixelsSAB);
  const counter = new Int32Array(counterSAB);
  // 各スレッドが自律的に仕事を取りにいく
  while (true) {
    const startRow = Atomics.add(counter, 0, chunkRows);
    if (startRow >= height) break;
    const endRow = Math.min(startRow + chunkRows, height);
    for (let y = startRow; y < endRow; y++) {
      const cy = (y / height) * 2.8 - 1.4;
      for (let x = 0; x < width; x++) {
        const cx = (x / width) * 3.5 - 2.5;
        let zx = 0, zy = 0, i = 0;
        while (zx * zx + zy * zy <= 4.0 && i < maxIter) {
          const xt = zx * zx - zy * zy + cx;
          zy = 2 * zx * zy + cy;
          zx = xt;
          i++;
        }
        const idx = y * width + x;
        const color = i === maxIter ? 0x000000ff : ((i * 9) & 0xff) << 24 | ((i * 7) & 0xff) << 16 | ((i * 5) & 0xff) << 8 | 0xff;
        pixels[idx] = color;
      }
    }
  }
}

ワーカープールは汎用化しておくと他のワークロードにも使い回せます。メッセージの型を揃え、ブロードキャストと同期点のAPIを持たせると、アプリ側のコードが整理され再利用性が高まります。

スレッドプール抽象化と再利用

// worker-pool.mjs
export class WorkerPool {
  constructor(moduleUrl, size) {
    this.size = size;
    this.workers = Array.from({ length: size }, () => new Worker(moduleUrl, { type: 'module' }));
    this.pending = new Map();
    this.workers.forEach((w, i) => {
      w.addEventListener('message', (ev) => {
        const { type } = ev.data || {};
        const q = this.pending.get(type);
        if (!q) return;
        q.count++;
        if (q.count === this.size) {
          q.resolve();
          this.pending.delete(type);
        }
      });
      w.addEventListener('error', (e) => console.error('Worker error', i, e));
      w.addEventListener('messageerror', (e) => console.error('Message error', i, e));
    });
  }
  broadcast(type, payload) {
    this.workers.forEach((w) => w.postMessage({ type, payload }));
    return Promise.resolve();
  }
  whenAll(type) {
    return new Promise((resolve) => {
      this.pending.set(type, { resolve, count: 0 });
    });
  }
  terminate() {
    this.workers.forEach((w) => w.terminate());
  }
}

コピー型メッセージングとの互換も保ちたい場合は、SharedArrayBufferを使わないワーカーを差し替えられるよう同じインターフェースに揃えておくと移行が容易です。構造化複製での転送は大きなバッファほどコピーコストが支配的になるため、SAB経路ではそのオーバーヘッドが消え、実行時間とガベージコレクションの負荷が安定しやすくなります[1]。設計パターンは公式ドキュメントやブラウザベンダの解説を参照するとよいでしょう[1,4]。

ベンチマークとチューニング結果

比較評価は、対象のワークロードとハードウェアによって差が出ます。例えば2048x2048ピクセル・最大反復100回といった条件のマンデルブロ計算では、並列実装が単一スレッドに対して数倍の短縮となるケースが多く報告されています[8,7]。一方、計算密度が低いフィルタやメモリアクセス主体の処理では、メモリ帯域とスレッド起動コストが支配的になり、効果は限定的になる点に注意が必要です。

チューニングの要点としては、チャンクサイズ(1回に割り当てる行数)を小さすぎず大きすぎない範囲に収めることが重要です。多くの環境で4〜8行程度に設定すると、排他回数を抑えつつ負荷の偏り(スレッド間の“仕事の取り合い”のばらつき)を軽減できます。メモリの分割は行単位の連続領域に固定し、複数スレッドが同一キャッシュラインを頻繁に更新しないようにするのがコツです。画像処理ならば出力は一度だけ書き込み、読み取りは局所的に済ませる方針が安定します。

ベンチマーク用の簡易ハーネスも示します。計測にはperformance.nowを用い、ウォームアップを入れて再現性を確保します。結果の読み解きや比較は、一般的なWebアプリ性能最適化レビュー[9]の考え方を援用すると良いでしょう。

// bench.mjs
import { WorkerPool } from './worker-pool.mjs';

async function runParallel() {
  const WIDTH = 2048, HEIGHT = 2048, MAX_ITER = 100;
  const pixelsSAB = new SharedArrayBuffer(Uint32Array.BYTES_PER_ELEMENT * WIDTH * HEIGHT);
  const counterSAB = new SharedArrayBuffer(4);
  new Int32Array(counterSAB)[0] = 0;
  const pool = new WorkerPool(new URL('./mandelbrot-worker.mjs', import.meta.url), 8);
  const t0 = performance.now();
  await pool.broadcast('start', { width: WIDTH, height: HEIGHT, maxIter: MAX_ITER, pixelsSAB, counterSAB, chunkRows: 4 });
  await pool.whenAll('done');
  const t1 = performance.now();
  pool.terminate();
  return t1 - t0;
}

async function runSingleThread() {
  // 単一スレッドの同等処理(省略可)。ここではワーカーのロジックを関数として実行
  const WIDTH = 2048, HEIGHT = 2048, MAX_ITER = 100;
  const pixels = new Uint32Array(WIDTH * HEIGHT);
  const t0 = performance.now();
  for (let y = 0; y < HEIGHT; y++) {
    const cy = (y / HEIGHT) * 2.8 - 1.4;
    for (let x = 0; x < WIDTH; x++) {
      const cx = (x / WIDTH) * 3.5 - 2.5;
      let zx = 0, zy = 0, i = 0;
      while (zx*zx + zy*zy <= 4.0 && i < MAX_ITER) {
        const xt = zx*zx - zy*zy + cx;
        zy = 2*zx*zy + cy;
        zx = xt;
        i++;
      }
      const idx = y * WIDTH + x;
      pixels[idx] = i === MAX_ITER ? 0x000000ff : ((i * 9) & 0xff) << 24 | ((i * 7) & 0xff) << 16 | ((i * 5) & 0xff) << 8 | 0xff;
    }
  }
  const t1 = performance.now();
  return t1 - t0;
}

(async () => {
  // ウォームアップ
  await runParallel();
  const p = await runParallel();
  const s = await runSingleThread();
  console.log({ parallelMs: p.toFixed(1), singleMs: s.toFixed(1), speedup: (s/p).toFixed(2) + 'x' });
})();

よりヘビーな数値計算では、WebAssemblyのスレッドサポート(pthreads)とSharedArrayBufferの組み合わせが有効です。Emscriptenの-pthreadオプションでビルドし、同一のSABをバックエンドに利用する方式は、JavaScript実装よりさらに高速化する事例が多数紹介されています[6,7]。SIMD最適化との相乗効果も期待できます[7]。

エラー処理・キャンセル・フォールバック

プロダクションではワーカーのエラー、メッセージ不整合、タイムアウトを適切に扱う必要があります。AbortControllerでキャンセル可能にしつつ、crossOriginIsolatedでない環境へのフォールバックを同一の呼び出し面から切り替えられるようにします。

// robust.mjs
import { WorkerPool } from './worker-pool.mjs';

export async function runJob({ useSABPreferred = true, signal } = {}) {
  const sabOK = crossOriginIsolated && typeof SharedArrayBuffer !== 'undefined';
  const useSAB = useSABPreferred && sabOK;

  if (!useSAB) {
    return runFallback(); // コピー型メッセージングの実装
  }

  const pool = new WorkerPool(new URL('./job-worker.mjs', import.meta.url), Math.max(2, navigator.hardwareConcurrency || 4));

  const onAbort = () => {
    pool.terminate();
    throw new DOMException('Aborted', 'AbortError');
  };
  signal?.addEventListener('abort', onAbort, { once: true });

  try {
    const params = {/* 共有バッファ等 */};
    await pool.broadcast('start', params);
    const timer = setTimeout(() => {
      console.warn('Worker timeout, terminating...');
      pool.terminate();
    }, 30000);
    await pool.whenAll('done');
    clearTimeout(timer);
  } catch (e) {
    console.error('Job failed', e);
    throw e;
  } finally {
    signal?.removeEventListener('abort', onAbort);
  }
}

フォールバック側はSharedArrayBufferをTypedArrayに置き換え、postMessageでコピーする設計に揃えます。APIを不変にしておけば、将来的にサイト全体をCOEP運用に移したタイミングで、フラグ一つでSAB経路に切り替えられます。

ビジネスインパクトと導入計画

SharedArrayBufferの価値は、ブラウザ側に安全な並列実行基盤が整うことで、サーバー負荷の分散とユーザー体感速度の双方を改善する余地が生まれる点にあります。例えば、画像サムネイル生成やエンコード、統計的集計の一部をクライアントで先行実行すれば、バックエンドのピーク負荷を抑制できる可能性があります。最近のレビューでも、適切なフロントエンド最適化はアプリケーション全体の資源効率と応答性向上に寄与することが指摘されています[9]。もちろん処理の性質やデータの機微性に依存するため、PIIや機密情報を扱うタスクは依然としてサーバー側で実施すべきです。

導入期間の目安は、既存にWorkerベースの実装があるなら短いスプリント数でSharedArrayBuffer対応まで到達できることが多く、ゼロからの導入でも配信ヘッダーの調整、ワーカープールの抽象化、フォールバック設計、メトリクス実装まで含めて数週間〜の計画が現実的です。配信面の移行はCOOP/COEPの適用が最難関ですが、コンテンツセキュリティポリシーの整理とthird-partyの棚卸しを並行して進めれば、段階的ロールアウトは十分に可能です[2,5]。レンダリングパスへの統合やキャンバスへの転送は、OffscreenCanvasの活用でメインスレッドのブロッキングを避けられます。

最後に、SharedArrayBuffer導入はチームのパフォーマンス文化にも好影響を与えます。明示的なメトリクスを用意し、ベンチマークを継続運用し、回帰チェックをCIに組み込むことで、性能を偶然ではなく再現可能な品質として扱えるようになります。Worker設計やメモリ管理のベストプラクティスは、公式ドキュメントやブラウザ開発者ブログの知見をチーム標準としてドキュメント化しておくと属人性を抑えられます[1,3,4]。

まとめ

ブラウザが本格的に並列計算を受け止められる今、SharedArrayBufferとAtomicsはフロントエンドの性能設計における強力な選択肢です。安全要件を守り、ワーカープールと共有メモリを組み合わせるだけで、多くのデータ並列タスクは単一スレッドの壁を超えられます[1,4]。マンデルブロの例のような高い計算密度の処理では数倍の短縮が期待でき、他の画像処理や数値処理でも同様のスケールが見込めます[8]。

まずはクロスオリジン分離の適用と、SAB経路とフォールバック経路を同じAPIの裏側に実装することから始めてください。ひとつの画面や処理単位で効果検証を行い、ベンチマークとユーザー体感の両面で改善が認められたら、段階的に適用範囲を広げるとリスクを抑えられます。次の一歩として、配信設定の見直しと小さなワークロードの並列化から試してみませんか。関連する公式リソース(COOP/COEP[2]、Atomics/SAB[1,4]、WebAssemblyの並列化[6,7])も併読し、チームの標準としてドキュメント化していきましょう。

参考文献

  1. SharedArrayBuffer - JavaScript | MDN Web Docs
  2. Eiji Kitamura. Making your website “cross-origin isolated” using COOP and COEP. web.dev
  3. Jake Archibald, Eiji Kitamura. SharedArrayBuffer updates in Android Chrome 88 and Desktop Chrome 92. Chrome Developers Blog
  4. Atomics - JavaScript | MDN Web Docs
  5. Cross-Origin-Embedder-Policy (COEP) response header | MDN Web Docs
  6. Pthreads support — Emscripten
  7. Bruno Couriol. Boosting WebAssembly Performance with SIMD and Multi-Threading. InfoQ
  8. Lars T. Hansen. A Taste of JavaScript’s New Parallel Primitives. Mozilla Developer Blog
  9. Albornoz et al. Overview of Web Application Performance Optimization Techniques. arXiv preprint (2024)