How do compiler functions work?
WebAssembly runtimes let you call functions defined in wasm. How this works in
wazero is different depending on your RuntimeConfig
.
RuntimeConfigCompiler
compiles machine code from your wasm, and jumps to that when invoking a function.RuntimeConfigInterpreter
does not generate code. It interprets wasm and executes go statements that correspond to WebAssembly instructions.
How the compiler works precisely is a large topic, and discussed at length on this page. For more general information on architecture, etc., please refer to Docs.
Engines
Our Docs introduce the “engine” concept of wazero. More precisely, there
are three types of engines, Engine
, ModuleEngine
and callEngine
. Each has
a different scope and role:
Engine
has the same lifetime asRuntime
. This compiles aCompiledModule
into machine code, which is both cached and memory-mapped as an executable.ModuleEngine
is a virtual machine with the same lifetime as its Module. Notably, this binds each function instance to corresponding machine code owned by itsEngine
.callEngine
is the implementation of api.Function in a Module. This implementsFunction.Call(...)
by invoking machine code corresponding to a function instance inModuleEngine
and managing the call stack representing the invocation.
Here is a diagram showing the relationships of these engines:
Callbacks from machine code to Go
Go source can be compiled to invoke native library functions using CGO. However, CGO is not GO. To call native functions in pure Go, we need a different approach with unique constraints.
The most notable constraints are:
- machine code must not manipulate the Goroutine or system stack
- we cannot modify the signal handler of Go at runtime
Handling the call stack
One constraint is the generated machine code must not manipulate Goroutine (or system) stack. Otherwise, the Go runtime gets corrupted, which results in fatal execution errors. This means we cannot1 call Go functions (host functions) directly from machine code (compiled from wasm). This is routinely needed in WebAssembly, as system calls such as WASI are defined in Go, but invoked from Wasm. To handle this, we employ a “trampoline strategy”.
Let’s explain the “trampoline strategy” with an example. random_get
is a host
function defined in Go, called from machine code compiled from guest main
function. Let’s say the wasm function corresponding to that is called _start
.
_start
function is called by wazero by default on Instantiate
.
Here is a TinyGo source file describing this.
//go:import wasi_snapshot_preview1 random_get
func random_get(age int32)package main
import "unsafe"
// random_get is a function defined on the host, specifically, the wazero
// program written in Go.
//
//go:wasmimport wasi_snapshot_preview1 random_get
func random_get(ptr uintptr, size uint32) (errno uint32)
// main is compiled to wasm, so this is the guest. Conventionally, this ends up
// named `_start`.
func main() {
// Define a buffer to hold random data
size := uint32(8)
buf := make([]byte, size)
// Fill the buffer with random data using an imported host function.
// The host needs to know where in guest memory to place the random data.
// To communicate this, we have to convert buf to a uintptr.
errno := random_get(uintptr(unsafe.Pointer(&buf[0])), size)
if errno != 0 {
panic(errno)
}
}
When _start
calls random_get
, it exits execution first. wazero calls the Go
function mapped to random_get
like a usual Go program. Finally, wazero
transfers control back to machine code again, resuming _start
after the call
instruction to random_get
.
Here’s what the “trampoline strategy” looks like in a diagram. For simplicity,
we’ll say the wasm memory offset of the buf
is zero, but it will be different
in real execution.
Signal handling
Code compiled to wasm use runtime traps to abort execution. For
example, a panic
compiled with TinyGo becomes a wasm function named
runtime._panic
, which issues an unreachable instruction
after printing the message to STDERR.
package main
func main() {
panic("help")
}
Native JIT compilers set custom signal handlers for Wasm runtime traps,
such as the unreachable instruction. However, we cannot
safely modify the signal handler of Go at runtime.
As described in the first section, wazero always exits the execution of machine
code. Machine code sets status when it encounters an unreachable
instruction.
This is read by wazero, which propagates it back with ErrRuntimeUnreachable
.
Here’s a diagram showing this:
One thing you will notice above is that the calls between wasm functions, such
as from _start
to runtime._panic
do not use a trampoline. The trampoline
strategy is only used between wasm and the host.
Summary
When an exported wasm function is called, using a wazero API, such as
Function.Call()
, wazero allocates a callEngine
and starts invocation. This
begins with jumping to machine code compiled from the Wasm binary. When that
code makes a callback to the host, it exits execution, passing control back to
exec_native
which then calls a Go function and resumes the machine code
afterwards. In the face of Wasm runtime errors, we exit the machine code
execution with the proper status, and return the control back to exec_native
function, just like host function calls. Just instead of calling a Go function,
we call panic
with a corresponding error. This jumping is why the strategy is
called a trampoline, and only used between the guest wasm and the host running
it.
it’s technically possible to call it directly, but that would come with performing “stack switching” in the native code. It’s almost the same as what wazero does: exiting the execution of machine code, then call the target Go function (using the caller of machine code as a “trampoline”). ↩︎