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.
Run it
Section titled “Run it”$ go run main.go098440423032242--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:
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.gofunc 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))) }}A guest with nothing in it
Section titled “A guest with nothing in it”The guest is deliberately trivial, so that nothing in it distracts from the host-side lifecycle:
(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:
wasm-tools parse testdata/add.wat -o testdata/add.wasmThe 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
Compile once, above the fork
Section titled “Compile once, above the fork”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.
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.wasmvar 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.
Instantiate inside the goroutine
Section titled “Instantiate inside the goroutine”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.
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.
One function handle per instance
Section titled “One function handle per instance”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.
// 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 name, the memory, and the close
Section titled “The name, the memory, and the close”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.
From here
Section titled “From here”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.