Wasmでのマルチスレッド
WebAssemblyにおけるマルチスレッドの仕組み
ブラウザはデフォルトでシングルスレッドです。並列Wasmコードを実行するために、プラットフォームは2つのビルディングブロックを提供します:
メインスレッド Web Workers(スレッドプール)
┌─────────────────┐ ┌─────────────────┐
│ JavaScript │ │ Worker 1 │
│ + Wasmインスタンス│ │ Wasmインスタンス │
│ │ 生成 │ (共有メモリ) │
│ postMessage() ──┼───────>│ │
│ │ └─────────────────┘
│ │ ┌─────────────────┐
│ │ │ Worker 2 │
│ SharedArray ───┼───────>│ Wasmインスタンス │
│ Buffer │ │ (共有メモリ) │
│ │ └─────────────────┘
│ │ ┌─────────────────┐
│ │ │ Worker 3 │
│ Atomics.wait() │ │ Wasmインスタンス │
│ Atomics.notify()│<──────>│ Atomics操作 │
└─────────────────┘ └─────────────────┘SharedArrayBuffer
SharedArrayBufferは重要なプリミティブです。ArrayBufferとは異なり、コピーなしでメインスレッドとWorker間で共有できます。WasmのリニアメモリはSharedArrayBufferをバッキングとして使用でき、すべてのスレッドが同じメモリにアクセスできます:
// 共有Wasmメモリの作成
const memory = new WebAssembly.Memory({
initial: 256, // 256ページ(16 MB)
maximum: 4096, // 4096ページ(256 MB)
shared: true // ← SharedArrayBufferバッキングを有効化
});Atomics
Atomics APIは、Wasmのmemory.atomic.*命令に直接マッピングされる低レベル同期プリミティブを提供します:
| Atomicsメソッド | 目的 | Wasm相当 |
|---|---|---|
Atomics.load() |
共有値の読み取り | i32.atomic.load |
Atomics.store() |
共有値の書き込み | i32.atomic.store |
Atomics.add() |
アトミックインクリメント | i32.atomic.rmw.add |
Atomics.compareExchange() |
CAS操作 | i32.atomic.rmw.cmpxchg |
Atomics.wait() |
通知まで待機(futex) | memory.atomic.wait32 |
Atomics.notify() |
待機中のスレッドを起床 | memory.atomic.notify |
wasm-bindgen-rayon: Wasmでの並列イテレータ
rayonクレートはRustでデータ並列イテレータを提供します。wasm-bindgen-rayonアダプタはrayonのスレッドプールをWeb Workersに橋渡しします:
// Cargo.toml
// [dependencies]
// rayon = "1.8"
// wasm-bindgen-rayon = "1.2"
use rayon::prelude::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn parallel_sum(data: &[f64]) -> f64 {
data.par_iter().sum()
}
#[wasm_bindgen]
pub fn parallel_mandelbrot(width: u32, height: u32) -> Vec<u8> {
let pixels: Vec<u8> = (0..height)
.into_par_iter() // ← 並列イテレーション
.flat_map(|y| {
(0..width).map(move |x| compute_pixel(x, y, width, height))
})
.collect();
pixels
}セットアップ
// lib.rs — rayonを使う前にこれを呼ぶ必要がある
pub use wasm_bindgen_rayon::init_thread_pool;// JavaScript側
import init, { initThreadPool, parallel_sum } from './pkg/my_crate';
async function main() {
await init();
await initThreadPool(navigator.hardwareConcurrency);
// これでrayonのpar_iter()がWeb Workersを使います!
const result = parallel_sum(new Float64Array([1, 2, 3, 4, 5]));
}ビルド設定
# .cargo/config.toml
[target.wasm32-unknown-unknown]
rustflags = ["-C", "target-feature=+atomics,+bulk-memory,+mutable-globals"]
[unstable]
build-std = ["panic_abort", "std"]COOP/COEPヘッダー(必須!)
SharedArrayBufferはCross-Origin Isolationの制約を受けます。サーバーは必ず以下のヘッダーを送信する必要があります:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp ブラウザセキュリティモデル:
ヘッダーなし: ヘッダーあり:
┌──────────────────┐ ┌──────────────────┐
│ SharedArrayBuffer │ │ SharedArrayBuffer │
│ ブロック │ │ 許可 │
│ │ │ │
│ "SecurityError: │ │ Cross-origin │
│ SharedArray │ │ isolated = true │
│ Buffer is not │ │ │
│ defined" │ │ COOP: same-origin│
└──────────────────┘ │ COEP: require-corp│
└──────────────────┘サーバー設定の例
# Nginx
add_header Cross-Origin-Opener-Policy same-origin;
add_header Cross-Origin-Embedder-Policy require-corp;
# Apache (.htaccess)
Header set Cross-Origin-Opener-Policy "same-origin"
Header set Cross-Origin-Embedder-Policy "require-corp"
# Vite (vite.config.ts)
export default {
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
};スレッドが効果的な場合とそうでない場合
スレッドにはオーバーヘッド(Worker生成、同期、メッセージパッシング)が伴います。そのコストを償却できるほどの十分な作業量がある場合にのみ効果があります:
高速化
│
│ ● 理想的(線形)
│ ●╱
│ ●╱ ●── 実際(アムダールの法則)
│ ●╱ ●
│ ●╱ ●
│●╱ ● ●── 収穫逓減
│╱●
│●
├───────────────────── スレッド数
1 2 4 8 16
アムダールの法則: 高速化 = 1 / (S + P/N)
S = 逐次部分の割合
P = 並列部分の割合 (S + P = 1)
N = スレッド数| ワークロード | スレッドは効果的? | 理由 |
|---|---|---|
| マンデルブロ描画(1024x1024) | はい | 完全並列、CPU集約型 |
| 画像フィルタ(大きな画像) | はい | 各ピクセルが独立 |
| 1000要素のソート | いいえ | 小さすぎ、オーバーヘッド > 節約 |
| JSONパース | いいえ | ほぼ逐次的 |
| 行列乗算(大規模) | はい | 行をスレッド間で分割 |
| DOM操作 | いいえ | メインスレッドで実行する必要あり |
| 物理シミュレーション(1000+物体) | はい | 各物体の更新が独立 |
| 短い文字列のSHA-256 | いいえ | 逐次アルゴリズム、微小入力 |
経験則: 逐次処理で約5ms未満の作業であれば、スレッドのオーバーヘッドが利得を打ち消します。
スレッドプールアーキテクチャ
┌─────────────────────────────────────────────────────────┐
│ メインスレッド │
│ │
│ 1. initThreadPool(4) │
│ ├── Worker 1を生成 ──┐ │
│ ├── Worker 2を生成 ──┤ Workersは同じ.wasmを │
│ ├── Worker 3を生成 ──┤ 共有メモリでロード │
│ └── Worker 4を生成 ──┘ │
│ │
│ 2. parallel_sum(data) │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ rayonワークスティーリングスケジューラ │ │
│ │ │ │
│ │ タスクキュー: [chunk1][chunk2]... │ │
│ │ │ │
│ │ W1 ← steal ← W2 ← steal ← W3 │ │
│ │ │ │
│ │ 各Workerはアトミックload/storeを通じて│ │
│ │ 共有メモリのチャンクを処理 │ │
│ └──────────────────────────────────────┘ │
│ │
│ 3. 結果がメインスレッドに返される │
└─────────────────────────────────────────────────────────┘ブラウザサポート
| ブラウザ | SharedArrayBuffer | Wasmスレッド | 状態 |
|---|---|---|---|
| Chrome 91+ | 対応 | 対応 | フルサポート |
| Firefox 79+ | 対応 | 対応 | フルサポート |
| Safari 15.2+ | 対応 | 対応 | フルサポート |
| Edge 91+ | 対応 | 対応 | フルサポート(Chromium) |
| Node.js 16+ | 対応 | 対応 | --experimental-wasm-threads |
| Deno 1.9+ | 対応 | 対応 | フルサポート |
機能検出
function supportsWasmThreads() {
try {
// SharedArrayBufferをチェック
new SharedArrayBuffer(1);
// Atomicsをチェック
if (typeof Atomics === 'undefined') return false;
// Cross-Origin Isolationをチェック
if (!crossOriginIsolated) return false;
// Wasmスレッドサポートをチェック
const mem = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true });
return mem.buffer instanceof SharedArrayBuffer;
} catch {
return false;
}
}実践例: 並列画像処理
use rayon::prelude::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn blur_image(pixels: &mut [u8], width: usize, height: usize, radius: usize) {
let input = pixels.to_vec();
// 行を並列処理
pixels
.par_chunks_mut(width * 4)
.enumerate()
.for_each(|(y, row)| {
for x in 0..width {
let (mut r, mut g, mut b, mut count) = (0u32, 0u32, 0u32, 0u32);
for dy in -(radius as i32)..=(radius as i32) {
for dx in -(radius as i32)..=(radius as i32) {
let ny = (y as i32 + dy).clamp(0, height as i32 - 1) as usize;
let nx = (x as i32 + dx).clamp(0, width as i32 - 1) as usize;
let idx = (ny * width + nx) * 4;
r += input[idx] as u32;
g += input[idx + 1] as u32;
b += input[idx + 2] as u32;
count += 1;
}
}
let idx = x * 4;
row[idx] = (r / count) as u8;
row[idx + 1] = (g / count) as u8;
row[idx + 2] = (b / count) as u8;
// アルファは変更なし
}
});
}まとめ
Wasmマルチスレッドは、共有メモリにSharedArrayBufferを、スレッドプールにWeb Workersを使用します。wasm-bindgen-rayonクレートにより、通常のRust並列コードを書く感覚で使えます — iter()の代わりにpar_iter()を使うだけです。COOP/COEPヘッダーの設定、+atomicsでのビルド、そしてスレッドのオーバーヘッドを克服するのに十分な大きさのワークロードのみを並列化することを忘れないでください。2024年現在、ほとんどのモダンブラウザがWasmスレッドを完全にサポートしています。