DEV Community

Cover image for Go WebAssembly Internals - Part 2
Denis Sedchenko
Denis Sedchenko

Posted on

Go WebAssembly Internals - Part 2

In previous article we covered how to build a simple Go program, interact with host environment by wrapping Go functions as JavaScript functions and how JS-to-Go call magic works under the hood.

This article will cover how Go runtime access global JavaScript objects and helper functions from wasm_exec.js glue-code library and how this mechanism can be exploited to link external JavaScript functions directly to our programs.

WebAssembly Import Object

Although WebAssembly programs are isolated and have no direct access to a browser (or other host environment), each module can import symbols (usually functions) during instantiation that can be used to communicate with outside world.

All module dependencies to import have to be provided in a form of an object with a symbol name as key and function as a value.

On WASM program side, module should do an import with import statement.

(module
    (func $myFunction (;0;) (import "myFunction") (param i32))
)
Enter fullscreen mode Exit fullscreen mode

Browser will link import object to a program during module instantiation process.

const importObject = {
  "myFunction": () => console.log("hello world")
}

WebAssembly.instantiate(/* wasm module binary */, importObject);
Enter fullscreen mode Exit fullscreen mode

Go Runtime Dependencies

WebAssembly Imports List In

Go WebAssembly module imports

Each Go program import a couple of runtime dependencies from import object prepared by Go helper class provided from wasm_exec.js file.

Most of those functions are used for syscall/js package but also there are a few core Go runtime dependencies.

Import Object in wasm_exec.js file

Import object in wasm_exec.js

Each imported function accepts current program stack pointer and may manipulate with go program memory inside this.mem field of Go helper class and return result.

We also see to what exact Go function is will be linked and what params it will return by function key name and comments left above function declarations.

Linking Imported Function

Lets have a look at declaration of syscall/js.stringVal function which present in import object:

//go:build js && wasm

package js

type ref uint64

func stringVal(x string) ref
Enter fullscreen mode Exit fullscreen mode

The stringVal function doesn't have //go:linkname or other compiler directives, so how Go linker know that function implementation is defined in import object?

Function implementation is defined in a separate file js_js.s in the same folder. File is written in a Go assembler language and contains body for each syscall/js dependency.

#include "textflag.h"

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

Enter fullscreen mode Exit fullscreen mode

The CallImport instruction is used to declare and call function imports.
Under the hood, Go compiler will generate an import statement with a path that corresponds to a function (including package name).

There is a proposal to replace CallImport instruction with a more convenient //go:wasmimport compiler directive.

Importing Custom Functions

All information above allows to define and link custom functions directly to a Go program. Execution of such calls are much cheaper from performance standpoint and Go programs and don't require global namespace pollution (function shouldn't be present in window object).

The main downside of this approach is that we have to manually deal with Go program stack, manually read and write data into program memory.

Lets write and import a simple multiplication function. Function will accept 2 integers and will return a result.

Full example source code is available in this repo.

Go Program

Our program will consist of 2 files: a main Go file and accompanying Go assembly file with WASM import.

Program will import and call multiply function which is written in JavaScript.

main.go

package main

import "fmt"

func multiply(a, b int) int

package main() {
    fmt.Println("Multiply result:", multiply(3, 4))
}
Enter fullscreen mode Exit fullscreen mode

main_js.s

#include "textflag.h"

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

Enter fullscreen mode Exit fullscreen mode

The textflag.h file is available in $GOROOT/src/runtime directory.

Building Program

Set GOOS and GOARCH environment variables to build a program as WebAssembly module:

GOOS=js GOARCH=wasm go build -o main.wasm main.go
Enter fullscreen mode Exit fullscreen mode

Writing Wrapper

Go SDK provides a wasm_exec.js file with Go helper class that implements Go WebAssembly ABI for browsers and contains an import object that needs to be modified.

Copy of the file is available in $GOROOT/misc/wasm/wasm_exec.js.

Lets extend Go class with a small wrapper which will allow exporting custom functions without touching original Go class implementation.

custom-go.mjs

// Copied from '$GOROOT/misc/wasm/wasm_exec.js'
import './wasm_exec.js';

export default class CustomGo extends global.Go {
  MAX_I32 = Math.pow(2, 32);

  // Copied from 'setInt64'
  setInt64(offset, value) {
    this.mem.setUint32(offset + 0, value, true);
    this.mem.setUint32(offset + 4, this.MAX_I32, true);
  }

  /**
   * Adds function to import object
   * @param name symbol name (package.functionName)
   * @param func function.
   */
  importFunction(name, func) {
    this.importObject.go[name] = func;
  }
}
Enter fullscreen mode Exit fullscreen mode

Function Implementation

Create a main.mjs file that will import and run our WebAssembly program.

import Go from './custom-go.mjs';
import { promises as fs } from 'fs';

// Instantiate Go wrapper instance
const go = new Go();

// Add our function to import object
go.importFunction('main.multiply', sp => {
    sp >>>= 0;  
    const a1 = go.mem.getInt32(sp + 8, true);  // SP + sizeof(int64)  
    const a2 = go.mem.getInt32(sp + 16, true); // SP + sizeof(int64) * 2
    const result = a1 * a2;
    console.log('Got call from Go:', {a1, a2, result});
    go.setInt64(sp + 24, result);
});


// Run the program
const buff = await fs.readFile('./main.wasm');  
const {instance} = await WebAssembly.instantiate(buff, go.importObject);  
await go.run(instance);
Enter fullscreen mode Exit fullscreen mode

Reading arguments

The sp argument is a stack pointer address which is passed to each imported function.
Values of first and second integers should be manually obtained from stack.

Go class has mem property which is an instance of DataView interface. As MDN documentation says:

DataView provides a low-level interface for reading and writing different number values into program memory without having to care about platform endianness.

The this.mem.getInt32 method accepts offset and boolean parameter to indicate that our value is stored in little endian format.

const a1 = go.mem.getInt32(sp + 8, true);  // Stack Pointer + sizeof(int64)
const a2 = go.mem.getInt32(sp + 16, true); // Stack Pointer + sizeof(int64) * 2
Enter fullscreen mode Exit fullscreen mode

Returning result

Result of the function should be put in memory after the last argument on stack.

go.setInt64(sp + 24, result);
Enter fullscreen mode Exit fullscreen mode

Final Result

Lets run our WebAssembly module with multiply function implementation inside Node.js.

Attention: for Node.js 18 and below, WebCrypto API polyfil is required.

$ node ./main.mjs

Got call from Go: { a1: 3, a2: 4, result: 12 }
Multiply result: 12
Enter fullscreen mode Exit fullscreen mode

Full example source code with polyfill is available in this repo.

Top comments (0)