2025年1月、Go を wasm にコンパイルして実行する記事を書きました。
このデモでは Go の標準コンパイラではなく TinyGo コンパイラ を使用して wasm を生成していました。
なぜ TinyGo を使ったかと云うと、Go 1.23 以前にはメソッドを個別に export する方法が無かったからです。
さて、2月11日に Go 1.24 がリリースされ、//go:wasmexport ディレクティブがサポートされるようになりました。
//go:wasmexport ディレクティブを使うとメソッドを個別に export できます。さっそく試してみましょう。
//go:wasmexport ディレクティブを試す
このようなコードを書きます。
// main.go package main import "fmt" func main() { c := make(chan struct{}) <-c } //go:wasmexport printString func printString(str string) { fmt.Println(str) }
func printString() の1行上に //go:wasmexport printString と書いています。
このディレクティブのおかげで func printString() が printString という名前で export されます。
GOOS=js GOARCH=wasm を指定してコンパイルしましょう。
$ GOOS=js GOARCH=wasm go build -o dist/main.wasm ./main.go
これにて main.wasm が生成されました。
が、wasm ファイルはバイナリなので 1.23 から 1.24 の変化が何もわかりませんね。
wasm2wat というツールを使うと、wasm バイナリから「WebAssembly テキスト形式 (.wat ファイル)」に変換できます。
$ wasm2wat dist/main.wasm > dist/wat/main.wat
.wat ファイルの中を覗いてみましょう。
$ cat dist/wat/main.wat | grep "(export " (export "run" (func $wasm_export_run)) (export "resume" (func $wasm_export_resume)) (export "getsp" (func $wasm_export_getsp)) (export "printString" (func $printString)) (export "mem" (memory 0))
printString という名前でメソッドが export されているのが見えます。
※ 上記は export 宣言の行だけを grep していて、もちろん全文ではありません。全文が見たいかたは GitHub へ go
ちなみに Go 1.23 で同じようにコンパイルすると、
$ GOOS=js GOARCH=wasm go_1_23 build -o dist/main_1_23.wasm ./main.go $ wasm2wat dist/main_1_23.wasm > dist/wat/main_1_23.wat $ cat dist/wat/main_1_23.wat | grep "(export " (export "run" (func $wasm_export_run)) (export "resume" (func $wasm_export_resume)) (export "getsp" (func $wasm_export_getsp)) (export "mem" (memory 0))
//go:wasmexport ディレクティブを書いていても printString は export されていません。
export したメソッドを呼び出す
無事に wasm が生成できたので JavaScript から呼び出してみます。
とはいえほとんどは前回の記事 JavaScript から wasm に文字列を渡す with TinyGo と同じです。
おおまかな流れ
JavaScript から wasm へ文字列を渡すには工夫が必要です。
というのも wasm は整数型や浮動小数点数型しか扱うことができず、文字列型というものが存在していないからです。組み込みの文字列型は存在しないもののメモリ上で文字列を扱うことは可能ということで以下のような手順を踏みます。
- 渡したい文字列の長さ (バイト数) のメモリ領域を確保し、JavaScript にポインタを渡す
- ポンタを元に共有メモリに文字列 (のバイト列) を書き込む
- メモリに書き込まれた文字列を読み出して print する
TinyGo のときとの差分だけを簡単に見ていきましょう。
1. 渡したい文字列の長さ (バイト数) のメモリ領域を確保し、JavaScript にポインタを渡す
TinyGo でコンパイルした wasm には標準で malloc メソッドが用意されていました。
が、Go 1.24 だと malloc メソッドは無いようなので自前で定義します。main.go に追記しましょう。
// main.go //go:wasmexport malloc func malloc(len uint32) *byte { buf := make([]byte, len) return &buf[0] }
かなり雑実装なので業務コードには使わないことをおすすめします。GC を全く考慮していませんし、free メソッドもありません。
コンパイルして .wat ファイルを覗いてみます。
$ GOOS=js GOARCH=wasm go build -o dist/main.wasm ./main.go $ wasm2wat dist/main.wasm > dist/wat/main.wat $ cat dist/wat/main.wat | grep "(export " (export "run" (func $wasm_export_run)) (export "resume" (func $wasm_export_resume)) (export "getsp" (func $wasm_export_getsp)) (export "malloc" (func $malloc)) (export "printString" (func $printString)) (export "mem" (memory 0))
良さそうです。
2. ポンタを元に共有メモリに文字列 (のバイト列) を書き込む
JavaScript 世界に wasm を読み込んで実行するために必要なコードが wasm_exec.js として提供されています。
Go 1.23 の wasm_exec.js と Go 1.24 の wasm_exec.js とでは微妙に内容が異なるようなので、1.24 版を使うようにしましょう。
メインの TypeScript コードはざっとこんな感じです。
// main.ts import './js/wasm_exec.js'; // wasm モジュールを読み込み、インスタンスを返す async function loadModule(): Promise<WebAssembly.Instance> { const go = new Go(); const result = await WebAssembly.instantiateStreaming( fetch(new URL('./dist/main.wasm', import.meta.url)), go.importObject ); const wasm = result.instance; go.run(wasm); return wasm; } function writeStringToMemory(wasm: WebAssembly.Instance, str: string): [number, number] { // JavaScript はデフォルトで UTF-16 形式で文字列を扱う // 今回は UTF-8 形式でやりとりしたいのでエンコードしておく let utf8str: Uint8Array = new TextEncoder().encode(str); // wasm モジュールは線形メモリを exports.memory として露出している // このメモリをやりとりに使う const memory = new Uint8Array(wasm.exports.mem.buffer); // TinyGo が export してくれる関数 // 引数で指定したバイト数のメモリ領域を確保し、その先頭アドレスを返す const ptr: number = wasm.exports.malloc(utf8str.length); // 確保したメモリ領域に UTF-8 形式のバイト列を書き込む memory.set(utf8str, /* offset: */ ptr); // wasm モジュール側からも memory にアクセス可能なので // 書き込みが終わった後は memory への参照は手放していい // ポインタとバイト列の長さの組みを返す // この組みを wasm の関数に渡すことで文字列が書き込まれた領域を知らせる return [ptr, utf8str.length]; } async function main() { const wasm = await loadModule(); const str = 'Hello, 世界🌍'; // 文字列を wasm の線形メモリに書き込む const [ptr, len] = writeStringToMemory(wasm, str); // ユーザー定義関数 `printString(ptr: i32, len: i32)` を呼び出す // ポインタとバイト列の長さの組みを渡すことで文字列を書き込んだ領域を知らせる wasm.exports.printString(ptr, len); // => Hello, 世界🌍 } main();
TinyGo では共有メモリが memory という名前で export されていました。Go では mem という名前になっているようです。
(これは Go 1.23 と 1.24 の差異ではなく、TinyGo と Go の差異です)
実行結果
先ほどの TypeScript ファイルを実行します。ランタイムに Deno を使います。
$ deno run --allow-read main.ts
Hello, 世界🌍
良い感じです。
まとめ
Go 1.24 の //go:wasmexport ディレクティブを試してみました。
今回のコード一式を GitHub に置いておきます。
私からは以上です。