😽

go:wasmimportを使ってHello Worldする

2024/10/25に公開

go:wasmimportとは

Go1.21ではWASIの実験的サポートが追加されましたが、それと同時に追加されたコンパイラディレクティブです。該当のリリースノートは次の箇所にあります。

https://golang.org/doc/go1.21#wasm

ドキュメントは https://pkg.go.dev/cmd/compile にあり、次のように書かれています。

The //go:wasmimport directive is wasm-only and must be followed by a function declaration. It specifies that the function is provided by a wasm module identified by “importmodule“ and “importname“.

例えば次のように、"importmodule"と"importname"を指定して使います。

//go:wasmimport a_module f
func g()

このとき、関数gの実装は書く必要がなく、実装はa_moduleというWasmモジュールfという関数によって提供されます。

この説明だと抽象的でよくわからないと思いますが、例えば次のようなことができます。

  • JavaScriptで書いた関数をwasm向けのGoコードからgo:wasmimportで使う
  • wasmtimeなどのWasmランタイムが提供している関数をGoの関数として使う

この記事の目標は、後者を行うコードを自分で動かしてみることでgo:wasmimportへの最初の一歩を踏み出すことです。

行うこと

DQNEOさんによるライブコーディング「自力でシステムコールを叩いてhello worldを出力しよう」を同じことをwasmimportを使って行います。

https://docs.google.com/presentation/d/10ru3LdbofJqgdmD8pprZuZyWbGvOFC8rKxb6q5Q46Xc/edit#slide=id.gcf4887a11e_1_317

このライブコーディングは、fmt.Printlnを使って文字列を標準出力するときに内部で行われていることを掘ってゆき、より低レベルのAPIを使うようにリファクタリングしていくというものです。これと同じことを、wasmimportについて行ってみます。

ただし、wasmランタイムを使ってGoプログラムを実行する場合には、システムコールを叩く代わりにwasmランタイムの関数を呼び出す点が異なります。表で比較すると次のようになります。

Goアセンブリからシステムコールを叩く場合 wasmimportした関数を叩く場合
最終的に呼びたいもの システムコール(write(2))など Wasmランタイムが提供するfs_write関数
どうやるか システムコールを叩くGoアセンブリを書く go:wasmimportを使ってWasmランタイムの関数をGoから呼び出す

fmt.Printlnsyscall.Writeにリファクタリングする

実際にやってみましょう。出発点は次のコードです。

func main() {
    fmt.Println("Hello, Wasm")
}

まず、オリジナルのライブコーディング同様に、これをsyscall.Writeを使うようにリファクタリングします。

func main() {
	syscall.Write(1, []byte("Hello world\n"))
}

syscallパッケージとは、ドキュメントに

Package syscall contains an interface to the low-level operating system primitives.

syscallパッケージは低レベルのOSプリミティブへのインタフェースを持っています。

とあるように、下にあるシステムの違いを吸収する役割を持っているパッケージです。その役割を果たすために、Goプログラムをビルドするターゲットのシステムごとに異なる実装を持っています。

例えばunix向けの実装は https://cs.opensource.google/go/go/+/refs/tags/go1.23.2:src/syscall/syscall_unix.go;l=201 にあります。

今回のハンズオンで使いたいのはwasip1向けの https://github.com/golang/go/blob/go1.23.2/src/syscall/fs_wasip1.go#L890-L895 です。

syscall.Writeを、そのwasip1向け実装に置き換える

syscall.Writeに任せていた部分を自前のコードに置き換えます。そのために、https://github.com/golang/go/blob/go1.23.2/src/syscall/fs_wasip1.go#L890-L895 の実装を、依存する型や関数含めて丸ごとmain.goにコピー&ペーストします。

少しコード量が多くなりますが、syscallパッケージへの依存が消えたことがわかると思います。

//go:build wasip1 && wasm

package main

import (
	"fmt"
	"runtime"
	"unsafe"
)

