goでWebサービス No.11(WebAssembly)


今回は最近話題になっているWebAssemblyを少し触って見たいと思います。

今回のデータはgithubにあげています。必要なファイルはクローンしてお使いください。

注意

コマンドラインを使った操作が出て来ることがあります。cd, ls, mkdir, touchといった基本的なコマンドを知っていることを前提に進めさせていただきます。
環境の違いで操作や実行結果に差異が出てくる可能性があります。私の実行環境は以下になります。

MacBook Pro (Early 2015)
macOS Catalina ver.10.15.6
go version go1.14.7 darwin/amd64
エディタはVScode

WebAssemblyとは?

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

ウェブブラウザ上で動的な操作を行う場合、ほとんどはJavaScriptで実装されると思います。ToDoリストのような簡単なものから、スプレッドシート(Excelなど)のような大規模なものまで多くのウェブアプリケーションがありますが特に大規模なものだとJavaScriptだと力不足なところがあります。もちろん処理によっては大規模なアプリケーションでなくても力不足になるでしょう。

それに対してネイティブアプリの多くはコンパイルされ機械語に変換されて使われるので処理が高速です。こういったバイナリー形式のファイルにコンパイルしたものをウェブブラウザ上でも使えるようにしたのがWebAssemblyです。

WebAssemblyを使うとC, C++, Rustといった言語で書かれたプログラムをwasmという形式のバイナリーファイルで出力することでウェブブラウザ上で使用できるようになります。Goもこのwasmを出力できるのでGoを使ってWebAssemblyを試してみたいと思います。

GoでWebAssembly

ディレクトリ構成は以下のようになります。Goでwasm形式での出力は通常のGoとTinyGoという小さいサイズで出力するものの2通りがあるのでそれぞれwasmwasm-tinyで作成しています。serverディレクトリに公開する静的ファイルとウェブサーバをおきます。なのでwasmwasm-tinyで出力したバイナリーファイルはserver/publicに移動します。

.
├── README.md
├── server
│   ├── public
│   │   ├── go.wasm           // web assembly(Goビルド)
│   │   ├── index.html
│   │   ├── tinygo.wasm       // web assembly(TinyGoビルド)
│   │   ├── tinywasm_exec.js  // TinyGo用のjsファイル
│   │   └── wasm_exec.js      // Go用のjsファイル
│   └── server.go
├── wasm
│   └── main.go
└── wasm-tiny
    └── main.go

処理の実装

main.goには実際の処理を記述します。今回はコンソールに文字列を表示するだけの単純なものにします。wasmwasm-tinyもコンパイルの違いだけで処理が同じなら記述も同じでかまいません。
今回はわかりやすいように2つのディレクトリに分けています。

main.go
// code:web11-1
package main

import (
	"fmt"
)

func main() {
    fmt.Println("Hello Go WebAssembly") // Go
    fmt.Println("Hello TinyGo WebAssembly") // TinyGo
}

コンパイルは以下のようにしてできます。これを実行するとgo.wasmまたはtinygo.wasmが出力されます。WebAssemblyの拡張子は.wasmです。ファイル名は任意です。

// Go 
$ GOOS=js GOARCH=wasm go build -o go.wasm
// TinyGo 
$ GOOS=js GOARCH=wasm tinygo build -o tinygo.wasm

サーバの実装

サーバは通常通りnet/httpを使用して実装します。今回はpublicディレクトリの静的ファイルをホスティングする形で行います。
なので先ほど出力したwasmファイルもpublicに移すか最初からpublicに出力してください。

server.go
// code:web11-2
package main

import (
	"log"
	"net/http"
)

func main() {
	port := "8080"
	log.Printf("listen on http://localhost:%s", port)
	http.Handle("/", http.FileServer(http.Dir("public")))
	http.ListenAndServe(":"+port, nil)
}

静的ファイルの記述は以下になります。

index.html
<!-- code:web11-3 -->
<html>
    <head>
        <meta charset="utf-8"/>
        <script src="tinywasm_exec.js"></script>
        <script>
            const go = new Go();
            WebAssembly.instantiateStreaming(fetch("tinygo.wasm"), go.importObject).then((result) => {
                go.run(result.instance);
            });
        </script>
    </head>
    <body>
        <h1>Go WebAssembly</h1>
    </body>
</html>

