今更のWasm入門 with Go


やったこと

WebAssembly(Wasm:ワズム)を全く理解してなかったので、諸々調べたりするとともに、Go(TinyGo)を使って軽く動かしてみました。
目次はこんな感じです。

  • Wasmの概要とか、調査したこと
  • TinyGoについて
  • Javascriptと連携してブラウザ上で「Hello, Wasm!」
  • WASIランタイム(wasmtime)を使って「Hello, Wasm!」

※ 個人的な備忘録であるためもし何か誤り等ございましたらご指摘いただけますと嬉しいです🙇‍♂️

Wasmとは何か

MDNの説明が分かりやすいかなと思います。

WebAssembly はモダンなウェブブラウザーで実行できる新しいタイプのコードです。ネイティブに近いパフォーマンスで動作するコンパクトなバイナリー形式の低レベルなアセンブリ風言語です。さらに、 C/C++ や Rust のような言語のコンパイル対象となって、それらの言語をウェブ上で実行することができます。 WebAssembly は JavaScript と並行して動作するように設計されているため、両方を連携させることができます。

ふんわり理解としては、RustやC/C++、Go等をコンパイルできるターゲットで、ブラウザ上で動かせるナイスなやつです。
また、言語ランタイム自体をWasmバイナリに変換すれば、直接WasmにコンパイルできないC#、Python等もブラウザ上で動きます。
下記リポジトリに、直接コンパイルできないものも含めたWasm対応言語の一覧があります。

もちろんバイナリとは言えネイティブの機械語ではないのでWasmを解釈できる仮想マシン(VM)上で動かします。
例えばGoogle ChromeであればV8という仮想マシンが内蔵されています。
各社がさまざまな仮想マシンを実装しています1

asm.js同様JS高速化の文脈から登場した背景から、元々はブラウザで動かすことを想定していました。
が、Wasm自体はブラウザに依存しない「スタックベースの仮想マシンとそこで実行されるバイナリの仕様」です。
そのため、ブラウザ外で動かすための仕様(WASI)が策定される等、「Outside of the Browsers」な動きも活発化しています。
ブロックチェーン上で動かしたり、コンテナの代わりにKubernetes上で動かす話もあるようです。

Wasmの仮想マシン

繰り返しになりますが、Wasmはスタックベースの仮想マシンとそこで実行されるバイナリの仕様です。

「スタックベースの仮想マシンとそこで実行されるバイナリの仕様」

Wasm理解のためにはこの一文をしっかりと腹落ちさせる必要があります。

そもそも仮想マシンとは一体何ぞや。
東京大学のコンパイラ演習2に分かりやすく書いてありました。仮想マシンとは、

仮想的に構築されたプログラム実行環境
• 大きく2種類に分類できる
 – System VM
  • 物理的なコンピュータを模擬した仮想環境 – 例: VirtualBox, VMware など
 – Process VM
  • アプリケーションを動かすための仮想環境
   – 例: バイナリトランスレータなど
    » OSが資源を仮想記憶を通してプロセスに対し見せているのも広義にはProcess VMと言える

です。WasmはProcess VMに該当します。
Process VMは、それこそJavaのJVMとか、Ruby/Pythonのインタプリタとかです。
WasmもJVM等と同様に、インタプリタ的にバイトコードを読み込み逐次的に実行していくわけです。
バイトコードインタプリタを最も素朴に動かす方法を考えると、

  • バイトコードをプログラム中で開く(open("foo.wasm")みたいな感じ)
  • 以下、巨大なwhile文の中で実行
    • バイナリから命令を1行ずつ取得する
    • 命令を実行する(巨大なswitch文があり、命令と合致するcase内の処理を実行)
    • 次の命令を取得する
    • ...

といった形になるかと思います。
それぞれの命令は、Wasmのバイナリの仕様で定まった通りに解釈できるようにswitchを実装します。
ネイティブな機械語であれば物理CPUが命令を取得/実行しますが(fetch-decode-execut cycle)、仮想マシンであれば、任意の言語の上に実装した仮想的なCPUが命令を読み取り動作します(仮想的なCPUと聞くと大仰に聞こえるが、上記のwhile文も仮想的なCPUです。一旦はこのようなwhile文 = 仮想的なCPUを想定すると理解が進みます)。
任意の言語(ホスト)上で実行されているというイメージを持つことが非常に大事だと思います。
このイメージを持っていれば、ホスト関数がWasmから呼び出される原理も非常に腹落ちします(任意の言語上で読み込みながら逐次実行してるだけなので、ホスト関数が呼ばれる命令行に差し掛かったら、ホスト、すなわち自分の関数をそのまま実行するようにswitchすれば良いだけなので)。

