Goで書いたWASMでfmt.Println()してからブラウザのコンソールに出力するまでを追う

先日さくらのクラウド向けCLI Usacloudをブラウザ上で動かせるChrome拡張UsaConをリリースしました。

febc-yamamoto.hatenablog.jp

このUsaConを作る際にいろいろ調べたことを備忘をかねて書いておきます。
(もし間違えている箇所などあったらご指摘いただけると嬉しいです。)

今回はfmt.Println()Hello WorldするだけのWASMを題材に、fmt.Println()からWebブラウザで出力されるまでのコードを追ってみます。

今回の環境

  • Go: 1.15.6
  • ブラウザ(動作確認用): Chrome 87

今回の題材アプリ

Hello Worldするだけの単純なアプリをWASMをしてビルドして実行します。

コードは↓↓だけの非常に単純なものです。

package main

import "fmt"

func main() {
    fmt.Println("hello wasm!")
}

あとはこれをビルドしてブラウザから実行可能にします。
実行にはGoに付属しているwasm_exec.htmlwasm_exec.jsを利用します。
この辺は以下のページに手順が書いてますので気になる方は参照してください。

WebAssembly · golang/go Wiki · GitHub

ビルドして実行可能にするまでの詳細はこちら(読み飛ばし可)

GOOSとGOARCHをセットしてビルド

$ GOOS=js GOARCH=wasm go build -o test.wasm main.go

Goに付属しているwasm_exec.htmlwasm_exec.jsをコピー

$ cp `go env GOPATH`/misc/wasm/wasm_exec.html ./
$ cp `go env GOPATH`/misc/wasm/wasm_exec.js ./

適当なWebサーバを起動

ここでは前述のWikiに従いgoexecを利用する形にしています。 既存のWebサーバを使う形やnpmでserveを使ったりする形でもOKです。

# install goexec
$ go get -u github.com/shurcooL/goexec
$ goexec 'http.ListenAndServe(`:8080`, http.FileServer(http.Dir(`.`)))'

ブラウザで開く

$ open http://localhost:8080/wasm_exec.html

画面上のRunボタンをクリックするとブラウザのコンソールにhello wasm!を表示されるはずです。

f:id:febc_yamamoto:20201225200858p:plain
RunボタンをクリックしてWASMを実行したところ

fmt.Println()の中を追う

早速fmt.Println()の中身を追ってみます。

func Println(a ...interface{}) (n int, err error) {
    return Fprintln(os.Stdout, a...)
}

func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
    p := newPrinter()
    p.doPrintln(a)
    n, err = w.Write(p.buf)
    p.free()
    return
}

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/fmt/print.go#L259-L275

os.Stdout(標準出力)のWrite()を呼んでますね。

ではWASMにした際の標準出力はどういう扱いになっているのでしょうか? まずこの辺をみていきます。

GOOS=js GOARCH=wasmの場合のos.Stdout

os.Stdoutの定義

os.Stdout*os.File型で、以下のように定義されています。

var (
    Stdin  = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
    Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
    Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
)

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/os/file.go#L62-L66

syscall.Stdoutは以下のとおりです。

const (
    Stdin  = 0
    Stdout = 1
    Stderr = 2
)

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/syscall_js.go#L115-L119

別段変わったところはないですね。
NewFile()で何か変わったことをしているのでしょうか?
こちらも追ってみます。

os.NewFile()の実装の中身

// +build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solaris

func NewFile(fd uintptr, name string) *File {
    kind := kindNewFile
    if nb, err := unix.IsNonblock(int(fd)); err == nil && nb {
        kind = kindNonBlock
    }
    return newFile(fd, name, kind)
}

func newFile(fd uintptr, name string, kind newFileKind) *File {
    fdi := int(fd)

    // ...中略...

    f := &File{&file{
        pfd: poll.FD{
            Sysfd:         fdi,
            IsStream:      true,
            ZeroReadIsEOF: true,
        },
        name:        name,
        stdoutOrErr: fdi == 1 || fdi == 2,
    }}
    // ...中略...

    // poll.FDのInitを呼んできる
    if err := f.pfd.Init("file", pollable); err != nil {
        // ...中略...
    }
}

ソース: https://github.com/golang/go/blob/go1.15.6/src/os/file_unix.go

こちらはビルドタグ(ソース先頭のやつ)が+build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solarisとなっていますので、WASM用に特別なことをしてるわけじゃないですね。
もう少し中の方ではWASM向けに分岐しているのですが、今回は省略します。

次は*os.FileWrite()がどう実装されているか見ていきます。

*os.FileWrite()の実装

*os.FileWrite()

go/src/os/file.goで実装されています。

func (f *File) Write(b []byte) (n int, err error) {

    // ...中略...

    n, e := f.write(b)

    // ...中略...
}

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/os/file.go#L173