WebAssembly.instantiateStreaming()
WebAssembly.instantiate() 関数は WebAssembly コードをコンパイル、インスタンス化するための主要な API で、Module と、その最初の Instance を返します。
引用:MDN Web Docs

ブラウザが提供するAPIはWebAssembly.instantiateStreaming()で残りはwasm_exec.jsから提供されているようです。

WebAssemblyのファイルはjsで取得し、wasm_exec.jsで実行します。wasm_exec.jsはGoとTinyGoと2種類ありそれぞれ以下のコマンドで取得できます。

# GOのwasmを実行する場合は、以下のファイルをダウンロードする
$ wget https://raw.githubusercontent.com/golang/go/master/misc/wasm/wasm_exec.js

# TinyGOのwasmを実行する場合は、以下のファイルをダウンロードする
$ wget https://raw.githubusercontent.com/tinygo-org/tinygo/master/targets/wasm_exec.js

これで出力した結果が以下になります。これはTinyGoでの出力結果になります。
TinyGoでの出力結果

ファイルサイズの比較

Goでのバイナリファイルは意外と大きいので確認しておきましょう。

$ ls -l
total 4928
-rwxr-xr-x  1 mbp  staff  2265398 11 10 10:48 go.wasm*
-rw-r--r--  1 mbp  staff      338 11 10 11:08 index.html
-rwxr-xr-x  1 mbp  staff   210803 11 10 10:48 tinygo.wasm*
-rw-r--r--  1 mbp  staff    15466 11 10 11:06 tinywasm_exec.js
-rw-r--r--  1 mbp  staff    17255 11 10 10:24 wasm_exec.js

こうみると通常の出力は2MBと大きいですね。それに対してTinyGoでの出力は200KBと小さめです。Rustで出力するともっと小さいみたいなのでファイルサイズに関して言えばGoは強いとは言えないかもしれません。がGoの強みがそのままウェブブラウザ上で使えるという点で上手くやればいいものができるのかもしれません。

Goで定義した関数をjsから呼び出す。

Goで実装した関数をjsから呼び出すことができます。以下の例を元に説明していきます。

// code:web11-4
package main

import (
	"fmt"
	"syscall/js"
)
// ①jsで呼び出す関数の実装
func add(this js.Value, args []js.Value) interface{} {
	console := js.Global().Get("console")
	console.Call("log", args[0].Int()+args[1].Int())
	return nil
}
// ②jsで呼び出す関数の登録
func registerCallbacks() {
	js.Global().Set("add", js.FuncOf(add))
}
// ③実行
func main() {
	// チャンネルによって永続化
	c := make(chan struct{}, 0)
	println("Go WebAssembly Initialized")
	registerCallbacks()
	<-c
}

Goでjsの操作を行うにはsyscall/jsという標準パッケージをインポートする必要があります。web11-4ではこれを使って足し算の結果を標準出力する関数addを実装しています。

  1. add関数の実際の処理を実装しています。引数のargsがjsで関数が呼び出された場合に渡される引数を保持しています。
    JavaScriptはwindowからいろいろなオブジェクトがぶら下がっているのでjs.Global()を使用しwindowにアクセスし、そこから格要素にアクセスしていきます。ここではGet()でコンソールオブジェクトを取得し、変数consoleにセットしています。
    次の行ではCall()を使用し、log()を実行しています。Call()の第2引数に指定したものが第1引数log()の引数に渡されるので、足し算の結果を渡します。
    Get()などで取得した要素はGoではjs.Valueという型になります。足し算を行うためにはInt()で型を変換する必要があります。

  2. registerCallbacksは関数を登録します。ここではSet()を使ってaddというオブジェクトとして、①で実装したadd関数をセットしています。Set()はDOM要素の値を変更する時にも使用します。[1]

  3. main関数です。ここでregisterCallbacks()を実行します。ここでチャンネルを使用していますが、チャンネルを使うことでmain関数の実行が完了し終了するのを防いでいます。

実行結果がいかになります。ここでは、あくまでwindowにセットしただけなのでコンソールから呼び出してあげる必要があることに注意してください。

引数に整数以外のものを指定するとpanicで終了してしまいます。実際に使用するのなら、これを避けるための実装が必要でしょう。

DOM操作を行う。

今度は、入力された値の掛け算の結果をアンオーダーリストに追加していく関数を実装してみます。