// GOOS=wasip1 GOARCH=wasm go build -o main
// WASMTIME_LOG=wasmtime_wasi=trace wasmtime main
func main() {
	var buf = []byte("Hello, Wasm\n")
	_, err := write(1, buf)
	if err != nil {
		panic(err)
	}
}

// syscall.Writeのwasip1実装を(ほとんど)コピーしてきたもの
// 参考元は https://github.com/golang/go/blob/master/src/syscall/fs_wasip1.go#L910-L915
func write(fd int, b []byte) (int, error) {
	var nwritten size
	errno := fd_write(int32(fd), makeIOVec(b), 1, unsafe.Pointer(&nwritten))
	runtime.KeepAlive(b)
	return int(nwritten), errnoErr(errno)
}

type size = uint32
type Errno uint32

func (e Errno) Error() string {
	return fmt.Sprintf("errno %d", e)
}

type uintptr32 = uint32

func makeIOVec(b []byte) unsafe.Pointer {
	return unsafe.Pointer(&iovec{
		buf:    uintptr32(uintptr(bytesPointer(b))),
		bufLen: size(len(b)),
	})
}
func bytesPointer(b []byte) unsafe.Pointer {
	return unsafe.Pointer(unsafe.SliceData(b))
}

type iovec struct {
	buf    uintptr32
	bufLen size
}

// 本質的でないので簡略化した
func errnoErr(e Errno) error {
	switch e {
	case 0:
		return nil
	}
	return e
}

//go:wasmimport wasi_snapshot_preview1 fd_write
//go:noescape
func fd_write(fd int32, iovs unsafe.Pointer, iovsLen size, nwritten unsafe.Pointer) Errno

コード量は多いですが、重要なのは、結局次の関数が呼び出されているということです:

//go:wasmimport wasi_snapshot_preview1 fd_write
//go:noescape
func fd_write(fd int32, iovs unsafe.Pointer, iovsLen size, nwritten unsafe.Pointer) Errno

例えばLinux上でfmt.Printlnを実行したときには、最終的にwrite(2)システムコールが呼ばれます。それと類似して、WASI向けのGoプログラムにおいては、最終的にWasmランタイムが提供するfd_write関数を呼び出したいです。

しかし、Wasmランタイムの関数はもちろんGoの関数ではありませんから、それをGoの関数として呼び出せないとsyscall.WriteをGoで書くことができません。
そこでGoの関数宣言だけを書き、go:wasmimportディレクティブを使ってwasi_snapshot_preview1モジュールのfd_write関数をimportします。すると、Wasmランタイムが提供している該当の関数をGoの関数として、Goのプログラムから呼び出せるようになります。

実行する

実行するには、goの他にWasmランタイムが必要です。この記事ではwasmtimeを使います。

https://wasmtime.dev/

上記に従ってwasmtimeをインストールした後、次の2つを行います。

  • wasi向けにmain.goをコンパイルして、wasi向けのバイナリmainを作る
  • Wasmランタイムであるwasmtimeで、mainを実行する
GOOS=wasip1 GOARCH=wasm go build -o main
wasmtime main

すると、次のようにターミナルに文字列が表示されるので、実験成功です。

wasmtime main
Hello, Wasm

使用したコードの全体

コードの全体は次のファイルにあります。

https://github.com/nobishino/wasmimport-study/blob/main/main.go

手元で実行したい場合の手順は次のようになります。

git clone https://github.com/nobishino/wasmimport-study
cd wasmimport-study
GOOS=wasip1 GOARCH=wasm go build -o main
wasmtime main

この記事へのフィードバックについて

  • この記事についてフィードバックやご意見がある場合、GitHubリポジトリにissueかPRを立てていただけると助かります。
    • ZennのコメントよりもGitHub上でのやり取りが好ましいです。
    • GitHub上ではissueを立てずにいきなりPRを立てても大丈夫です。
    • とりあえずカジュアルに聞きたい場合はXやGophers Slack@Nobishiiにコンタクトしてもらえればと思います。
GitHubで編集を提案

Discussion