Introduction

TinyGo is an alternative compiler for Go source code. It can generate %.wasm files instead of architecture-specific binaries through two targets:

  • wasm: for browser (JavaScript) use.
  • wasi: for use outside the browser.

This document is maintained by wazero, which is a WebAssembly runtime that embeds in Go applications. Hence, all notes below will be about TinyGo’s wasi target.

Overview

When TinyGo compiles a %.go file with its wasi target, the output %.wasm depends on a subset of features in the [WebAssembly 2.0 Core specification] (https://wazero.io/specs/#core) and WASI host functions.

Unlike some compilers, TinyGo also supports importing custom host functions and exporting functions back to the host.

Here’s a basic example of source in TinyGo:

package main

//export add
func add(x, y uint32) uint32 {
	return x + y
}

// main is required for the `wasi` target, even if it isn't used.
func main() {}

The following is the minimal command to build a %.wasm binary.

tinygo build -o main.wasm -target=wasi main.go

The resulting wasm exports the add function so that the embedding host can call it, regardless of if the host is written in Go or not.

Disclaimer

This document includes notes contributed by the wazero community. While wazero includes TinyGo examples, and maintainers often contribute to TinyGo, this isn’t a TinyGo official document. For more help, consider the TinyGo Using WebAssembly Guide or joining the #TinyGo channel on the Gophers Slack.

Meanwhile, please help us maintain this document and star our GitHub repository, if it is helpful. Together, we can make WebAssembly easier on the next person.

Constraints

Please read our overview of WebAssembly and constraints. In short, expect limitations in both language features and library choices when developing your software.

Unsupported standard libraries

TinyGo does not completely implement the Go standard library when targeting wasi. What is missing is documented here.

The first constraint people notice is that encoding/json usage compiles, but panics at runtime.

package main

import "encoding/json"

type response struct {
	Ok bool `json:"ok"`
}

func main() {
	var res response
	if err := json.Unmarshal([]byte(`{"ok": true}`), &res); err != nil {
		println(err)
	}
}

This is due to limited support for reflection, and effects other serialization tools also. See Frequently Asked Questions for some workarounds.

Unsupported System Calls

You may also notice some other features not yet work. For example, the below will compile, but print “readdir unimplemented : errno 54” at runtime.

package main

import "os"

func main() {
	if _, err := os.ReadDir("."); err != nil {
		println(err)
	}
}

The underlying error is often, but not always syscall.ENOSYS which is the standard way to stub a syscall until it is implemented. If you are interested in more, see System Calls.

Memory

When TinyGo compiles go into wasm, it configures the WebAssembly linear memory to an initial size of 2 pages (128KB), and marks a position in that memory as the heap base. All memory beyond that is used for the Go heap.

Allocations within Go (compiled to %.wasm) are managed as one would expect. The allocator can grow until memory.grow on the host returns -1.

Host Allocations

Sometimes a host function needs to allocate memory directly. For example, to write JSON of a given length before invoking an exported function to parse it.

The below snippet is a realistic example of a function exported to the host, who needs to allocate memory first.

//export configure
func configure(ptr uintptr, size uint32) {
	json := ptrToString(ptr, size)
}

Note: WebAssembly uses 32-bit memory addressing, so a uintptr is 32-bits.

The general flow is that the host allocates memory by calling an allocation function with the size needed. Then, it writes data, in this case JSON, to the memory offset (ptr). At that point, it can call a host function, ex configure, passing the ptr and size allocated. The guest wasm (compiled from Go) will be able to read the data. To ensure no memory leaks, the host calls a free function, with the same ptr, afterwards and unconditionally.

Note: wazero includes an example project that shows this.

There are two ways to implement this pattern, and they affect how to implement the ptrToString function above:

  • Built-in malloc and free functions
  • Custom malloc and free functions

While both patterns are used in practice, TinyGo maintainers only support the custom approach. See the following issues for clarifications:

Built-in malloc and free functions

The least code way to allow the host to allocate memory is to call the built-in malloc and free functions exported by TinyGo:

(func (export "malloc") (param $size i32) (result (;$ptr;) i32))
(func (export "free") (param $ptr i32))

Go code (compiled to %.wasm) can read this memory directly by first coercing it to a reflect.SliceHeader.

func ptrToString(ptr uintptr, size uint32) string {
	return *(*string)(unsafe.Pointer(&reflect.SliceHeader{
		Data: ptr,
		Len:  uintptr(size),
		Cap:  uintptr(size),
	}))
}

The reason TinyGo maintainers do not recommend this approach is there’s a risk of garbage collection interference, albeit unlikely in practice.

Custom malloc and free functions

The safest way to allow the host to allocate memory is to define your own malloc and free functions with names that don’t collide with TinyGo’s:

(func (export "my_malloc") (param $size i32) (result (;$ptr;) i32))
(func (export "my_free") (param $ptr i32))

The below implements the custom approach, in Go using a map of byte slices.

func ptrToString(ptr uintptr, size uint32) string {
	// size is ignored as the underlying map is pre-allocated.
	return string(alivePointers[ptr])
}

var alivePointers = map[uintptr][]byte{}

//export my_malloc
func my_malloc(size uint32) uintptr {
	buf := make([]byte, size)
	ptr := &buf[0]
	unsafePtr := uintptr(unsafe.Pointer(ptr))
	alivePointers[unsafePtr] = buf
	return unsafePtr
}

//export my_free
func my_free(ptr uintptr) {
	delete(alivePointers, ptr)
}

Note: Even if you define your own functions, you should still keep the same signatures as the built-in. For example, a size parameter on ptrToString, even if you don’t use it. This gives you more flexibility to change the approach later.

System Calls

Please read our overview of WebAssembly and System Calls. In short, WebAssembly is a stack-based virtual machine specification, so operates at a lower level than an operating system.

For functionality the operating system would otherwise provide, TinyGo imports host functions defined in WASI.

For example, tinygo build -o main.wasm -target=wasi main.go compiles the below main function into a WASI function exported as _start.

When the WebAssembly runtime calls _start, you’ll see the effective GOARCH=wasm and GOOS=linux.

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println(runtime.GOARCH, runtime.GOOS)
}