write()が呼ばれてるのでそちらを見てみます。

*os.Filewrite()

今度はgo/src/os/file_posix.go

func (f *File) write(b []byte) (n int, err error) {
    // poll.FDのWriteを呼んでいる
    n, err = f.pfd.Write(b)
    // ...中略...
}

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/os/file_posix.go#L48

f.pfd.Write()が呼ばれています。 *os.Filepfdフィールドはos.NewFile()の中でこっそり登場していましたね。
ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/os/file_unix.go#L114-L118

次にpoll.FDWrite()を見てみます。

poll.FDWrite()

// +build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solaris

// Write implements io.Writer.
func (fd *FD) Write(p []byte) (int, error) {
    // ...中略...

        // syscall.Writeを呼んでいる
        n, err := ignoringEINTR(func() (int, error) { return syscall.Write(fd.Sysfd, p[nn:max]) })

    // ...中略...

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/internal/poll/fd_unix.go#L267

syscall.Write()が呼ばれています。

GOOS=js GOARCH=wasmの場合のsyscall.Write()

go/src/syscall/fs_js.goで実装されています。

func Write(fd int, b []byte) (int, error) {
    // ...中略...

    // jsに値を渡す
    js.CopyBytesToJS(buf, b)

    // fsCall??
    n, err := fsCall("write", fd, buf, 0, len(b), nil)

    // ...中略...
}

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/fs_js.go#L400-L429

ここでようやくWASMっぽい部分が出てきました。 js.CopyBytesToJS()でバッファに値を入れてfsCall("write", ...)してます。

ちょっと寄り道: GoでビルドしたWASMとJavaScriptの値のやりとりはどうやってるの?

WASM/JavaScript両方からアクセスできるメモリ領域を介して値のやりとりをしています。
参考: WebAssembly.Memory

GoでビルドしたWASMの場合は、memという名前でこのMemoryがエクスポートされています。

f:id:febc_yamamoto:20201225211239p:plain
デバッガでWebAssembly.Instanceのexportsを確認しているところ

先ほど出てきたjs.CopyBytesToJS()はこの共有メモリ領域への書き込みを行なっています。 jsからも読み書きできるところにデータを書いておいてfsCall("write", ...)を呼んでいるということですね。

次にfsCall("write", ...)を追ってみます。

fsCall("write", ...)の実装

fsCallは以下のように実装されています。

func fsCall(name string, args ...interface{}) (js.Value, error) {
    // ...中略...

    // jsFSのCallを呼んでる
    jsFS.Call(name, append(args, f)...)

    // ...中略...
}

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/fs_js.go#L495-L523

jsFSはこちらです。

var jsFS = js.Global().Get("fs")

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/fs_js.go#L20

syscall.jsパッケージのGlobal()経由でGet("fs")してます。

syscal/js

syscall.jsGlobal()は以下のように定義されています。

func Global() Value {
    return valueGlobal
}

var (
    // valueGlobalは106行目で定義されている
    valueGlobal    = predefValue(5, typeFlagObject)

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/js/js.go#L144-L146

syscall/jsValue型ですね。ValueGet()は以下のように実装されています。

func (v Value) Get(p string) Value {
    // ...中略...

    r := makeValue(valueGet(v.ref, p))

    // ...中略...
}

func valueGet(v ref, p string) ref

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/js/js.go#L297-L304

最終的にvalueGet()を呼んでいます。
funcのボディがないですが、これはasmで実装されています。

TEXT ·valueGet(SB), NOSPLIT, $0
  CallImport
  RET

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/js/js_js.s#L15-L17

CallImportというやつがいますね。これは最終的にWebAssemblyにインポートしているfuncを呼ぶようになっているみたいです。 (この辺はまだちゃんと追えていません…)

インポートしてるfuncというのは、JavaScriptでWebAssemblyのインスタンスを作る時に使うWebAssembly.instantiateStreaming()の引数で渡すやつですね。
参考: MDN: WebAssembly.instantiateStreaming()

GoでビルドしたWASMの場合は、wasm_exec.jsの中で定義されているものを利用することになります。

    global.Go = class {
        constructor() {
            // ...中略...
            this.importObject = {
                go: {
                    // func valueGet(v ref, p string) ref
                    "syscall/js.valueGet": (sp) => {
                        const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
                        sp = this._inst.exports.getsp();
                        storeValue(sp + 32, result);
                    },

                    // func copyBytesToJS(dst ref, src []byte) (int, bool)
                    "syscall/js.copyBytesToJS": (sp) => {
                        const dst = loadValue(sp + 8);
                        const src = loadSlice(sp + 16);
                        if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
                            this.mem.setUint8(sp + 48, 0);
                            return;
                        }
                        const toCopy = src.subarray(0, dst.length);
                        dst.set(toCopy);
                        setInt64(sp + 40, toCopy.length);
                        this.mem.setUint8(sp + 48, 1);
                    },
                }
            };
        }
        // ...中略...

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/misc/wasm/wasm_exec.js#L248-L468

先ほど出てきたcopyBytesToJSもここで定義されていますね。 ということで最終的にjs上で定義されたfuncを呼ぶということがわかりました。

fsCall("write", ...)の実装(再び)

ということで改めてfsCall("write", ...)の実装を見てみます。

func fsCall(name string, args ...interface{}) (js.Value, error) {
    // ...中略...

    // jsFS.Call("write", ...)という引数になっている
    jsFS.Call(name, append(args, f)...)

    // ...中略...
}

var jsFS = js.Global().Get("fs")

js.Global().Get("fs")はjs上で定義されたvalueGet()を呼んでいました。 ここでjs上のglobal.jsを取得しています。取得したglobal.jsに対してCall("write", ...)してます。

これによりjs上のglobal.fswrite()を呼ぶようになっています。

ゴール: js上でconsole.log()を呼ぶ部分

global.fsには何が入ってる?

ブラウザ上で実行している場合は前述のwasm_exec.jsにより以下のように定義されています。

    if (!global.fs) {
        let outputBuf = "";
        global.fs = {
            constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
            writeSync(fd, buf) {
                outputBuf += decoder.decode(buf);
                const nl = outputBuf.lastIndexOf("\n");
                if (nl != -1) {
                    console.log(outputBuf.substr(0, nl));
                    outputBuf = outputBuf.substr(nl + 1);
                }
                return buf.length;
            },
            write(fd, buf, offset, length, position, callback) {
                if (offset !== 0 || length !== buf.length || position !== null) {
                    callback(enosys());
                    return;
                }
                const n = this.writeSync(fd, buf);
                callback(null, n);
            },
            chmod(path, mode, callback) { callback(enosys()); },
            chown(path, uid, gid, callback) { callback(enosys()); },
            close(fd, callback) { callback(enosys()); },
            fchmod(fd, mode, callback) { callback(enosys()); },
            fchown(fd, uid, gid, callback) { callback(enosys()); },
            fstat(fd, callback) { callback(enosys()); },
            fsync(fd, callback) { callback(null); },
            ftruncate(fd, length, callback) { callback(enosys()); },
            lchown(path, uid, gid, callback) { callback(enosys()); },
            link(path, link, callback) { callback(enosys()); },
            lstat(path, callback) { callback(enosys()); },
            mkdir(path, perm, callback) { callback(enosys()); },
            open(path, flags, mode, callback) { callback(enosys()); },
            read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
            readdir(path, callback) { callback(enosys()); },
            readlink(path, callback) { callback(enosys()); },
            rename(from, to, callback) { callback(enosys()); },
            rmdir(path, callback) { callback(enosys()); },
            stat(path, callback) { callback(enosys()); },
            symlink(path, link, callback) { callback(enosys()); },
            truncate(path, length, callback) { callback(enosys()); },
            unlink(path, callback) { callback(enosys()); },
            utimes(path, atime, mtime, callback) { callback(enosys()); },
        };
    }

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/misc/wasm/wasm_exec.js#L36-L87

global.fsが未定義だったら最低限の実装で埋めています。 write()は以下の部分です。

            writeSync(fd, buf) {
                outputBuf += decoder.decode(buf);
                const nl = outputBuf.lastIndexOf("\n");
                if (nl != -1) {
                    console.log(outputBuf.substr(0, nl));
                    outputBuf = outputBuf.substr(nl + 1);
                }
                return buf.length;
            },
            write(fd, buf, offset, length, position, callback) {
                if (offset !== 0 || length !== buf.length || position !== null) {
                    callback(enosys());
                    return;
                }
                const n = this.writeSync(fd, buf);
                callback(null, n);
            },

write()writeSync()を呼び、最終的にconsole.log()されていますね。

長かったですがこれでGoのfmt.Println()からコンソール出力されるところまでたどり着きました。


まとめ

無理やりまとめると

  • fmt.Println()syscall.Write()を呼ぶ
  • syscall(のGOOS=js GOARCH=wasm向けの実装)やsyscall/jsはjs側と協調して動作するようになっている
  • js側の基本的な実装はwasm_exec.jsで行われている
  • wasm_exec.jsでのglobal.fsの実装は最低限しかない

みたいな感じでしょうか。

ひとまずGoから標準出力に出力することでブラウザのコンソールに出力できることはわかりました。
でも上記の通りglobal.fsは最低限の実装しかないです。CLIの場合は標準入力から読んだり、その他のファイルを開いたりしたいことがあるかと思います。
CLIを動かしたい場合は自分で実装しないといけない部分が結構ありそうです。

次回以降の記事でUsaConでこの辺の問題をどう解決していったかを取り上げたいと思います。

今回は以上です。