Deno Wasm

Deno WASM #

DenoはJavascriptのほかにもWebAssembly(WASM)を実行できるランタイムです。

WASMを利用することで、Javascript以外のプログラミング言語で作成したプログラムをDenoで動かすことができます。様々な言語をWASMにすることができますが、WASMのバイナリサイズを小さくするためには、組み込み開発にも使えるようなローレベルのシステムプログラミングのできる言語が向いているようです。ここではTinyGo, Rust, ZigをWASMにし、Denoで実行します。TinyGoはGoの別実装で、元々のGoはサーバーサイドのプログラミングを志向しているのに対し、TinyGoはマイコンのような組み込み用途に向けたものになっており、WASMにしたときにGoよりもサイズを小さくできます。

TinyGo #

Go/TinyGoではsyscall/jsライブラリでJavascriptとの橋渡しを行えます。

WASMはそのままではi32, i64, f32, f64といったスカラー値の変数しか外部、この場合はDenoのJavascriptとやり取りすることしかできません。文字列のようなプログラミングで良く使用するようなデータ型でさえそのままでは入出力ができません。JavascriptのデータとWASM(この場合Go/TinyGO)プログラムのデータを仲介するのにsyscall/jsを使います。これはGoプログラムの中でJavascriptのデータを変換・処理する方法になります。

Javascriptは動的な型付けのプログラミング言語であるのに対し、Goは静的型付け言語です。JavascriptのデータをGoではjs.Value型と受け取ります。Type()関数によりそのデータが何の型なのかがわかります。整数ならInt()、文字列ならStrig()といった関数によるGoで扱うデータとして取り出すことになります。

Goは(RustやZigとは異なり)ランタイムのあるプログラミング言語です。そのためGoのWASMは実行状態を保つ必要があります。main関数の最後にあるselect {}はそのためのもので、Goのプログラムが終了しないようにしています。

package main

import (
 "syscall/js"
)

func main() {
 js.Global().Set("TakeString", js.FuncOf(jsTakeString))

 js.Global().Set("greet", js.FuncOf(
  func(this js.Value, args []js.Value) interface{} {
   if len(args) == 0 {
    return "Hello, World!"
   }
   return "Hi " + args[0].String()
  }))

 js.Global().Set("multiply", js.FuncOf(
  func(this js.Value, args []js.Value) interface{} {
   if len(args) == 0 {
    return 0
   } else if len(args) == 1 {
    return args[0].Int()
   }
   return args[0].Int() * args[1].Int()
  }))

 js.Global().Set("concat", js.FuncOf(
  func(this js.Value, args []js.Value) interface{} {
   if len(args) == 0 {
    return ""
   } else if len(args) == 1 {
    return args[0].String()
   }
   return args[0].String() + args[1].String()
  }))

 select {}
}

func jsTakeString(this js.Value, args []js.Value) interface{} {
 if len(args) == 2 && args[0].Type() == js.TypeString && args[1].Type() == js.TypeString {
  return takeString(args[0].String(), args[1].String())
 }
 return ""
}

func takeString(a, b string) string {
 return a + b
}

TinyGoでビルドするコマンドです。WASMバイナリのサイズをちいさくするため、-no-debug-panic=trapオプションをつけています。

tinygo build -target wasm -print-allocs=. -size full -no-debug -panic=trap -o g-wasm.wasm wasm.go

なおGo(TinyGoではなくて)でビルドするコマンドは次のようになります。

GOOS=js GOARCH=wasm go build -o go-wasm.wasm wasm.go

ビルドしたWASMファイルは、TinyGoのwasm_exec.jsというJavascriptを使って読み込みます。 このスクリプトを実行するとTinyGoのプログラムでGlobal()にセットした関数に、windowオブジェクトを介してアクセスできるようになります。Goはランタイムのあるプログラミング言語なので、その初期化などを行うため、wasm_exec.jsが必要です。

TinyGoのwasm_exec.jsとGoのwasm_exec.jsは、ファイル名は同じですが違うものです。TinyGoのWASMの場合はTinyGo付属のwasm_exec.js、GoのWASMの場合はGo付属のwasm_exec.jsを使わなければなりません。

