JavaScriptで真のマルチスレッドを解放。本ガイドでは、SharedArrayBuffer、Atomics、Web Worker、そして高性能Webアプリのためのセキュリティ要件を包括的に解説します。
JavaScript SharedArrayBuffer: Webにおける並行プログラミングの徹底解説
何十年もの間、JavaScriptのシングルスレッドという性質は、そのシンプルさの源であると同時に、重大なパフォーマンスのボトルネックでもありました。イベントループモデルはほとんどのUI主導のタスクで見事に機能しますが、計算量の多い処理に直面すると苦戦します。時間のかかる計算はブラウザをフリーズさせ、ユーザーにフラストレーションのたまる体験をもたらします。Web Workerはスクリプトをバックグラウンドで実行させることで部分的な解決策を提供しましたが、それ自体に大きな制限がありました。それは非効率なデータ通信です。
そこで登場するのがSharedArrayBuffer
(SAB)です。これはWeb上でスレッド間の真の低レベルメモリ共有を導入することで、根本的にゲームのルールを変える強力な機能です。Atomics
オブジェクトと組み合わせることで、SABはブラウザで直接、新時代の高性能な並行アプリケーションを切り開きます。しかし、大きな力には大きな責任、そして複雑さが伴います。
このガイドでは、JavaScriptにおける並行プログラミングの世界を深く掘り下げていきます。なぜそれが必要なのか、SharedArrayBuffer
とAtomics
がどのように機能するのか、対処しなければならない重要なセキュリティ上の考慮事項、そして実際に始めるための実践的な例を探ります。
旧世界:JavaScriptのシングルスレッドモデルとその限界
解決策の価値を理解する前に、問題を完全に理解しなければなりません。ブラウザでのJavaScriptの実行は、伝統的に「メインスレッド」または「UIスレッド」と呼ばれる単一のスレッドで行われます。
イベントループ
メインスレッドは、JavaScriptコードの実行、ページのレンダリング、ユーザーインタラクション(クリックやスクロールなど)への応答、CSSアニメーションの実行など、すべてを担当します。これらのタスクはイベントループを用いて管理され、メッセージ(タスク)のキューを継続的に処理します。あるタスクの完了に時間がかかると、キュー全体がブロックされます。他の何も実行できなくなり、UIはフリーズし、アニメーションはカクつき、ページは応答しなくなります。
Web Worker:正しい方向への一歩
Web Workerは、この問題を緩和するために導入されました。Web Workerは、本質的には別のバックグラウンドスレッドで実行されるスクリプトです。重い計算をワーカーにオフロードすることで、メインスレッドをユーザーインターフェースの処理に専念させることができます。
メインスレッドとワーカー間の通信は、postMessage()
APIを介して行われます。データを送信すると、それは構造化複製アルゴリズムによって処理されます。これは、データがシリアライズされ、コピーされ、そしてワーカーのコンテキストでデシリアライズされることを意味します。これは効果的ですが、大規模なデータセットに対しては重大な欠点があります:
- パフォーマンスのオーバーヘッド:メガバイト、あるいはギガバイトものデータをスレッド間でコピーするのは遅く、CPU負荷が高い処理です。
- メモリ消費:メモリ内にデータの複製を作成するため、メモリが限られたデバイスでは大きな問題となり得ます。
ブラウザ上のビデオエディタを想像してみてください。ビデオフレーム全体(数メガバイトになることもある)を処理のために毎秒60回ワーカーとやり取りするのは、法外にコストがかかるでしょう。これこそが、SharedArrayBuffer
が解決するために設計された問題なのです。
ゲームチェンジャー:SharedArrayBuffer
の導入
SharedArrayBuffer
は、ArrayBuffer
に似た、固定長の生のバイナリデータバッファです。決定的な違いは、SharedArrayBuffer
は複数のスレッド(例:メインスレッドと1つ以上のWeb Worker)間で共有できるという点です。postMessage()
を使ってSharedArrayBuffer
を「送信」するとき、コピーを送っているのではなく、同じメモリブロックへの参照を送っているのです。
これは、あるスレッドがバッファのデータに加えた変更が、そのバッファへの参照を持つ他のすべてのスレッドから即座に見えることを意味します。これにより、コストのかかるコピーとシリアライズのステップが不要になり、ほぼ瞬時のデータ共有が可能になります。
このように考えてみてください:
postMessage()
を使うWeb Worker:これは、2人の同僚がメールで文書のコピーを送り合って作業するようなものです。変更のたびに、全く新しいコピーを送る必要があります。SharedArrayBuffer
を使うWeb Worker:これは、2人の同僚が共有オンラインエディタ(Google Docsなど)で同じ文書を編集するようなものです。変更はリアルタイムで両者に見えます。
共有メモリの危険性:競合状態
瞬時のメモリ共有は強力ですが、並行プログラミングの世界からの古典的な問題、競合状態(race conditions)も引き起こします。
競合状態は、複数のスレッドが同時に同じ共有データにアクセスして変更しようとし、最終的な結果がそれらの実行される予測不可能な順序に依存する場合に発生します。SharedArrayBuffer
に保存された単純なカウンターを考えてみましょう。メインスレッドとワーカーの両方がそれをインクリメントしたいとします。
- スレッドAが現在の値「5」を読み取ります。
- スレッドAが新しい値を書き込む前に、オペレーティングシステムがスレッドAを一時停止し、スレッドBに切り替えます。
- スレッドBが現在の値を読み取りますが、それはまだ「5」です。
- スレッドBは新しい値(6)を計算し、それをメモリに書き戻します。
- システムはスレッドAに切り替わります。スレッドAはスレッドBが何かをしたことを知りません。中断したところから再開し、新しい値(5 + 1 = 6)を計算して、メモリに「6」を書き戻します。
カウンターは2回インクリメントされたにもかかわらず、最終的な値は7ではなく6です。これらの操作はアトミック(atomic)ではありませんでした。つまり、中断可能であり、データの損失につながったのです。これこそが、SharedArrayBuffer
をその重要なパートナーであるAtomics
オブジェクトなしでは使えない理由です。
共有メモリの守護者:Atomics
オブジェクト
Atomics
オブジェクトは、SharedArrayBuffer
オブジェクトに対してアトミックな操作を行うための静的メソッドのセットを提供します。アトミックな操作は、他のどの操作にも中断されることなく、完全に実行されることが保証されます。それは完全に実行されるか、全く実行されないかのどちらかです。
Atomics
を使用することで、共有メモリに対する読み取り-変更-書き込み操作が安全に実行されることを保証し、競合状態を防ぎます。
主要なAtomics
メソッド
Atomics
が提供する最も重要なメソッドのいくつかを見てみましょう。
Atomics.load(typedArray, index)
: 指定されたインデックスの値をアトミックに読み取り、それを返します。これにより、完全で破損していない値を読み取っていることが保証されます。Atomics.store(typedArray, index, value)
: 指定されたインデックスに値をアトミックに格納し、その値を返します。これにより、書き込み操作が中断されないことが保証されます。Atomics.add(typedArray, index, value)
: 指定されたインデックスの値に、ある値をアトミックに加算します。その位置にあった元の値を返します。これはx += value
のアトミック版です。Atomics.sub(typedArray, index, value)
: 指定されたインデックスの値から、ある値をアトミックに減算します。Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: これは強力な条件付き書き込みです。index
にある値がexpectedValue
と等しいかチェックします。等しい場合、それをreplacementValue
に置き換え、元のexpectedValue
を返します。等しくない場合は何もしないで、現在の値を返します。これは、ロックのようなより複雑な同期プリミティブを実装するための基本的な構成要素です。
同期:単純な操作を超えて
時には、安全な読み書き以上のことが必要になります。スレッド同士が協調し、お互いを待つ必要があるのです。一般的なアンチパターンは「ビジーウェイト」で、スレッドがタイトなループにとどまり、メモリ上の位置の変更を常にチェックし続けることです。これはCPUサイクルを無駄にし、バッテリー寿命を消耗させます。
Atomics
は、wait()
とnotify()
によって、はるかに効率的な解決策を提供します。
Atomics.wait(typedArray, index, value, timeout)
: これはスレッドにスリープ状態になるよう指示します。index
にある値がまだvalue
であるかチェックします。もしそうなら、スレッドはAtomics.notify()
によって起こされるか、オプションのtimeout
(ミリ秒単位)に達するまでスリープします。index
の値が既に変更されている場合は、即座にリターンします。スリープ中のスレッドはCPUリソースをほとんど消費しないため、これは非常に効率的です。Atomics.notify(typedArray, index, count)
: これはAtomics.wait()
を介して特定のメモリ位置でスリープしているスレッドを起こすために使用されます。最大でcount
個の待機中のスレッドを起こします(count
が提供されないかInfinity
の場合はすべて起こします)。
すべてをまとめる:実践ガイド
理論を理解したところで、SharedArrayBuffer
を使用したソリューションを実装する手順を見ていきましょう。
ステップ1:セキュリティの前提条件 - オリジン間分離
これは開発者にとって最も一般的なつまずきの石です。セキュリティ上の理由から、SharedArrayBuffer
はオリジン間分離(cross-origin isolated)状態にあるページでのみ利用可能です。これは、共有メモリによって可能になる高精度タイマーを悪用してオリジン間でデータを漏洩させる可能性があるSpectreのような投機的実行の脆弱性を緩和するためのセキュリティ対策です。
オリジン間分離を有効にするには、メインドキュメントに対して2つの特定のHTTPヘッダーを送信するようにWebサーバーを設定する必要があります:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
(COOP): ドキュメントのブラウジングコンテキストを他のドキュメントから分離し、それらがあなたのwindowオブジェクトと直接対話するのを防ぎます。Cross-Origin-Embedder-Policy: require-corp
(COEP): ページによって読み込まれるすべてのサブリソース(画像、スクリプト、iframeなど)が、同一オリジンであるか、Cross-Origin-Resource-Policy
ヘッダーまたはCORSでクロスオリジンとして明示的にロード可能とマークされていることを要求します。
これは、特に必要なヘッダーを提供していないサードパーティのスクリプトやリソースに依存している場合、設定が難しいことがあります。サーバーを設定した後、ブラウザのコンソールで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つの部分を持ちます:
- インデックス 0: ステータスフラグ(0 = 処理中, 1 = 完了)。
- インデックス 1: 完了したワーカーの数を数えるカウンター。
- インデックス 2: 最終的な合計値。
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);
}
};
実世界でのユースケースと応用
この強力だが複雑な技術は、実際にどこで違いを生むのでしょうか? それは、大規模なデータセットに対する、重く、並列化可能な計算を必要とするアプリケーションで優れています。
- WebAssembly (Wasm): これがキラーユースケースです。C++、Rust、Goのような言語はマルチスレッドの成熟したサポートを持っています。Wasmを使用すると、開発者はこれらの既存の高性能なマルチスレッドアプリケーション(ゲームエンジン、CADソフトウェア、科学モデルなど)をブラウザで実行するためにコンパイルでき、スレッド通信の基盤メカニズムとして
SharedArrayBuffer
を使用します。 - ブラウザ内データ処理:大規模なデータ可視化、クライアントサイドでの機械学習モデルの推論、および膨大な量のデータを処理する科学シミュレーションを大幅に高速化できます。
- メディア編集:高解像度画像にフィルターを適用したり、音声ファイルにオーディオ処理を施したりする作業をチャンクに分割し、複数のワーカーで並列処理することで、ユーザーにリアルタイムのフィードバックを提供できます。
- 高性能ゲーム:現代のゲームエンジンは、物理演算、AI、アセットのロードにマルチスレッドを多用しています。
SharedArrayBuffer
により、完全にブラウザ内で動作するコンソール品質のゲームを構築することが可能になります。
課題と最終的な考慮事項
SharedArrayBuffer
は変革をもたらしますが、万能薬ではありません。慎重な取り扱いを必要とする低レベルのツールです。
- 複雑さ:並行プログラミングは非常に難しいことで有名です。競合状態やデッドロックのデバッグは信じられないほど困難になることがあります。アプリケーションの状態がどのように管理されるかについて、異なる考え方をする必要があります。
- デッドロック:デッドロックは、2つ以上のスレッドが、それぞれが他方がリソースを解放するのを待って、永遠にブロックされたときに発生します。これは、複雑なロックメカニズムを誤って実装した場合に起こり得ます。
- セキュリティのオーバーヘッド:オリジン間分離の要件は大きなハードルです。サードパーティのサービス、広告、決済ゲートウェイが必要なCORS/CORPヘッダーをサポートしていない場合、それらとの統合が壊れる可能性があります。
- すべての問題に適しているわけではない:単純なバックグラウンドタスクやI/O操作には、
postMessage()
を使った従来のWeb Workerモデルの方がシンプルで十分な場合が多いです。大量のデータが関わる明確なCPUバウンドのボトルネックがある場合にのみ、SharedArrayBuffer
に手を出すべきです。
結論
SharedArrayBuffer
は、Atomics
やWeb Workerと連携して、Web開発のパラダイムシフトを象徴しています。それはシングルスレッドモデルの境界を打ち破り、新世代の強力で、高性能で、複雑なアプリケーションをブラウザに招き入れます。これにより、Webプラットフォームは、計算量の多いタスクにおいて、ネイティブアプリケーション開発とより対等な立場に立つことができます。
並行JavaScriptへの道のりは挑戦的であり、状態管理、同期、セキュリティに対する厳格なアプローチが要求されます。しかし、リアルタイムの音声合成から複雑な3Dレンダリング、科学計算まで、Webで可能なことの限界を押し広げようとする開発者にとって、SharedArrayBuffer
を習得することはもはや単なる選択肢ではなく、次世代のWebアプリケーションを構築するための必須スキルとなっています。