// code:web11-5
func mult(this js.Value, args []js.Value) interface{} {
	// 指定したIDの要素の値を取得
	value1 := js.Global().Get("document").Call("getElementById", args[0].String()).Get("value").String()
	value2 := js.Global().Get("document").Call("getElementById", args[1].String()).Get("value").String()
	// 取得した値をint型に変換
	int1, _ := strconv.Atoi(value1)
	int2, _ := strconv.Atoi(value2)
	// 乗算
	ans := int1 * int2
	// 答えを文字列に変換
	s := strconv.Itoa(int1) + "*" + strconv.Itoa(int2) + "=" + strconv.Itoa(ans)
	// liタグの作成
	li := js.Global().Get("document").Call("createElement", "li")
	// liタグに値を設定
	li.Set("textContent", s)
	// ulタグにliタグをアペンドチャイルド
	js.Global().Get("document").Call("getElementById", args[2].String()).Call("appendChild", li)
	return nil
}

ここではかける数とかけられる数が入力されているDOM要素のidと掛け算の結果を埋め込むDOM要素のidの3つの引数を受け取ります。それを元に計算をし、その結果を作成したliタグに埋め込みます。最後にそのli要素を第3引数で指定したDOM要素に埋め込みます。

実行結果は以下のようになります。

速度を比較してみる

ここまで扱ってきた処理はそこまで大変ではないのでJavaScriptで実装した方が良いでしょう。ここでは、もう少し負荷のかかる処理で実行速度を比較してみます。
具体的にはキャンバスにマンデルブロー集合を描画するコードで比べます。この処理はキャンバス上の格点の座標を使い漸化式を計算します。それが発散するかどうかで図形を描きます。キャンバスの縦方向と横方向で二重ループになり、漸化式の収束・発散を確かめるのでさらにループを足した三重ループになります。なので昔はコンピュータの性能を図るために使用されたようです。現代のコンピュータにどうかはわかりませんが、このような理由と昔マンデルブローのコードを書いたことがあるという理由から今回使います。どちらもアルゴリズムは同じにし、計測するのはマンデルブロー集合の計算の部分のみにし、描画に必要な処理は含めないようにします。
処理の部分のコードのみを載せます。またコードの説明も割愛します。
jsのコード

// code:web11-6
function jsmand() {
    const canvas = document.getElementById("cnvs");
    if (canvas.getContext) {
        const ctx = canvas.getContext("2d");
        const startTime = Date.now();
        const w = canvas.width;
        const h = canvas.height;
        const itr = 255;
        var size = 3;
        var arr = [];
        let x, y;
        for (let i=0; i<w; i++) {
            x = (i / w) * size - (size / 2);
            arr[i] = [];
            for (let j=0; j<h; j++) {
                y = (j / h) * size - (size / 2);
                var a = 0;
                var b = 0;
                for (let k=0; k<=itr; k++) {
                    // マンデルブロの計算
                    var _a = a * a - b * b + x;
                    var _b = 2 * a * b + y;
                    a = _a;
                    b = _b;
                    if (a * a + b * b > 4) {
                        break;
                    }
                    arr[i][j] = k;
                }
            }
        }
        const endTime = Date.now();
        for (let i=0; i < w; i++) {
            for (let j=0; j < h; j++) {
                ctx.fillStyle = `hsl(${arr[i][j]}, 100%, 50%)`
                ctx.fillRect(i, j, 1, 1);
            }
        }
        time = endTime - startTime;
        const t = document.getElementById("create-time");
        t.textContent = time + "ミリ秒"
    } else {
        console.log("no context.");
    }
}

goのコード

// code:web11-7
func mand(this js.Value, args []js.Value) interface{} {
	canvas := js.Global().Get("document").Call("getElementById", "cnvs")
	ctx := canvas.Call("getContext", "2d")
	start := time.Now()
	w := 400
	h := 400
	itr := 255
	size := 3
	var arr [400][400]int
	for i := 0; i < w; i++ {
		x := (float64(i)/float64(w))*float64(size) - (float64(size) / 2)
		for j := 0; j < h; j++ {
			y := (float64(j)/float64(h))*float64(size) - (float64(size) / 2)
			a := float64(0)
			b := float64(0)
			for k := 0; k <= itr; k++ {
				aTemp := a*a - b*b + x
				bTemp := 2*a*b + y
				a = aTemp
				b = bTemp
				if a*a+b*b > 4 {
					break
				}
				arr[i][j] = k
			}
		}
	}
	end := time.Now()

	for i := 0; i < w; i++ {
		for j := 0; j < h; j++ {
			l := 255 - arr[i][j]
			hsl := "hsl(" + strconv.Itoa(l) + ", 100%, 50%)"
			ctx.Set("fillStyle", hsl)
			ctx.Call("fillRect", i, j, 1, 1)
		}
	}
	processTime := fmt.Sprintf("%vミリ秒\n", (end.Sub(start)).Milliseconds())
	js.Global().Get("document").Call("getElementById", "create-time").Set("textContent", processTime)
	return nil
}

