Skip to content

One instance per goroutine

Fifty goroutines share one Runtime and one CompiledModule, and each one builds its own instance before calling into it. That split is the whole lesson: compilation is the expensive half and it is shareable, while an instance — its linear memory, its globals, its tables — belongs to one goroutine at a time. The example lives in examples/concurrent-instantiation.

Terminal window
$ go run main.go
0
98
4
40
42
30
32
24
2
--snip--

Fifty lines, one per goroutine, each the double of that goroutine’s index. The order is whatever the scheduler produced, which is why main_test.go asserts on membership rather than on a transcript:

examples/concurrent-instantiation/main_test.go
11 collapsed lines
package main
import (
"strconv"
"strings"
"testing"
"github.com/samyfodil/wazy/internal/testing/maintester"
"github.com/samyfodil/wazy/internal/testing/require"
)
// Test_main ensures the following will work:
//
// go run main.go
func Test_main(t *testing.T) {
stdout, _ := maintester.TestMain(t, main, "main")
for i := 0; i < 50; i++ {
require.True(t, strings.Contains(stdout, strconv.Itoa(i*2)))
}
}

The guest is deliberately trivial, so that nothing in it distracts from the host-side lifecycle:

examples/concurrent-instantiation/testdata/add.wat
(module
(func (export "add") (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add)
)

Assembled with wasm-tools, per the comment above the embed in main.go:

Terminal window
wasm-tools parse testdata/add.wat -o testdata/add.wasm

The result is 41 bytes and is committed next to the source, so the example runs with nothing installed but Go.

  • Directoryexamples/concurrent-instantiation
    • main.go
    • main_test.go
    • Directorytestdata
      • add.wat
      • add.wasm 41 bytes, committed

CompileModule is where the machine code gets produced. Doing it once, before any goroutine starts, is what makes the rest of the program cheap — and the resulting CompiledModule is read-only from then on, so every goroutine can hold the same pointer.

examples/concurrent-instantiation/main.go
11 collapsed lines
package main
import (
"context"
_ "embed"
"fmt"
"log"
"sync"
"github.com/samyfodil/wazy"
)
// addWasm was generated by the following:
//
// wasm-tools parse testdata/add.wat -o testdata/add.wasm
//
//go:embed testdata/add.wasm
var addWasm []byte
func main() {
// Choose the context to use for function calls.
ctx := context.Background()
// Create a new WebAssembly Runtime.
r := wazy.NewRuntime(ctx)
defer r.Close(ctx) // This closes everything this Runtime created.
// Compile the Wasm binary once so that we can skip the entire compilation time during instantiation.
compiledWasm, err := r.CompileModule(ctx, addWasm)
if err != nil {
log.Panicf("failed to compile Wasm binary: %v", err)
}

The Runtime is shared too. It owns a store that every instance registers into, and its Close tears down everything it created — compiled modules included — which is why the deferred close at the top of main is enough for the whole program.

Each goroutine calls InstantiateModule against that shared compiled module and gets back its own api.Module: a fresh set of memories, globals and tables, built from the same code.

examples/concurrent-instantiation/main.go
var wg sync.WaitGroup
const goroutines = 50
wg.Add(goroutines)
// Instantiate the Wasm module from `compiledWsam`, and invoke the exported "add" function concurrently.
for i := 0; i < goroutines; i++ {
go func(i int) {
defer wg.Done()
// Instantiate a new Wasm module from the already compiled `compiledWasm`.
instance, err := r.InstantiateModule(ctx, compiledWasm, wazy.NewModuleConfig().WithName(""))
if err != nil {
log.Panicf("[%d] failed to instantiate %v", i, err)
}
defer instance.Close(ctx) // This closes everything this Instance created.

WithName("") is the one call in this example you would not have written on your own, and the next section explains why it is there. Everything else in the goroutine body is ordinary Go: the deferred close, and the deferred wg.Done that lets main wait for all fifty.

The export is looked up on the instance, not on the compiled module, because a Function is bound to the memory it reads and writes.

examples/concurrent-instantiation/main.go
// Calculates "i + i" by invoking the exported "add" function.
result, err := instance.ExportedFunction("add").Call(ctx, uint64(i), uint64(i))
if err != nil {
log.Panicf("[%d] failed to invoke \"add\": %v", i, err)
}
// Ensure the addition "i + i" is actually calculated.
expected := uint64(i * 2)
if result[0] != expected {
log.Panicf("expected %d, but got %d", expected, result[0])
}
// Logs the result.
fmt.Println(expected)
}(i)
}
wg.Wait()
}

The []uint64 going in and coming back is the slot encoding from Adding two numbers, unchanged by concurrency. What is new is where the lookup happens: ExportedFunction is called on the instance, inside the goroutine, and never hoisted above the loop.

The empty name is what allows fifty of them. A module name must be unique inside a Runtime. Two instances registering the same non-empty name is an error — module[NAME] has already been instantiated — and it is an expensive one, because Store.Instantiate completes the entire instantiation before it tries to register, then closes what it just built and returns the failure. Passing "" makes the instance anonymous and takes it out of that namespace entirely. In this example the module carries no name section, so the default name is already empty and the call is belt-and-braces; swap in a TinyGo or Rust build, which does emit a name section, and the same loop breaks on the second goroutine without it. The trade is that anonymous modules cannot be imported by name from another module, which is the right default for a per-request instance and the wrong one for a long-lived library module.

Shared and not shared. The Runtime and the CompiledModule are meant to be used from many goroutines. An instantiated module is not: api.Function.Call is documented as not goroutine-safe, with the explicit advice to “create another Function if you want to invoke the same function concurrently”. There is no lock to take here — one instance per goroutine is the mechanism. This particular guest declares no memory at all, so nothing could be corrupted even if you tried; the moment the guest holds state — a memory, a mutable global — the instance is the unit that owns it, and it is unshared only because you gave each goroutine its own.

Close each instance, not just the runtime. Every instance links itself into the store’s module list and stays there until it is closed or until the Runtime is. For a fifty-iteration program that distinction never bites; for a server instantiating per request it is the difference between a flat heap and one that grows for the life of the process. The deferred instance.Close(ctx) is the whole discipline. Closing a CompiledModule while calls are still outstanding on instances made from it is explicitly safe, so shutdown ordering is not something you have to engineer.

The cost is what makes this a pattern and not a stunt. Instantiating a compiled 37 KB module takes 1.7 µs and 3.3 KB of heap (reference/performance), which is why the guidance there is to not pool instances: a fresh one per request is both cheaper to reason about and safer than scrubbing a reused one between calls. Keep CompileModule out of the request path and the per-request work is a few microseconds of allocation.

Sharing a compiled module across goroutines is one axis. Sharing compiled code across Runtimes, or across process restarts, is the other — and that is the next example.