はじめに
先日投稿した「うっかり世界最強のWasmコンパイラを作ってしまった件」が、気づけば2万PVを超え、多くの反響やスターをいただきました。読んでくださった方、本当にありがとうございます。
前回の記事では「Goライクな構文のまま、libcフリーでわずか2.56KBのWasmを作る」という話をしました。
しかし、あれで終わりではありません。
「ブラウザで動くなら、Web Workerを使ったマルチスレッド並行処理(Goでいう go 文やチャネルのような並行計算)もサクッと動かしたい」
そう考えて並行処理ランタイムを自作し、ブラウザ上で実際に動く技術デモページを公開しました。
- GitHub Pages 技術デモ: https://kanryu.github.io/hike-lang/
普通、WebAssemblyでマルチスレッドを扱おうとすると、バイナリサイズは平気でメガバイト単位へと跳ね上がります。
ところが、設計思想を根本から見直して逆算的にスレッド制御を組み上げた結果――
同期待ちを含むマルチスレッド並行計算が入ったWasmバイナリのサイズは、前回の2.56KBをさらに下回る「1.46KB」になってしまいました。
何かの間違いかと思うほど極小ですが、ブラウザのWorkerスレッド上でれっきとした並行計算がミリ秒未満で走り抜けます。今回は、なぜこれほど極小サイズでWasmのマルチスレッド並行処理が成立したのか、その裏側のアーキテクチャを公開します。
WASMマルチスレッドの「高すぎる壁」
これまでWebAssemblyでマルチスレッドを実現しようとすると、主に以下の2つのアプローチが主流でした。
-
pthread付きのCランタイムを丸ごとWASMへコンパイルする(Emscripten等)
POSIXスレッドをエミュレートするため、巨大なC標準ライブラリ(musl libc等)や仮想ファイルシステム、分厚いJSグルーコードを根こそぎ抱え込むことになり、最小限のコードでも数百KB〜数MBに膨らみます。 -
WASI-threads規格に乗っかる
標準化が進められている仕様ですが、ランタイム側の実装依存が大きく、ブラウザ環境へ持ち込むには重厚なポリフィルや複雑なビルドパイプラインが必要になります。
どちらのアプローチも共通しているのは、「OSネイティブのスレッドモデルやスケジューラを、WASMという箱庭の中で力技で再発明しようとしている」という点です。M:Nスケジューラや複雑な同期プリミティブをWASMバイナリ内部に丸抱えしようとするからこそ、バイナリが肥大化し、開発者にも重いトレードオフを強いていました。
コペルニクス的転回:「JSフックを叩く瞬間は完全な同期が取れている」
そこで発想を180度転換しました。
ブラウザには、世界最高峰のエンジニアたちが数十年にわたって磨き上げてきた「イベントループ」「Web Worker」「メッセージキュー」という極めて洗練された非同期・並行基盤が最初から備わっています。
WASMの内部にOSのスケジューラを模倣した仮想世界を作る必要はあるのか?
着目したのは、「WASMスレッドがJavaScript側から提供されたホストAPIを叩いている瞬間は、WASMスレッド間の完全な同期が取れている」という事実です。
- WASM側で複雑な同期ループを自前で回すのをやめる。
- スレッド起動(
hike_thread_spawn)や待機通知のトリガーは、薄いJavaScriptフックへ委譲する。 - WASM側の責務は「サンク関数ポインタを受け取って実行し、バッファに戻り値を格納する」という計算とメモリ配置だけに限定する。
この「ホスト(JS)の自然発生的な同期プレッシャーを活用する」という逆算的なスレッド制御を採ったことで、WASMバイナリから重厚なスケジューリングコードが根こそぎ消滅しました。
1.46KBを支える3つのアーキテクチャ
1. ホスト主導のメモリハンドシェイク
従来の静的メモリ確保を廃止し、WASMの起動時にホストへヒープサイズを問い合わせるプロトコルを採用しました。
; WASM側の起動処理(LLVM IR)
declare i32 @requestHeapSizeKB()
...
%v4 = call i32 @requestHeapSizeKB() ; JSホストに許可サイズを問い合わせ
WASM内部で固定長ヒープを抱え込まず、JavaScript側から「64KB許可」「256KB許可」と指定された分だけリニアメモリを拡張(memory.grow)して引き渡します。これにより、初期メモリの無駄を削ぎ落としながら、ブラウザのUIから動的にメモリ上限を調整できるようになりました。
2. runtime.js 1本への統合(ポリモーフィックランタイム)
Web Workerを使う設計では通常 main.js と worker.js の2ファイルが必要になりますが、Hikeではruntime.js ただ1本に集約しました。
// 実行コンテキストを自己検出
const isWorker = typeof importScripts === 'function' ||
(typeof window === 'undefined' && typeof self !== 'undefined');
if (isWorker) {
// Web Workerコンテキスト: WASMインスタンス化と並行タスクの実行コンテナ
self.onmessage = async (e) => { ... };
} else {
// メインスレッドコンテキスト: 自分自身(runtime.js)をWorkerとして起動
class HikeConcurrentRuntime {
async start() {
this.worker = new Worker('runtime.js?t=' + Date.now());
...
}
}
}
コンテキストを自動判定して振る舞いを変えることで、ファイルの取り違えやキャッシュの不整合を防ぎ、コンパイラからのファイル出力も極めてクリーンになります。
3. 直感的なGoライク構文
これだけ極限まで削ぎ落としていながら、書くコードはGo言語そのものの直感的な並行構文です。
package main
// JavaScriptホストに利用可能なヒープサイズ(KB単位)を問い合わせる
extern func requestHeapSizeKB() int
// メモリ確保関数(ランタイム提供)
extern func malloc(size int) *byte
// JavaScript側への結果通知コールバック
extern func displayResult(sum int, mul int)
func compute(a int, b int) (int, int) {
return a + b, a * b
}
func main() int {
// 1. JavaScriptホストに使用可能サイズ(KB)を問い合わせる
heapKB := requestHeapSizeKB()
// 2. JavaScriptが指定したサイズ分だけメモリを確保
heapBuffer := malloc(heapKB * 1024)
// 3. ワーカースレッドへタスクをオフロードし、<- で同期回収
sum, mul := <-Async(func() (int, int) {
return compute(15, 3)
})
// 4. 計算結果を画面UIへ反映
displayResult(sum, mul)
return 0
}
低レイヤの同期プリミティブやWorkerメッセージのハンドリングはすべてコンパイラと極小ランタイムが裏で解決するため、開発者は並行計算のロジックを書くだけで済みます。
【核心解説】わずか1行の裏で何が起きているのか?
sum, mul := <-Async(func() (int, int) {
return compute(15, 3)
})
パッと見はGo言語のゴルーチンやチャネル受信のような直感的なコードですが、この1行の裏では「関数のサンク化」「スレッドプールへのディスパッチ」「共有メモリを介した真のブロッキング待機」「多値アンパック」という高度なパイプラインがわずか数ナノ秒で連鎖しています。
ランタイムとWASMの内部挙動を3つのステップに分けて解説します。
1. Async(func() ...):無名関数のサンク化とタスク発行
Async組み込み関数に無名関数を渡した瞬間、コンパイラと極小ランタイムは以下の処理を協調して行います。
-
間接関数テーブルへの登録(サンク化):
渡された無名関数は、独立した関数としてコンパイルされ、WASMの関数テーブル(__indirect_function_table)に関数ポインタ(インデックス)として登録されます。 -
タスク記述子の確保:
ランタイムは共有リニアメモリ上に「タスク記述子(Task Descriptor)」を確保します。ここには「結果を書き込むメモリバッファのアドレス」「完了通知用のイベントID」が含まれます。 -
ホスト(JS)への非同期委譲:
WASM側からホストAPIhike_thread_spawn(funcIndex, paramPtr)を呼び出します。ブラウザのWeb Workerスレッドプールへタスクが投げられ、WASM側のAsync呼び出しは「タスクハンドル(記述子ポインタ)」を即座に返して次の行へ進みます。
2. sum, mul := <-...:同期回収とスタックへの多値アンパック
左矢印演算子 <- は、Hike言語における「タスクの完了を待機し、結果を取り出す同期演算子(Await)」です。
-
直感的な多値代入:
非同期関数が複数の戻り値(ここでは(int, int))を返す場合、タスク記述子に紐づく結果バッファから低レイヤで直接値を取り出し、呼び出し元のローカル変数sumとmulへ一括してアンパック代入します。 - 開発者は「コールバック地獄」や「Promiseチェーン」を一切書く必要がなく、同期コードと全く同じメンタルモデルで並行処理の結果を受け取れます。
3. コンカレントWASMにおける「同期待ち」の物理的な仕組み
Webブラウザ上でWASMを動かす際、最大の難所となるのが「ブラウザのメインスレッド(UIスレッド)は同期ブロック(待機)が仕様上許されていない」という制約です。同期ブロッキングを行うと、ブラウザ画面の描画やボタン操作が完全にフリーズしてしまうからです。
Hikeはこの問題を以下の設計で突破しています。
-
main()自体をMain Worker上で走らせるアーキテクチャ:
画面を描画するメインスレッド上で直接WASMを動かすのではなく、Hikeのmain()自体をバックグラウンドの「Main Worker」スレッドへ逃がして実行しています。 -
Atomics.waitによる真の同期スリープ:
Workerスレッド上であれば、共有メモリ(SharedArrayBuffer)を介した同期ブロッキングが完全に許可されます。
<-で結果を待つ間、メインプロセスはCPUを100%消費してぶん回すビジーループ(スピンロック)ではなく、Atomics.waitを叩いてスレッド自体を完全にスリープ(休止状態)に入れます。 -
通知による起床と再開:
裏で計算を担当していたサブスレッドが完了し、共有メモリに結果(18と45)を書き込んだ瞬間にAtomics.notifyが発行されます。スリープしていたメインプロセスはミリ秒未満で起床し、待機していた<-を抜けて即座に後続の処理を実行します。
「UIを1ミリも固めずに、WASMスレッド内では完全な同期ブロッキングを実現する」。このアーキテクチャがあるからこそ、Web上で <-Async(...) というGoライクな同期プログラミングが成立しています。
なぜ動く?ブラウザのセキュリティ関門(COOP / COEP)
技術デモをローカルやサーバーで動かす際、避けて通れないのがブラウザのセキュリティ仕様です。
WASMのマルチスレッドは、Worker間でメモリを共有する SharedArrayBuffer(WebAssembly.Memory({ shared: true }))と Atomics 命令に依存しています。しかし現代のブラウザは、Spectre等のサイドチャネル攻撃を防ぐため、以下のHTTPヘッダーを出力して「クロスオリジン分離」を行わない限り、共有メモリの作成をブロックします。
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
デモ環境ではこのヘッダーを付加することで、ブラウザの安全性を担保しつつ、WASMマルチスレッドの真のポテンシャルを解放しています。
実際に動くデモとリポジトリ
理論上の話ではなく、実際にブラウザ上でミリ秒未満で計算が終わる様子を体験できます。
- 技術デモページ: https://kanryu.github.io/hike-lang/
- GitHub リポジトリ: https://github.com/kanryu/hike-lang
巨大なCランタイムや重厚な規格の完成を待たなくても、ブラウザが本来持っているプリミティブを熟知し、境界を正しく設計すれば、わずか1.46KBで実用的なマルチスレッド言語環境は作れます。
「小さいことは正義」を突き詰めた自作言語の世界、ぜひデモページでボタンを押して体感してみてください。