これの実行結果が以下になります。

比べるとjsの方が早いことがわかります。また、これは計測していませんが体感的にWebAssemblyの方はウィンドウ上に結果が反映されるまでに時間がかかるようです。おそらくwasm_exec.jsでの処理のオーバーヘッドがあるのでしょう。

まとめ

jsの方が早いのは意外でした。GoでWebAssemblyを使用するのは現段階では、仕組みの理解などの学習コストをかけて行うほど利点は無いように感じました。Goはバイナリファイルのサイズも大きいのでGoの良さを最大限に活かせないと逆にクオリティを落としてしまう結果になりそうです。またwasm_exec.jsの処理のオーバーヘッドもカバーする必要もありそうです()。
少なくとも私のような駆け出しエンジニアが手を出すにはまだ早い代物だと思いました。
今回のデータは全てgithubにあります。ディレクトリ構成などの確認や他の部分のコードの確認にお使いください。
またアドバイス等もあればお願いします。

おまけ

ゴルーチンを使って並行処理するば劇的に早くなるんじゃね?と安易に実装したら414ミリ秒と逆に遅くなりました。一発目の実行は1800ミリ秒だったのですごくムラがあります。
私の実装がよくないのはもちろん理解していますが何が悪かったんだろう?[2]

var Arr [400][400]int

func gomand(this js.Value, args []js.Value) interface{} {
	var wg sync.WaitGroup
	canvas := js.Global().Get("document").Call("getElementById", "cnvs")
	ctx := canvas.Call("getContext", "2d")
	start := time.Now()
	w := 400
	h := 400
	itr := 255
	size := 3
	for i := 0; i < w; i++ {
		x := (float64(i)/float64(w))*float64(size) - (float64(size) / 2)
		for j := 0; j < h; j++ {
			wg.Add(1)
			y := (float64(j)/float64(h))*float64(size) - (float64(size) / 2)
			go mand(x, y, i, j, itr, &wg)
		}
	}
	wg.Wait()
	end := time.Now()
	// fmt.Println(Arr)
	for i := 0; i < w; i++ {
		for j := 0; j < h; j++ {
			l := 255 - Arr[i][j]
			hsl := "hsl(" + strconv.Itoa(l) + ", 100%, 50%)"
			ctx.Set("fillStyle", hsl)
			ctx.Call("fillRect", i, j, 1, 1)
		}
	}
	processTime := fmt.Sprintf("%vミリ秒\n", (end.Sub(start)).Milliseconds())
	js.Global().Get("document").Call("getElementById", "create-time").Set("textContent", processTime)
	return nil
}

func mand(x float64, y float64, i int, j int, itr int, w *sync.WaitGroup) {
	a := float64(0)
	b := float64(0)
	for k := 0; k <= itr; k++ {
		aTemp := a*a - b*b + x
		bTemp := 2*a*b + y
		a = aTemp
		b = bTemp
		if a*a+b*b > 4 {
			break
		}
		Arr[i][j] = k
	}
	w.Done()
}
脚注
  1. js.Funcオブジェクトは最終的にRelease()を呼ばないとGCで回収されない。
    プログラムは実行すると変数や関数などの領域をメモリー上に確保します。後から不要になった領域を開放する機能としてガベージコレクション(GC)があります。Goで作成した関数はjs.Funcオブジェクトとしてメモリー上に領域を確保されますが、これはRelease()を呼ばないとGCで回収されないみたいです。ページを閉じるまで関数を使う必要がある場合は必要ありません(コメントで教えていただきました)。 ↩︎

  2. WASM環境ではgoroutineはメインスレッドにすべてぶら下がるので効率化は期待できない。
    WASMにおいてgoroutineは非同期処理を抽象化するために使用されているみたいで上のおまけでやったような処理は効率化には繋がらないみたいです(コメントで教えていただきました)。 ↩︎