9月28日、Cloudflareが「Supporting native Rust in Workers with the new Emscripten target for wasm-bindgen」と題した記事を公開した。Cloudflare Workersで、Tokioのasyncエコシステムに依存するネイティブRustコードを動かすための新しいアプローチを詳述した内容だ。
その実力を端的に示すのが、記事末尾で紹介されているデモだ。RustネイティブのMinecraftサーバー実装であるPumpkinを、Workers上のDurable Object(Cloudflareが提供するサーバーレス向けステートフル実行環境)でTCPイングレス付きで動作させることに成功した。本物のTCPソケットをTokio経由で使用しており、「Workersでどこまで動くか」の現時点での最大値を示している。
なぜこれが難しかったか
wasm-bindgenはRustベースのWebAssemblyアプリケーションをWorkers上で動かすための中心的なツールチェーンだ。一方、EmscriptenはGoogleがメンテナンスするオープンソースのWasmコンパイラツールチェーンで、タイマー・ファイルシステム・ソケットといったネイティブ機能をWeb環境上で仮想化する。C/C++製アプリケーションをWasm化する際に長年使われてきた実績がある。
問題は、wasm-bindgenとEmscriptenがどちらも「自分がビルドを主導し、JavaScriptを生成する」という前提で設計されていた点にある。両者を同時に使うことができず、どちらか一方を選ぶしかなかった。これがRustの豊富なネイティブライブラリをWorkers上で使う際の根本的な壁となっていた。
この解決に取り組んだのはGoogleのPortable ToolchainsチームのMitch Foleyと同僚のYifan Yangだ。1年以上前に始まったこの取り組みは、Cloudflareエンジニアとの協力を経てようやく結実した。設計の核心は役割分担にある:Emscriptenがビルドを主導しWasmモジュールをロードする一方、wasm-bindgenはEmscriptenのライブラリシステムに直接インクルード可能な小さなポータブルなJSバインディングを生成する。
新たに追加された -sWASM_BINDGEN 設定フラグにより、以下の2パターンが可能になった:
- EmscriptenドリブンのC++コードから、wasm-bindgenのRustコードを静的ライブラリとして利用する
- Rustのコンパイラがドライブするwasm-bindgenアプリを、Emscriptenターゲット向けにビルドする
詳細はwasm-bindgenのEmscriptenドキュメントを参照。
Tokioをイベントループと共存させる
Workers上でTokioを動かす際の本質的な難しさは、アーキテクチャの衝突にある。WorkersはシングルスレッドのJSイベントループ上で動作するのに対し、Tokioのasyncランタイムはスレッドのパーキングセマンティクス(非同期I/Oの待機中にスレッドをスリープさせ、次のイベントが来るまで待機する仕組み)を前提としている。JSイベントループをブロックすることはできないため、そのまま組み合わせることができない。
Cloudflareはこの問題に対して2つのアプローチで対処している。
アプローチ1: JSPI(WebAssembly JavaScript Promise Integration)
JSPIは同期的な操作でWasmスタックをサスペンドし、制御をJSイベントループに返す仕組みで、Tokioのパーキングセマンティクスと直接対応する。ただし、スタックスイッチが発生するたびにスレッドローカルなTokioランタイムコンテキスト(現在実行中のランタイムへの参照)を退避・復元する必要があり、完全な再入可能(reentrant)な対応には追加の実装が必要だ。現在、TokioおよびEmscriptenチームとともに上流へのマージ作業が進行中だ。
アプローチ2: LocalEventLoop
より汎用的な解として提案されているのが LocalEventLoop だ。通常のTokioランタイムがスレッドをパークして待機するところを、代わりに「ホストイベントループへの通知」に置き換える設計だ。
通常のTokioランタイムでは、I/O待機中にスレッドがブロックされ、JSイベントループが止まってしまう:
let rt = Builder::new_current_thread().enable_all().build()?;
rt.block_on(async {
let mut stream = TcpStream::connect(addr).await?;
let mut buf = [0u8; 1024];
// I/O待機中はスレッドがパークし、JSイベントループをブロックしてしまう
let n = stream.read(&mut buf).await?;
Ok::<_, io::Error>(())
})?;
LocalEventLoop を使うと、待機中の制御がホストに返るため共存が可能になる:
let el = Builder::new_current_thread()
.enable_all()
.build_local_event_loop(Default::default(), host_waker)?;
el.spawn_local(async {
let mut stream = TcpStream::connect(addr).await?;
let mut buf = [0u8; 1024];
// 待機中は制御がホストに戻る
let n = stream.read(&mut buf).await?;
Ok::<_, io::Error>(())
});
spawn_local は即座にリターンし、ホストイベントループが el.drive() 呼び出しでタスクを進める。データが来るまでランタイムは単純にリターンし、ソケットが読み取り可能になるとTokioが host_waker を通じてホストに通知する。この設計はWorkers固有のものではなく、GTKのメインループ、Win32メッセージポンプ、Cocoaのランループにも同様に組み込める汎用性を持つ。
ソケット対応:40本超のPRでEmscriptenに実装
Tokioの net 機能(TCP、UDP、Unixソケット)を動かすには、Tokioが内部で使う非同期I/O監視機構のEmscripten上での対応が必要だった。
Cloudflareエンジニアはこれを40本以上のプルリクエストをEmscriptenに送ることで解決した。その成果が新しい -sNODERAWSOCKETS コンパイルオプションだ。WorkersはすでにNode.js互換APIとして node:net を実装しているため、この層がそのままWorkersでのソケットブリッジとしても機能する。
ライブラリ互換性の現状
wasm32-unknown-emscripten ターゲット対応後、多くのRustライブラリはそのまま動作したという。EmscriptenがRustで target_family = unix をすでにサポートしていることが大きい。libc、socket2、mio などの低レベルライブラリへのパッチも、既存のプラットフォームゲートに target_os = "emscripten" を追加する程度の軽微なものが大半で、各ライブラリのメンテナも概ね受け入れに積極的だったとしている。
現在のパッチセットとサンプルアプリケーションは試験的な公開プレビューとして利用可能だ。
詳細はSupporting native Rust in Workers with the new Emscripten target for wasm-bindgenを参照していただきたい。