Garbage Collection
Everything so far has kept data in linear memory, where the program manages it. That suits C, C++, Rust, Zig, Odin, and any language where the programmer or the compiler decides when memory is freed. A language with a garbage collector has a harder time: it must bring its own collector, compiled to WebAssembly, and that collector cannot see the call stack to find roots. WebAssembly 3.0's garbage collection feature, usually called WasmGC, takes a different approach: the module declares struct and array types, allocates objects of those types, and the engine's own collector, the one that already manages JavaScript objects, manages them. This chapter covers the type system, the instructions, and how to decide whether your language should use it.
#Struct types22.1
A struct type declares a list of fields, each with a type and an optional
mut:
(module
(import "env" "print_f64" (func $print (param f64)))
(type $point (struct (field $x (mut f64)) (field $y (mut f64))))
(func (export "main")
(local $p (ref $point))
(local.set $p (struct.new $point (f64.const 1.5) (f64.const 2.5)))
(struct.set $point $x (local.get $p) (f64.const 10))
(call $print (struct.get $point $x (local.get $p)))
(call $print (struct.get $point $y (local.get $p)))))
10 2.5
struct.new $point pops a value for each field and pushes a reference to
a new object. struct.get and struct.set take the type and the field as
immediates, so the field's position and type are known statically and the
access compiles to a single load or store.
The local's type is (ref $point): a reference to a $point. There is no
pointer arithmetic, no way to get the address of a field, and no way to
free the object. It lives until nothing refers to it.
Fields can also be packed, i8 or i16, to save space. Packed fields
are read with struct.get_s or struct.get_u, which sign- or zero-extend
them to an i32.
#Nullable and non-null references22.2
A reference type is either non-null, (ref $t), or nullable, (ref null
$t). A non-null local must be assigned before it is read, which the
validator checks. A nullable one starts as null, and every access through
it is checked at runtime:
(module
(type $point (struct (field $x f64)))
(func (export "main") (result f64)
(local $p (ref null $point))
(struct.get $point $x (local.get $p))))
RuntimeError: dereferencing a null pointer
ref.as_non_null converts a nullable reference to a non-null one,
trapping if it is null. br_on_null and br_on_non_null branch on
whether a reference is null, and refine its type on the path where it is
not. A compiler for a language with non-nullable types can use non-null
references throughout, and the engine can then omit the null checks.
#Arrays22.3
An array type has a single element type, and each array has a length fixed when it is created:
(module
(import "env" "print_f64" (func $print (param f64)))
(type $floats (array (mut f64)))
(func $sum (param $a (ref $floats)) (result f64)
(local $i i32)
(local $total f64)
(block $done
(loop $next
(br_if $done (i32.ge_u (local.get $i) (array.len (local.get $a))))
(local.set $total (f64.add (local.get $total)
(array.get $floats (local.get $a) (local.get $i))))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $next)))
(local.get $total))
(func (export "main")
(local $a (ref $floats))
(local.set $a (array.new $floats (f64.const 0.5) (i32.const 10))) ;; ten 0.5s
(array.set $floats (local.get $a) (i32.const 3) (f64.const 100))
(call $print (call $sum (local.get $a)))
(call $print (call $sum (array.new_fixed $floats 3
(f64.const 1) (f64.const 2) (f64.const 3))))))
104.5 6
The array instructions include array.new (every element the same),
array.new_default (zeros or nulls), array.new_fixed (the elements
given), array.new_data and array.new_elem (from a segment), array.get
and array.set, array.len, array.fill, array.copy, and
array.init_data. Every access is bounds-checked, and an out-of-bounds
index traps.
An array of i8 is the natural representation of a byte string, and
array.new_data creates one from a data segment, so string literals can
live in data segments and be turned into GC arrays when needed.
#The type hierarchy22.4
GC references fit into a hierarchy of heap types:
anyis the top of the internal hierarchy. Every struct, array andi31reference is ananyref.eqis the types whose references can be compared withref.eq.structandarrayare the supertypes of every struct type and every array type.i31is the type of unboxed 31-bit integers (section 22.7).funcis a separate hierarchy for function references, andexternone for host references;exnholds exception references.none,nofunc,noexternandnoexnare the bottom types, the types ofnullin each hierarchy.
The familiar funcref and externref from Chapter 11 are abbreviations
for (ref null func) and (ref null extern). Likewise anyref, eqref,
structref, arrayref and i31ref are the nullable references to the
abstract types.
#Subtyping and casts22.5
A struct type can be declared a subtype of another, if it has the same fields at the start, possibly followed by more. A reference to the subtype can then be used wherever the supertype is expected. This is how class hierarchies are compiled:
(type $animal (sub (struct (field $legs i32))))
(type $bird (sub $animal (struct (field $legs i32) (field $wingspan f32))))
A type written with sub can have subtypes; a type without it is final,
and cannot. Final types are the default because the engine can then test
the exact type with a single comparison.
Going the other way, from a supertype to a subtype, needs a runtime check.
ref.test tests whether a reference is of a type, ref.cast converts it
and traps if it is not, and br_on_cast and br_on_cast_fail branch on
the result:
(module
(import "env" "print_i32" (func $print (param i32)))
(type $a (struct (field i32)))
(type $b (array i8))
;; 1 for a struct $a, 2 for an array $b, 3 for an i31, 0 otherwise
(func $kind (param $x anyref) (result i32)
(if (ref.test (ref $a) (local.get $x)) (then (return (i32.const 1))))
(if (ref.test (ref $b) (local.get $x)) (then (return (i32.const 2))))
(if (ref.test (ref i31) (local.get $x)) (then (return (i32.const 3))))
(i32.const 0))
(func (export "main")
(call $print (call $kind (struct.new $a (i32.const 1))))
(call $print (call $kind (array.new_default $b (i32.const 4))))
(call $print (call $kind (ref.i31 (i32.const 7))))
(call $print (call $kind (ref.null any)))))
1 2 3 0
#Recursive types22.6
Types often refer to each other. A linked list node refers to its own type, which needs nothing special:
(module
(import "env" "print_i32" (func $print (param i32)))
(type $node (struct (field $value i32) (field $next (ref null $node))))
(func (export "main")
(local $list (ref null $node))
(local $i i32)
(local $sum i32)
;; build 1 -> 2 -> 3 -> 4 -> 5 by pushing 5, 4, 3, 2, 1 onto the front
(local.set $i (i32.const 5))
(loop $build
(local.set $list (struct.new $node (local.get $i) (local.get $list)))
(local.set $i (i32.sub (local.get $i) (i32.const 1)))
(br_if $build (local.get $i)))
;; walk it
(block $end
(loop $walk
(br_on_null $end (local.get $list)) ;; leaves a non-null reference
(struct.get $node $value) ;; ... which this consumes
(local.set $sum (i32.add (local.get $sum)))
(local.set $list (struct.get $node $next (ref.as_non_null (local.get $list))))
(br $walk)))
(call $print (local.get $sum))))
15
When several types refer to one another, they must be declared together
in a recursion group, (rec ...). A class with a vtable is the classic
case: the object refers to the vtable, the vtable holds functions, and the
functions take the object. Here is Chapter 11's shapes example again, with
GC objects in place of linear memory:
(module
(import "env" "print_f64" (func $print (param f64)))
(rec
(type $shape (sub (struct (field $vt (ref $vtable)))))
(type $vtable (struct (field $area (ref $area_fn))))
(type $area_fn (func (param (ref $shape)) (result f64))))
(type $circle (sub final $shape (struct (field $vt (ref $vtable)) (field $r f64))))
(type $rect (sub final $shape (struct (field $vt (ref $vtable)) (field $w f64) (field $h f64))))
(func $circle_area (type $area_fn) (param $s (ref $shape)) (result f64)
(local $c (ref $circle))
(local.set $c (ref.cast (ref $circle) (local.get $s)))
(f64.mul (f64.const 3.14159)
(f64.mul (struct.get $circle $r (local.get $c)) (struct.get $circle $r (local.get $c)))))
(func $rect_area (type $area_fn) (param $s (ref $shape)) (result f64)
(local $r (ref $rect))
(local.set $r (ref.cast (ref $rect) (local.get $s)))
(f64.mul (struct.get $rect $w (local.get $r)) (struct.get $rect $h (local.get $r))))
(global $circle_vt (ref $vtable) (struct.new $vtable (ref.func $circle_area)))
(global $rect_vt (ref $vtable) (struct.new $vtable (ref.func $rect_area)))
;; shape.area(): load the vtable, load the method, call it
(func $area (param $s (ref $shape)) (result f64)
(call_ref $area_fn
(local.get $s)
(struct.get $vtable $area (struct.get $shape $vt (local.get $s)))))
(func (export "main")
(call $print (call $area (struct.new $circle (global.get $circle_vt) (f64.const 2))))
(call $print (call $area (struct.new $rect (global.get $rect_vt) (f64.const 3) (f64.const 4))))))
12.56636 12
Two new things appear here. ref.func creates a typed function
reference, of type (ref $area_fn), and call_ref calls through one.
Unlike call_indirect, call_ref needs no table, and no runtime
signature check, since the reference's type already guarantees the
signature. Typed function references are also how closures are compiled
with GC: a struct holding the captured variables and a (ref $fn).
The cast in each area function is the price of a method receiving its object as the base type. Engines make a cast to a final type cheap: one load and one compare.
#i31ref22.7
An i31ref is a 31-bit integer stored directly in a reference, with no
allocation. ref.i31 makes one from the low 31 bits of an i32, and
i31.get_s and i31.get_u read it back:
(module
(import "env" "print_i32" (func $print (param i32)))
(func (export "main")
(call $print (i31.get_s (ref.i31 (i32.const -5))))
(call $print (i31.get_u (ref.i31 (i32.const -5))))))
-5 2147483643
Dynamically typed languages represent every value as a reference, and
i31ref lets small integers share that representation without allocating
a box for each. An anyref can hold either an object or an i31, and
ref.test or br_on_cast tells them apart. OCaml, Scheme and Python
compilers targeting WasmGC use it this way.
#GC objects and JavaScript22.8
GC references can be passed to JavaScript and back. JavaScript sees them as opaque objects: it can store them, pass them back, and compare them, but it cannot read their fields or call methods on them:
(module
(type $point (struct (field $x f64) (field $y f64)))
(func (export "make") (param f64 f64) (result (ref $point))
(struct.new $point (local.get 0) (local.get 1)))
(func (export "x") (param (ref $point)) (result f64)
(struct.get $point $x (local.get 0))))
import fs from 'node:fs';
const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'));
const p = instance.exports.make(3, 4);
console.log(typeof p, Object.keys(p).length, p.x);
console.log(instance.exports.x(p));
try {
instance.exports.x({ x: 3 });
} catch (e) {
console.log(e.constructor.name);
}
object 0 undefined 3 TypeError
Passing an ordinary JavaScript object where a (ref $point) is expected is
a TypeError, checked at the boundary. Inside WebAssembly, extern
.convert_any and any.convert_extern convert between the any and
extern hierarchies, so a value of either kind can be stored as the
other.
Most importantly, the engine has one garbage collector for JavaScript and WasmGC objects. A WasmGC object referring to a JavaScript object, which refers back to it, is collected when both are unreachable. With a collector in linear memory, such cycles between the two heaps leak.
#Should your language use it?22.9
WasmGC is not a better linear memory. It is a different model, with different strengths:
| Linear memory | WasmGC | |
|---|---|---|
| Memory management | yours: manual, reference counting, or your own collector | the engine's collector |
| Layout control | complete: unions, interior pointers, custom alignment | structs and arrays only; no interior pointers, no unions |
| Code size | includes your allocator and collector | no runtime needed |
| Finding roots | needs a shadow stack (Chapter 10) | automatic |
| Cycles with JavaScript | leak, unless managed by hand | collected |
| Pauses | yours to control | the engine's, short and mostly concurrent |
| Interop with C libraries compiled to Wasm | direct: same memory | needs copying |
| Bulk data for browser APIs | typed arrays over memory, no copy | must be copied out |
For a language whose values are naturally objects, such as Java, Kotlin, Dart, OCaml, Scheme or a scripting language, WasmGC is usually the better choice: smaller modules, no collector to write, and easy interop with JavaScript. Kotlin, Dart and Java (through J2Wasm) target it in production.
For a game engine's systems language, linear memory usually remains the better choice. A game wants control over layout and allocation, wants to pass vertex and audio data to browser APIs without copying, wants to share memory with C libraries, and wants to decide itself when memory is reclaimed, for instance freeing a whole frame's arena at once. Nothing stops a language from using both: linear memory for bulk data, GC references for a scripting layer's objects, or for handles to host objects.
#What you have seen22.10
WasmGC adds struct and array types whose objects the engine allocates and
collects. References are typed, nullable or not, and fit into a hierarchy
with any, eq, struct, array and i31 in one tree and functions and
host references in others. Subtyping, casts and recursion groups let
classes and vtables be expressed directly, typed function references
replace tables for calls, and i31ref unboxes small integers. JavaScript
sees GC objects as opaque, and one collector manages both heaps. It is the
natural target for object-oriented and functional languages, and less so
for systems languages that want control over memory.
The next chapter covers the last major feature: threads.