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通りがあるのでそれぞれwasm
とwasm-tiny
で作成しています。server
ディレクトリに公開する静的ファイルとウェブサーバをおきます。なのでwasm
とwasm-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には実際の処理を記述します。今回はコンソールに文字列を表示するだけの単純なものにします。wasm
もwasm-tiny
もコンパイルの違いだけで処理が同じなら記述も同じでかまいません。
今回はわかりやすいように2つのディレクトリに分けています。
// 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に出力してください。
// 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)
}
静的ファイルの記述は以下になります。
<!-- 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での出力結果になります。
ファイルサイズの比較
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
を実装しています。
-
add
関数の実際の処理を実装しています。引数のargs
がjsで関数が呼び出された場合に渡される引数を保持しています。
JavaScriptはwindowからいろいろなオブジェクトがぶら下がっているのでjs.Global()
を使用しwindowにアクセスし、そこから格要素にアクセスしていきます。ここではGet()
でコンソールオブジェクトを取得し、変数console
にセットしています。
次の行ではCall()
を使用し、log()
を実行しています。Call()
の第2引数に指定したものが第1引数log()
の引数に渡されるので、足し算の結果を渡します。
Get()
などで取得した要素はGoではjs.Value
という型になります。足し算を行うためにはInt()
で型を変換する必要があります。 -
registerCallbacks
は関数を登録します。ここではSet()
を使ってadd
というオブジェクトとして、①で実装したadd
関数をセットしています。Set()
はDOM要素の値を変更する時にも使用します。[1] -
main関数です。ここで
registerCallbacks()
を実行します。ここでチャンネルを使用していますが、チャンネルを使うことでmain関数の実行が完了し終了するのを防いでいます。
実行結果がいかになります。ここでは、あくまでwindowにセットしただけなのでコンソールから呼び出してあげる必要があることに注意してください。
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()
}
-
js.Funcオブジェクトは最終的にRelease()を呼ばないとGCで回収されない。
プログラムは実行すると変数や関数などの領域をメモリー上に確保します。後から不要になった領域を開放する機能としてガベージコレクション(GC)があります。Goで作成した関数はjs.Funcオブジェクトとしてメモリー上に領域を確保されますが、これはRelease()
を呼ばないとGCで回収されないみたいです。ページを閉じるまで関数を使う必要がある場合は必要ありません(コメントで教えていただきました)。 ↩︎ -
WASM環境ではgoroutineはメインスレッドにすべてぶら下がるので効率化は期待できない。
WASMにおいてgoroutineは非同期処理を抽象化するために使用されているみたいで上のおまけでやったような処理は効率化には繋がらないみたいです(コメントで教えていただきました)。 ↩︎
Discussion
WASM環境でのgoroutineはメインスレッドにすべてぶら下がるので効率化は期待できません。非同期操作の抽象化としての役割でしかありません。(その分排他処理などは不要になります)
js.Funcオブジェクトは最終的にRelease()を呼ばないとGCで回収されないことを注意として書いておいてもらえると嬉しいです。(もちろん一度だけ作ってページが生きている限りずっとjs.Funcオブジェクトが必要な場合はReleaseしなくても大丈夫です)
なるほど知りませんでした。アドバイスありがとうございます😊
自分でも調べて追加したいと思います。
WASMで効率を上げたい場合は、WASM側で閉じた処理結果を最後にJS側に出力するというようにすると良いと思います。(つまりWASM側からJS側を操作する頻度を減らす)
確かにそれは思いました。WASMでJSを操作するのは現状あまり良くはなさそうです。JSが苦手な処理をWASMが担うように役割分担が大事なような気がします。