import * as _ from ".wasm_exec.js";
const go = new window.Go();
go.importObject.gojs["syscall/js.finalizeRef"] = _ => 0;  // TinyGoのワーニングメッセージ対策
const buf = await Deno.readFile("g-wasm.wasm");
const inst = await WebAssembly.instantiate(buf, go.importObject);
go.run(inst.instance);

console.log(window.TakeString("Hello ", "Tiny GO!"))

Rust #

RustプログラムをWASMにするにはwasm-packコマンドを使います。

JavascriptからRustのWASMの関数にアクセスするためにブリッジするJavascriptもwasm_bindgenという機能により自動生成されます。これはGoのアプローチと違って、Javascriptのプログラム内でJavascriptのデータ型を変換し、WASM(Rust)プログラムへ渡すという方法になっています。そのためRustプログラム内ではJavascriptのデータ型を扱うような部分はなくなっています。

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    let result = ["Hello ", name, " from R!"].join("");
    return result;
}

#[wasm_bindgen]
pub fn multiply(a: i32, b: i32) -> i32 {
    a * b
}

ビルドするのにwasm-packコマンドを使います。pkgディレクトリにビルドしたWASMバイナリファイルとそれを読むためのJavascriptが生成されます。

wasm-pack build --target deno

Denoでは下のように生成されたWASMを使えます。Go/TinyGoと比べると、自分でJavascriptのデータ型を扱うことがない分だけ、Rustプログラムは短くなります。また生成されるバイナリサイズも小さくなる傾向があるようです。

import { greet, multiply } from "./pkg/r_wasm.js";
console.log(greet("Rust WASM"));

Zig #

GoではGoプログラム内でJavascriptのデータ型を扱い、RustではJavascript内でJavascriptのデータ型を扱う仕組みがありました。Zigではまだそのような仕組みはないようです。ですので文字列のようなデータ列を扱うには、自前でメモリーのアロケーションを行い、ポインターを介してデータのコピーを明示的に行わなければなりません。

ここでそのようなことの必要のないi32のスカラー値のみを扱っています。

export fn add(a: i32, b: i32) i32 {
    return (a + b);
}

ターゲットをwasm32-freestandingとしてビルドします。

zig build-exe wasm.zig -target wasm32-freestanding -fno-entry --export=add

ZigではGoのようなランタイムがないのでその初期化のためのJavascriptはありません。またRustのように自動生成される便利なJavascriptもないので、直接WASMファイルを読み込みます。

const zsrc = await Deno.readFile("../src/z-wasm/wasm.wasm");
const zwasm = await WebAssembly.instantiate(zsrc, { env: {} })
const zwasm_add = zwasm.instance.exports.add;
console.log(zwasm_add(1, 3));

まとめ #

TinyGo, Rust, ZigのプログラムをWASMにビルドし、Deno上で実行するまでを試してみました。

Go/TinyGoにはsyscall/jsという強力なライブラリがあり、Go/TinyGoとJavascriptをブリッジすることができます。今回の例ではJavascriptからGo関数の呼び出しのみを試しましたが、逆方向のアクセスも行えます。例えばWASMの実行環境がDenoではなくてブラウザであればGoからDOMへアクセスすることもでき非常に強力です。Rustと比べるとWASMサイズは大きくなりますがこのsyscall/jsを使うためにGo/TinyGoを選択するのもありえます。

Rustはwasm-packというコマンドが用意されており、これを使うとWASMのビルド、wasm_bindgenによるJavascriptの生成が行えます。この生成されたJavascriptは、JavascriptとRustプログラム間でデータの橋渡しをしてくれるため、Rustプログラム内でJavascriptのデータ型を扱うことなく、Rustプログラムが短く書けます。またGo/TinyGoと比べると、WASMバイナリサイズは小さくなっています。これはRustはGoと比べて実行時ランタイムの分だけ小さいためと思われます。TinyGoはGoと比較して非常に小さなバイナリサイズとなりますが、TinyGoと比較してもRustのほうが小さくできるようです。

ZigはWASMへビルドできますが、Go/TinyGoのsyscall/jsやRustのwasm_bindgenのような便利なものは公式にはないようです。そのような仕組みを自前で作る分にはZigも良い選択肢と思います。