次にスタックベースとは何か。
バクっと理解するなら「仮想マシンの命令セットにはスタックマシンとレジスタマシンという2種類の仕様がある」というイメージです。
Wasmの命令を実際に眺めてみると、何か演算するときにスタックに値をpush/popする命令になっていることが分かります。
それに対してレジスタマシン、例えばDalvik等は値をレジスタに読み込み、レジスタ同士で演算する命令になります。
仮想マシンは仮想的なCPUの仕様なので「どんなCPUを想定しているか」によって命令セットも変わります。
仮想的なCPUがレジスタを持たずスタックのみで演算するならスタックマシン、レジスタ上で演算するならレジスタマシンです。
この辺り、JVM(スタックベースの仮想マシン)とDalvik(レジスタベースの仮想マシン)を比較した下記スライドが分かりやすかったです。

Wasmを調べる内に、スタックマシンだとか線形メモリだとかホスト関数だとか色々な概念が出てきてメチャクチャに混乱してしまったんですが、結局、Wasmの仮想マシンとは単純に、

Wasmのバイナリファイルを実行する、仕様で定まった仮想マシン

であると理解できてからは、割と腹落ち感が生まれました3
もしWasm仮想マシンを作るとすれば、

  • まずはバイトコードインタプリタで実装してみる
  • (バイトコードなので)構文木を辿り評価する通常のインタプリタに比べ早い
  • とは言えネイティブの機械語には負けるので、速度を求めるならJITコンパイラを導入したりする4

Lucetみたいに事前コンパイルする例もあるようですが、基本的な流れはこんな感じだと思います。

なぜTinyGoを使うのか

コンパイル元の言語によってWasmのバイナリサイズは変わります。
ランタイムも一緒にコンパイルされてバイナリに入るためです。
例えばGoはランタイムが大きい(ガベージコレクション/goroutine/スケジューラ等を含む)ので、バイナリサイズが大きくなります。

TinyGoはランタイムを小さく実装したGoのサブセットであり、バイナリサイズが小さくなるためWasm向きです。
Goの公式ですらTinyGoの使用を推奨しています。

例えばこちらによるとGoをコンパイルしたWasmは1.3MBなのに対して、TinyGoであれば3.8KBまで小さくなっています。

TinyGOはGoのサブセット(部分集合)なので書き味はGoとほぼ変わりません。
コンパイラを変更したことで言語が小さくなっただけです。

完全な余談ですが、Goのランタイムとはもちろん仮想マシンのことではありません。
公式のFAQをGoogle翻訳で訳したものが下記です。

Goには、すべてのGoプログラムの一部であるランタイムと呼ばれる広範なライブラリがあります。ランタイムライブラリは、ガベージコレクション、同時実行性、スタック管理、およびGo言語の他の重要な機能を実装します。言語の中心ですが、GoのランタイムはlibcCライブラリに類似しています。

ただし、Goのランタイムには、Javaランタイムによって提供されるような仮想マシンが含まれていないことを理解することが重要です。Goプログラムは、事前にネイティブマシンコード(または、一部のバリアント実装の場合はJavaScriptまたはWebAssembly)にコンパイルされます。したがって、この用語はプログラムが実行される仮想環境を表すためによく使用されますが、Goでは「ランタイム」という言葉は重要な言語サービスを提供するライブラリに付けられた名前にすぎません。

GCについては、今後正式にサポートする提案が出ているみたいですが、現状はまだサポートされていないようです5

JSとWasmでHello, Wasm!

ここからは実際に動かしてみた編です。

準備

TinyGo

公式サイトの手順通りに入れます。
Go自体を事前に入れておく必要があったりするため、公式サイトの手順を必ず参照してください。
下記は飽くまで参考までに、です。

brew tap tinygo-org/tools
brew install tinygo

# 入ったか確認
tinygo version

Wabt

これは必須ではないですが、Wasmバイナリの中身を確認したい方はWabtを入れると良いかもです。
こちらもリポジトリに手順が書いてあります。
Wabtを入れるとwasm2wat等のコマンドが使えるようになります。

# wabtを落としてきてビルドする
git clone --recursive https://github.com/WebAssembly/wabt
cd wabt

mkdir build
cd build
cmake ..
cmake --build .

# パス通す
export PATH=$PATH:/Users/kawamou/private/wabt/bin

wasm_exec.js

