🤖

Go+WebAssemblyでlife gameを作った

2023/11/17に公開

GoでLifegameを作ってみた。
https://yomek33.github.io/lifegame_gowasm/
リポジトリ: https://github.com/yomek33/lifegame_gowasm

Goにおけるwasmについて色々調べたメモ:
https://zenn.dev/yomek33/scraps/6093b35f758787

事前に読んだ本:
入門WebAssembly | Rick Battagline, 株式会社クイープ, 株式会社クイープ |本 | 通販 | Amazon

Reference:
WebAssembly
WebAssembly · golang/go Wiki
js package - syscall/js - Go Packages
Loading and running WebAssembly code - WebAssembly | MDN

環境等
go 1.20
Github Pages

WebAssemblyとは

公式サイトによると

WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.
WebAssembly(略称:Wasm)は、スタックベースの仮想マシン用のバイナリ命令フォーマットである。Wasmは、プログラミング言語の移植可能なコンパイル・ターゲットとして設計されており、クライアントおよびサーバー・アプリケーションのウェブ上での展開を可能にする。

wasmを使用する目的は主に高速化、セキュアネス、移植性など。

Lifegame

LifeGameは生命の誕生、進化、淘汰に関するシミュレーションゲームで、一定の規則にそってセルが変化する。詳しくはライフゲーム(Wikipedia)

GoでのWebAssembly

コンパイル

Goをwasmにコンパイルする方法は次の2通り

Go e.g. GOOS=js GOARCH=wasm go build -o X.wasm X.go
TinyGo e.g. tinygo build -o X.wasm -target wasm X.go
Goでコンパイルする場合は、GOOSをjs、GOARCHをwasmに指定してwasmにコンパイルすることができる。

TinyGoでコンパイルした方が生成ファイルがかなり小さくなる。TinyGoはより小さいランタイムのGoのサブセット。ガベージコレクションや並行処理、いくつかのライブラリにおいてGoとは違う挙動するが、概ね同じように使える。

例えば、次のようなコードでもかなりの差になる。

package main

func main() {
	println("Hello, World!")
}
$ GOOS=js GOARCH=wasm go build -o main.wasm main.go
$ tinygo build -o wasm.wasm -target wasm main.go  
$ ls -lh
~
 1.3M 10 27 07:48 main.wasm.   (Go)
 83K 10 27 07:48 wasm.wasm.   (TinyGo)

syscall/js パッケージ

js package - syscall/js - Go Packages

Goのsyscall/jsでDOM操作を行うことができる。
以下のようにしてボタンクリックのイベントに対するリスナーを追加し、処理を行う。

button := document.Call("createElement", "button")
button.Set("innerText", "Click me")
button.Call("addEventListener", "click", js.FuncOf(func(js.Value, []js.Value) interface{} {
    println("Button clicked!")
    return nil
}))
body.Call("appendChild", button)

func ValueOf

| Go                     | JavaScript             |
| ---------------------- | ---------------------- |
| js.Value               | [its value]            |
| js.Func                | function               |
| nil                    | null                   |
| bool                   | boolean                |
| integers and floats    | number                 |
| string                 | string                 |
| []interface{}          | new array              |
| map[string]interface{} | new object             |

JSとGoの型の変換は上記表のようになる。ValueOf関数を確認する方が早い。記事によってはここの情報が古いことがよくあるので、ちゃんと確認する。

JS側での呼び出し

まず、ブラウザでGoのwasmファイルを実行するのに必要なJSファイルwasmexec.jsをコピー。

cp "$(go env GOROOT)/misc/wasm/wasmexec.js" .

呼び出し側のJSコードを以下のように書く。wasm モジュールWebAssembly.instantiateStreaming()メソッドでフェッチする。

参考:
https://github.com/golang/go/blob/b2fcfc1a50fbd46556f7075f7f1fbf600b5c9e5d/misc/wasm/wasm_exec.html#L16-L39

     if (!WebAssembly.instantiateStreaming) {
        WebAssembly.instantiateStreaming = async (resp, importObject) => {
          const source = await (await resp).arrayBuffer();
          return await WebAssembly.instantiate(source, importObject);
        };
      }

      const go = new Go();
      let mod, inst;
      WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject)
        .then((result) => {
          mod = result.module;
          inst = result.instance;
          document.getElementById("runButton").disabled = false;
          go.run(inst);
          go_createGrid();
        })
        .catch((err) => {
          console.error(err);
        });

GoでのWASMはアプリケーションであって、ライブラリではない

参考:Learning Golang through WebAssembly - Part 3, Interacting with JavaScript from Go 

CやRustではWASMはライブラリとして扱われるが、GoでのWASMはアプリケーションとして動作するというのが、他の言語との大きな違いの一つ。CやRustでは、機能をネイティブライブラリにカプセル化してライブラリとしてその機能を呼び出すことができるが、Goではランタイムが終了してしまうとそれ以上に操作ができなくなる。

  • 解決策1 チャネルを使う

チャネルでmain関数が終わらないようにする。

func main() {
    c := make(chan bool)
    js.Global().Set("print", js.FuncOf(print))
      js.Global().Set("add", js.FuncOf(add))
    <-c
}
  • 解決策2 JS側で関数を使う時はasync/awaitで呼び出す

なんらかのエラーでPanicすると、ランタイムが終了してしまう.....ので、例外処理をする.... ʕ◔ϖ◔ʔ

      async function cellClick(targetCell) {
        try {
          return go_cellClickHandler(targetCell);
        } catch (err) {
          inst = await WebAssembly.instantiate(mod, go.importObject);
          go.run(inst);
          console.log(err);
        }
      }
  • 解決策3 TinyGoを使う

TinyGoならmain関数以外もそのままエクスポートできるらしい。あまり挙動を確認できていないので、TinyGoの公式のコードを載せておく。

https://github.com/tinygoorg/tinygo/blob/release/src/examples/wasm/export/wasm.go

終わり

ざっと簡単にまとめた。結構あっというまにできたが、メモリやパフォーマンスを考慮することは全然できてない.....。低レイヤの知識が曖昧すぎるので、もう少し知識が増えたら戻ってきたい。

GoはwebAssemblyのサポートの開発がまだまだ途中なので、よく新しい機能に関する話題を耳にし、楽しい。

WebAssemblyを勉強することになったのは、低レイヤに入門したいと思ったのがきっかけだったのだが、それは叶えられたと思う。 入門WebAssembly の本では、スタックやメモリの知識をどのように使うかっを具体的に想像できるようになったし、低レベルでのビット操作はどのように行われるかなどを学べた。ただ本当に低レイヤの入口の入口にいるに過ぎないので、進んでいきたい。

ʕ◔ϖ◔ʔ

Discussion