Zigのコミッタの方から有益なフィードバックを頂いたり、Wasmランタイムのメンテナからコメントしてもらったり、Twitterでプロ開発者に助けてもらったり、と良い話が多かったのでそのへんの話も含めつつメモとして残しておきます。
最初に断っておきますがWasmにもZigにも特に詳しくないです。
概要
自分の開発しているOSSでWebAssemblyによるプラグイン機能に対応したのですが*1、Wasmは仕様が小さく関数の引数や戻り値もi32/i64/f32/f64だけで頑張るみたいな世界なので直接ユーザに生のWasm用コードを書いてもらうのは利便性の面で厳しいです。*2
そうなると各言語ごとにSDKを用意する必要があるわけですが、自分の開発しているOSSがGoで書かれていることもあり最初はTinyGoに対応しました。GoだとWASI対応していないのでGo+WASIをやりたい場合は必然的にTinyGoになるかと思われます。TinyGoだとWasmもシュッとビルドできて便利なのですが、Goの標準パッケージで対応されていないものがそこそこあって(v0.25.0時点)何も考えずに普通にGoを書くときの気持ちでいるとビルドできないことが多いです。
上のドキュメントを見てもらえれば分かりますが encoding/json
などが使えなかったりします。あと自分が開発していた時はTinyGo v0.23.0だったためGoのジェネリクスに対応しておらず、適当にパッケージをimportすると死ぬということが多発しました。さらにエラーだとジェネリクスが原因というのがぱっと分からずしばらくハマったりしました。v0.24.0でジェネリクス対応されたようなので今はもう少し楽になっているとは思いますし開発も盛んなのでどんどん便利になっていくと思いますが、自分の用途ではそこまでリッチな機能は不要だったのでWasm用に何か仕様の小さな言語はないかなと探していました。
そこで最近話題になっていたZigを試してみたという記事です。
Zigがどういう言語かというのは最近日本語でも記事が多く出ているので割愛しますが、1日頑張って読めば終えられるぐらいチュートリアルが短いです。
Zig + Wasmの記事はいくつか見つけたのですが大体Hello worldに留まっていました。ちょうど自分がZigを書き始めようとしたタイミングでCloudflareのブログも公開されたので「これは!」と思って中を見たらHello worldだったのでく〜〜〜となってました。
挨拶されすぎて世界も困惑してますよ!!!ということでもう少し実践的な内容を自分でやってみたのでメモです。
実装
GoのWasmランタイム
Wasmを動かすためのランタイムとしてはWasmer や Wasmtime が有名ですが、どちらもRustで書かれています。上述したように自分のOSSはGoで書かれているためGoで使えるランタイムを探していました。Go用の Wasmer Go なども見つけたのですが、CGOを使っており絶対にCGOを使わないポリシーを持つ自分のOSSでは採用できませんでした。
そうしてしばらく探していたところTetrateにより開発されているwazeroが見つかりました。
CGOを使わずにPure Goで書かれています。さらに凄いことに依存ライブラリも0なので go.mod/go.sum
が空です。まさに探していたプロジェクトなので導入を決めました。まだバージョン1.0.0に到達していないので破壊的変更が入ることもありますが、ソースコードもそこまで巨大ではないので自分で読んで何とか出来るサイズなのも嬉しいです。日本人の方( @mathetakeさん )が開発しています。世界に貢献していて凄いですね。READMEにも書かれていますが、元々個人で開発されていたOSSを会社に移しています。
TinyGoのExample
wazeroにはTinyGoとRustのサンプルが付いています。
今回はTinyGoのサンプルを見てみます。ただコードは変更される可能性があるので基本的にはGitHubを参考にしてください。
ランタイムの初期化
詳細はコードを直接見てもらったほうが早いですが、呼び出す側は以下のようにランタイムを初期化します。
// Create a new WebAssembly Runtime. r := wazero.NewRuntimeWithConfig(wazero.NewRuntimeConfig(). // Enable WebAssembly 2.0 support, which is required for TinyGo 0.24+. WithWasmCore2()) defer r.Close(ctx) // This closes everything this Runtime created.
以下のように logString
というGoの関数を log
という名前でWasm側にexportすることも出来ます。なおエラー処理はブログ内では全て省略しています。
// Instantiate a Go-defined module named "env" that exports a function to // log to the console. _, err := r.NewModuleBuilder("env"). ExportFunction("log", logString). Instantiate(ctx, r) }
関数の実行
あとはコンパイルされたWasmモジュールを受け取ってexportされた関数を取得できます。以下では greet
という名前でWasm側からexportされている関数を呼び出せるように取得しています。
mod, err := r.InstantiateModuleFromBinary(ctx, greetWasm) if err != nil {...} // Get references to WebAssembly functions we'll use in this example. greet := mod.ExportedFunction("greet")
あとはこの greet
を呼び出すわけですが、引数としてstringを渡したいとします。Wasmは関数の引数として直接文字列を受け取ることは出来ないので、Linear Memory (または単にMemory)と呼ばれる任意のバイト列を書込み可能な領域に値を書き込んでそのポインタとサイズを渡します。
// Instead of an arbitrary memory offset, use TinyGo's allocator. Notice // there is nothing string-specific in this allocation function. The same // function could be used to pass binary serialized data to Wasm. results, err := malloc.Call(ctx, nameSize) if err != nil {...} namePtr := results[0] // This pointer is managed by TinyGo, but TinyGo is unaware of external usage. // So, we have to free it when finished defer free.Call(ctx, namePtr) // The pointer is a linear memory offset, which is where we write the name. if !mod.Memory().Write(ctx, uint32(namePtr), []byte(name)) {...}
コメントに書いてありますが、Wasmを呼び出す側が適当にメモリを確保するよりはTinyGoのメモリアロケータを使うほうが良いようです。自分でメモリを管理するのは大変ですし合理的な感じがします。実際TinyGoは malloc
と free
をexportしてくれているのでこれを使えるのですが、ドキュメントに記載されていない闇関数とのことです。全然関係ないですが遊戯王ど真ん中世代なので高橋和希先生の訃報は悲しかったです。
何にせよ上の例では malloc
にサイズを渡してメモリを確保し、そのポインタにバイト列(ここでは文字列)を書き込んでいます。あとはそのポインタとサイズを greet
に渡すだけです。
// Now, we can call "greet", which reads the string we wrote to memory!
_, err = greet.Call(ctx, namePtr, nameSize)
次にこのポインタとサイズを受け取るWasm側を見てみます。
//export greet func _greet(ptr, size uint32) { name := ptrToString(ptr, size) greet(name) }
ptrToString
を呼んでstringに変換してそれをgreetという関数で表示しています。greet内は単にホスト側のロギング関数を呼んでいるだけなので、 ptrToString
の中を見てみます。
// ptrToString returns a string from WebAssembly compatible numeric types // representing its pointer and length. func ptrToString(ptr uint32, size uint32) string { // Get a slice view of the underlying bytes in the stream. We use SliceHeader, not StringHeader // as it allows us to fix the capacity to what was allocated. return *(*string)(unsafe.Pointer(&reflect.SliceHeader{ Data: uintptr(ptr), Len: uintptr(size), // Tinygo requires these as uintptrs even if they are int fields. Cap: uintptr(size), // ^^ See https://github.com/tinygo-org/tinygo/issues/1284 })) }
Goのsliceはdata, len, capで構成されているので受け取ったポインタとサイズをそれらに代入しています。ちなみにLenとCapはintで定義されているにも関わらず uintptr
を渡しているのでGoだとビルドできません。逆にTinyGoでWasmビルドする時は uintptr
じゃないとダメでした。
今回はstringを渡しましたが、適当なオブジェクトをシリアライズしてバイト列にして渡す際も同様にポインタとサイズを渡すだけなので、この方法で任意の型を渡せます。受け取る側はデシリアライズすればOKです。
別の渡し方
上の例では
- ホスト側から
malloc
を呼び出す - その領域に値を書き込む
- Wasm側にポインタとサイズを渡す
という順序で処理を行いましたが、
- Wasm側でメモリを確保する
- ホスト側の関数にそのポインタを渡す
- ホスト側で値を入れて返す
という方法も使われるようです。GitHubに転がっているサンプルをいくつか見てるとこっちのほうが多い感じもします。以下はProxy-Wasm SDK Goの例です。
var raw *byte var size int st := internal.ProxyDequeueSharedQueue(queueID, &raw, &size) if st != internal.StatusOK { return nil, internal.StatusToError(st) } return internal.RawBytePtrToByteSlice(raw, size), nil
この例では raw
と size
のポインタをホスト側に渡し、値を入れて返してもらったものを []byte
に変換しています。
事前に長さを知った上で malloc
を呼び出してメモリ確保できるので前者の方法の方が効率的に見えるのですが、あまりこの辺の差は分かってないです。
Zigの実装
ではこのTinyGoのサンプルを参考にZigで実装してみます。
ちなみにZig初心者のくせにせっかく作ったしwazeroにPR出しちゃおう、と無邪気に投げたところZigのコミッタの方がフィードバックを下さり、自分の書いたものより圧倒的に良くなりました。初心者のクソコードをレビューしてくださるなんて感謝しかないです。こういう事が起こるので、初心者だし...と恐れず行動してみるのが大事です。OSSメンテナのレビューの手間を増やすなという意見もあるかもしれませんが、別にダメならメンテナはクローズするだけなので申し訳ないとか思わずに飛び込んでみて良いと思っている派です。
注意点としてはWASIではないという点です。上のPR内のコメントを見てもらえれば分かるのですが、WASIだとまた色々と異なるのでいずれPRを出そうと思います。いくつかハマりどころがあるので元気があればブログも書きます。
malloc
自分の見た限りZigでは malloc
等はexportされていなさそうなので自分で定義します。 export
を fn
の前に付けるとexport出来るようです。
pub export fn malloc(length: usize) ?[*]u8 { const memory = allocator.alloc(u8, length) catch return null; return memory.ptr; }
この allocator
は std.heap.page_allocator
を使っているのですが、targetがWasmの場合は WasmPageAllocator
に切り替わるように見えます。Zig歴4日目なので嘘かもしれません。ziglearnを読んでた時間も含めると6日目ぐらいです。
zig/heap.zig at ae16c1d0835b517cb858cb9615888cdab1e351c5 · ziglang/zig · GitHub
つまり allocator
で適当にメモリを確保すればWasmのMemoryをいい感じに使ってくれるので、普通のZigプログラムを書くのと同じノリで行けるのかなと理解しています。ただZig歴4日目なので(略)
ちなみにこの memory.ptr
を返すのが大事で、memory
変数自体は恐らくスタックに確保されるので &memory
のようにしてしまうと関数を抜けたタイミングでmemory変数は消されてしまって不安定な挙動になります。不安定と言ったのは -O ReleaseSafe
とかだと動くのに -O Debug
にするとpanicになっていたためです。Debug
の方が積極的にスタックを消すのでしょうか。ちなみにこれは困っていたら@mattn_jpさんが助けてくださいました。
あんま自信ないですが、memory 変数そのものはスタックで、memory.ptr が実際にヒープに取られたメモリなんだと思います。
— mattn (@mattn_jp) 2022年8月2日
以前マスタケさんにZigのProxy-Wasm SDKを教えてもらっていたのでコードを読んでいて、何で memory.ptr
にしてるんだろうと思ったのですがZig歴1日目だったので適当に &memory
にしてガチャガチャやっていて死んでいました。以下はProxy-Wasm Zig SDKの例です。
pub export fn proxy_on_memory_allocate(size: usize) [*]u8 { const memory = allocator.alloc(u8, size) catch unreachable; return memory.ptr; }
このサンプルもマスタケさんが書いたもので、自分がZigの調査を始めた時に教えて下さいました。「まさかZigのSDKも...?」「あるよ」とHEROのバーのマスターを思い出しました。*3
Trivy Module Zig SDKの参考になれば!https://t.co/gbsyp9UXZT
— Takeshi Yoneda(マスタケ) (@mathetake) 2022年7月22日
また、自分のオリジナルのコードは以下のように @ptrToInt
を使ってusizeを返していました。
pub export fn malloc(size: usize) usize { var memory = allocator.alloc(u8, size) catch unreachable; return @ptrToInt(memory.ptr); }
ただポインタはただのメモリオフセットでしかないから [*]u8
を戻り値にすれば良いと教えていただき @ptrToInt
を使わない形になりました。Wasmには [*]u8
という型はないので(裏側ではusizeと同じだとしても)コンパイル時に怒られるのかなと思っていたのですが、Wasmをターゲットにしても特に問題なしでした。
free
以下のようになります。
pub export fn free(buf: [*]u8, length: usize) void { allocator.free(buf[0..length]); }
こちらも最初はusizeを受け取って @intToPtr
していたのですが、 [*]u8
で受け取ってslicingすれば良いと教えてもらって今の形になりました。
さらに以前は [*]u8
を free
に渡せばいいかと思ったのですが怒られました。
/usr/local/Cellar/zig/0.9.1_1/lib/zig/std/mem.zig:2755:9: error: expected []T or *[_]T, passed [*]u8 @compileError("expected []T or *[_]T, passed " ++ @typeName(sliceType));
ptrToString
最初は先程のTinyGoの例と同様、ポインタとサイズを受け取って []u8
に変換していました。以下は自分のオリジナルコードです。
pub fn ptrToString(ptr: u32, size: u32) []u8 { var s: []u8 = undefined; s.ptr = @intToPtr([*]u8, ptr); s.len = size; return s; }
ただ、こちらも上のfreeの例同様、 [*]u8
を引数にしてしまえばslicingだけでstring( []u8
)になるので不要と教わりました。修正後は以下になります。
pub export fn greet(message: [*]const u8, size: u32) void { const name = _greeting(message[0..size]) catch unreachable; _greet(name); }
messageの型が [*]const u8
になっていて、それを message[0..size]
のようにslicingすることで []u8
に変換しています。とてもシンプルに書けて最高です。
stringToPtr
上の ptrToString
とは逆でZig内の文字列をポインタとサイズに変換してホスト側に返します。
// stringToPtr returns a pointer and size pair for the given string in a way // compatible with WebAssembly numeric types. pub fn stringToPtr(s: []const u8) u64 { const p: u64 = @ptrToInt(s.ptr); return p << 32 | s.len; }
u64を返している理由ですが、WebAssembly 1.0では一つしか戻り値を返せないためです。
In the current version of WebAssembly, at most one value is allowed as a result. However, this may be generalized to sequences of values in future versions.
WebAssembly 2.0では複数の戻り値も返せるはずですが、まだDraftですし今回は1.0準拠で行きます。
Result types classify the result of executing instructions or functions, which is a sequence of values, written with brackets.
Types — WebAssembly 2.0 (Draft 2022-08-04)
ポインタとサイズの2つを返したいのに返せないので、u64に2つのu32を入れて返しています。ちなみに上の例で一旦u64の変数に入れてからじゃないと << 32
は動きませんでした。
@ptrToInt(s.ptr) << 32 | s.len;
上のように書くと以下のようなエラーが出ます。
./src/main.zig:68:32: error: integer value 32 cannot be coerced to type 'u5' return @ptrToInt(s.ptr) << 32 | s.len;
ビットシフトしたい変数の型により制限を受けるようです。バグのような感じがしますが、とりあえず一旦大きい型を持つ変数に入れてあげたら動きました。
呼び出し側
基本的にTinyGoで見たサンプルコードと同じで動きます。
_, err = greet.Call(ctx, namePtr, nameSize)
サンプル
ここまで歯抜けで説明してきたので何の話をしているか意味不明だったかもしれないのですが、コード全体を見れば普通に理解できると思います。上のPRを取り込んでもらったので以下でコード全体を読むことが出来ます。
まとめ
GoのWasmランタイムであるwazero上で、Zigで書いたコードをWasmにコンパイルして動かしてみました。単なるHello worldレベルではなくメモリを確保してsliceをGo<=>Wasmで相互にやり取りするというところまで確かめました。上述したように任意のオブジェクトをシリアライズすればやり取りできるので大体のことは何とかなります。ここまで出来ると大分実用的な気がします。
正直Zigは始めたばかりですしWasmも特別詳しいわけでもないのでエアプなブログを書いている気がしますが、せっかくZigのコミッタやWasmランタイムのメンテナ達からレビューしてもらったので初学者の悪戦苦闘の記録として公開しておきます。Twitterでプロ開発者に助けてもらったりもしたので、このサンプルを書くためだけに多くの人に手助けしてもらっています。まさかり投げられそうで怖いですが、オープンに活動すると誰かが助けてくれたりして色々捗るのでおすすめです。
Zigは始めたばかりなのに思ったよりハマりどころが少なくて良かったです。言語仕様がコンパクトなところも気に入っています。以下は精神年齢が小学生で止まっているのですぐ漫画の話をしてしまう図です。
Zigはコンパクトで良いなと思ったけどGoも最初はそう思ってて今はジェネリクス入ったり複雑になってきてるし、これはワンピースでバギー戦はすっきりしてて見やすかったのにワノ国編はごちゃごちゃして何やってるのか分からないのと同じ現象なのでプログラミングは実質ひとつなぎの大秘宝
— イスラエルいくべぇ (@knqyf263) 2022年8月3日
*1:実際には既にプラグインは別の機構があったので苦肉の策でモジュールと呼んでます
*2:WebAssembly 2.0のドラフトでもその辺は変わっていなさそうに見えますが、ちゃんと追ってないので今度ちゃんと読みます
*3:今の若者は知らなさそう https://ciatr.jp/topics/44857