wasm_exec.jsはいわゆる「グルーコード」です。
GoやTinyGoをコンパイルしたWasmがインポートするべきホスト関数等が定義されています6
wasm_exec.js内のホスト関数越しにブラウザとインタラクションするわけです。
例えばRustであればwasm-bindgen等でコンパイルするとグルーコードが自動生成されますが、Goの場合はグルーコードを外部に持つ運用にしているためリポジトリから直接取得する必要があるようです。

https://github.com/tinygo-org/tinygo/blob/release/targets/wasm_exec.js

にあるファイルをローカルに落とします。

実行

Hello, Wasmするだけなので大した分量はありませんがこちらのリポジトリに入れてます。

# 参考
# https://zenn.dev/tomi/articles/2020-11-10-go-web11

# コンパイルする
tinygo build -o guest.wasm -target wasm ./guest.go

# 吐き出されたWasmをみたいなら下記を実行し任意のエディタで確認
wasm2wat guest.wasm > guest.wat

# 実行
go run server.go

# 確認(ブラウザで下記リンクにアクセスし検証ツールでコンソールを確認)
http://localhost:8080

ブラウザでhttp://localhost:8080にアクセスし、検証ツールでコンソールを開きHello, Wasm!の文字が見えていれば成功です。

WASIランタイムからHello, Wasm!

準備

wasmtime

WASIランタイム7としてwasmtimeを使うので入れます。

curl https://wasmtime.dev/install.sh -sSf | bash

# 入ったか確認
wasmtime --version

上記は公式の手順通りbashをパイプに繋いでいますが、環境に合わせてシェルを読み替えてパスを通してあげます。
上記のコマンドではビルド済みのwasmtimeが落ちてきます。
wasmtimeをビルドするためにはRustの環境等も必要なので、ビルド済みを落とすのが吉です。

実行

こちらもリポジトリに格納しています。

# 参考
# https://github.com/bytecodealliance/wasmtime-go/blob/main/README.md

# コンパイルする
tinygo build -o guest.wasm -target wasi ./guest.go

# 吐き出されたWasmをみたいなら下記を実行し任意のエディタで確認
wasm2wat guest.wasm > guest.wat

# 実行する①
# 単純にwasmtimeの上で実行
wasmtime guest.wasm

# 実行する②
# wasmtimeのGoパッケージ上で実行
go run host.go

まとめ

Wasmについて理解が少し深まったように思います。
今後も何かあり次第加筆していこうと思います。


  1. Lucet, wasmtime, SSVM, Swam, V8, WAMR, wasm3, wasmer, wasmi, WAVM, Gasm等があるようです。 

  2. コンパイラ演習 第 11 回 (2011/12/22) リンクはコチラ(ダウンロードが走ります)。 

  3. 実際には仮想マシンを実装したわけでないし、各仮想マシンの実装をがっつり読んだわけではないので想像になってしまいますが、線形メモリとか諸々の要素も、仮想マシンを実装したプログラミング言語上のデータ構造(それこそ配列とか)で表現してやれば良いはず。 

  4. JITの実装を全く知らなかったので、当初は仮想マシンとJITコンパイラの関連性をイメージできませんでしたが、インタプリタ的に逐次実行しつつ、コンパイル済みの命令にぶち当たったら関数ポインタなりで呼んでやる感じだと何となく分かりました。 

  5. 提案はこちら。初心者すぎて勘違いしてましたが「Wasmは仕様としてGCをサポートしていない」ため「GoのGCをコンパイルしてWasmにしても動かないの?!」と思ってました。これは誤りで、仕様としてサポートされていないというだけで、もちろんGCもWasmにコンパイルすれば動かせます。GCをセルフ実装した例などもあるようです。 

  6. Wasmの仕様上、仮想マシンでは数値計算しかできないのでI/Oを行いたいと思えばホスト関数をインポートする必要があります。システムコール的にホスト関数が呼び出されたら、バイナリを実行しているホスト側が、呼び出されたホスト関数をそのまま実行するイメージです。で、実行した結果を戻してあげればホスト側の関数を実行できたことになります。Wasm理解の上では、全てホスト言語のデータ構造の上で起きている現象と理解することが重要だと感じます(線形メモリも配列等のデータ構造上に実装する) 

  7. WASIはホスト関数の仕様です。各々がI/Oできるホスト関数を実装してWasmから呼び出すことも可能ですが、仕様(関数名とか動作とか)を決めておけば同様の仕様を持つランタイム上で動かせるので便利です。ランタイム側で元々同名で同様の処理を行うホスト関数が準備されているということなので