Chapter 22

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:

any eq i31 struct array $point $bytes none func $area_fn nofunc extern noextern exn noexn types the module defines
The heap type hierarchy. Each type is a subtype of the one above it; the bottom types have only the null value.

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.