Note: wazero includes an example WASI project including source code that implements cat without any WebAssembly-specific code.

WASI Internals

While developing WASI in TinyGo is outside the scope of this document, the below pointers will help you understand the underlying architecture of the wasi target. Ideally, these notes can help you frame support or feature requests with the TinyGo team.

A close look at the wasi target reveals how things work. Underneath, TinyGo leverages the wasm32-unknown-wasi LLVM target for the system call layer (libc), which is eventually implemented by the wasi-libc library.

Similar to normal code, TinyGo decides which abstraction to use with GOOS and GOARCH specific suffixes and build flags.

For example, os.Args is implemented directly using WebAssembly host functions in runtime_wasm_wasi.go. syscall.Chdir is implemented with the same syscall_libc.go used for other architectures, while syscall.ReadDirent is stubbed (returns syscall.ENOSYS), in syscall_libc_wasi.go.

Concurrency

Please read our overview of WebAssembly and concurrency. In short, the current WebAssembly specification does not support parallel processing.

Tinygo uses only one core/thread regardless of target. This happens to be a good match for Wasm’s current lack of support for (multiple) threads. Tinygo’s goroutine scheduler on Wasm currently uses Binaryen’s Asyncify, a Wasm postprocessor also used by other languages targeting Wasm to provide similar concurrency.

In summary, TinyGo supports goroutines by default and acts like GOMAXPROCS=1. Since goroutines are not threads, the following code will run with the expected output, despite goroutines defined in opposite dependency order.

package main

import "fmt"

func main() {
	msg := make(chan int)
	finished := make(chan int)
	go func() {
		<-msg
		fmt.Println("consumer")
		finished <- 1
	}()
	go func() {
		fmt.Println("producer")
		msg <- 1
	}()
	<-finished
}

There are some glitches to this. For example, if that same function was exported (//export notMain), and called while main wasn’t running, the line that creates a goroutine currently panics at runtime.

Given problems like this, some choose a compile-time failure instead, via -scheduler=none. Since code often needs to be custom in order to work with wasm anyway, there may be limited impact to removing goroutine support.

Optimizations

Below are some commonly used configurations that allow optimizing for size or performance vs defaults. Note that sometimes one sacrifices the other.

Binary size

Those with %.wasm binary size constraints can set tinygo flags to reduce it. For example, a simple cat program can reduce from default of 260KB to 60KB using both flags below.

  • -scheduler=none: Reduces size, but fails at compile time on goroutines.
  • --no-debug: Strips DWARF, but retains the WebAssembly name section.

Performance

Those with runtime performance constraints can set tinygo flags to improve it.

  • -gc=leaking: Avoids GC which improves performance for short-lived programs.
  • -opt=2: Enable additional optimizations, frequently at the expense of binary size.

Frequently Asked Questions

Why do I have to define main?

If you are using TinyGo’s wasi target, you should define at least a no-op func main() {} in your source.

If you don’t, instantiation of the WebAssembly will fail unless you’ve exported the following from the host:

(func (import "env" "main.main") (param i32) (result i32))

How do I use json?

TinyGo doesn’t yet implement reflection APIs needed by encoding/json. Meanwhile, most users resort to non-reflective parsers, such as gjson.

Why does my wasm import WASI functions even when I don’t use it?

TinyGo has a wasm target (for browsers) and a wasi target for runtimes that support WASI. This document is written only about the wasi target.

Some users are surprised to see imports from WASI (wasi_snapshot_preview1), when their neither has a main function nor uses memory. At least implementing panic requires writing to the console, and fd_write is used for this.

A bare or standalone WebAssembly target doesn’t yet exist, but if interested, you can follow this issue.

Why is my %.wasm binary so big?

TinyGo defaults can be overridden for those who can sacrifice features or performance for a smaller binary. After that, tuning your source code may reduce binary size further.

TinyGo minimally needs to implement garbage collection and panic, and the wasm to implement that is often not considered big (~4KB). What’s often surprising to users are APIs that seem simple, but require a lot of supporting functions, such as fmt.Println, which can require 100KB of wasm.