Skip to content

Memory model

Gossamer manages memory for you automatically: there is no borrow checker, no lifetime annotations, and no manual ownership transfer. & and &mut exist - but they express aliasing intent, not ownership.

Under the hood the compiled tiers use deterministic reference counting with a cycle collector, not a tracing collector: most values are reclaimed at the moment their last reference dies, RAM stays flat and predictable, and there are no stop-the-world tracing pauses. The arena { } block (below) layers bulk allocation on top for short-lived object graphs.

Relationship to Swift's ARC

Gossamer's compiled-tier reference counting is closely modeled on Swift's Automatic Reference Counting (ARC), with one significant addition.

What is the same as Swift ARC:

  • Compiler-inserted retain/release. The compiler emits balanced retain/release pairs at compile time; there is no background thread and no tracing pass scanning the heap for liveness.
  • Deterministic, immediate reclamation. A strong count reaching zero destroys the value right then and releases its children. A liveness pass releases at a value's last use, the same kind of last-use shortening Swift's ARC performs.
  • Weak references. Weak<T> mirrors Swift's weak: it does not raise the strong count, and upgrade() returns None once the referent is gone. Gossamer does not provide Swift's unowned.

Where Gossamer diverges from Swift ARC:

  • Reference cycles are reclaimed automatically. This is the key difference. Swift's ARC never collects cycles: a retain cycle leaks unless you break it by hand with weak/unowned. Gossamer's compiled tiers add a Bacon-Rajan trial-deletion cycle collector (see below) that reclaims cycles with no annotation, so ownership cycles are not a leak you have to design around. The one exception is the bytecode interpreter (gos run), which backs values with Rust's Arc and does not collect cycles - there a strong cycle leaks, matching Swift's behavior. This is the same cross-tier caveat noted under weak references.
  • Reference counts are non-atomic by default. Swift uses atomic reference counts. Gossamer counts goroutine-local objects non-atomically and promotes an object to atomic counting only when it escapes to another goroutine, so single-goroutine code does not pay for the synchronization.
  • arena { } regions layer bulk bump-allocation on top, which ARC has no equivalent for.

In short: ARC's compile-time discipline and determinism, plus the automatic cycle reclamation that ARC leaves to the programmer.

Values vs references

  • Value-semantic types are copied on assignment and passed by value: bool, char, i8..i64, u8..u64, isize/usize, f32/f64.
  • Reference-semantic types share their backing storage when copied; the runtime reclaims the backing when the last reference dies. This includes String, Vec<T>, [T], struct, enum, closures.

& and &mut

&x means "read x without taking ownership". &mut x means "write x without taking ownership". Both are aliases of the source place, never copies. In particular:

let mut xs = [1, 2]
let r = &mut xs
r[0] = 0
// xs and r both observe [0, 2]

let mut r = &xs makes the reference binding rebindable, not its referent writable. Its type remains &T after a rebind. Conversely, let r = &mut xs permits writes through r but does not make r rebindable. The type checker rejects:

  • A &mut taken on a non-mut binding.
  • A bare value passed to an &mut T parameter. Write call(&mut value); only an existing &mut T reference may be forwarded without another &mut.
  • An assignment through a shared &T reference.
  • A mutating method call on an immutable place.
  • A projection that crosses any shared layer inside a nested reference chain.

These are correctness rules, not lifetime proofs. You never write 'a, and &mut does not mean "the only reference" as it does in Rust. It grants write access through an alias. The original mutable binding and every permitted mutable-reference chain can therefore observe and update the same value.

Gossamer rejects a second simple named &mut taken directly from the same root while the first remains in lexical scope. This catches the common accidental sibling-alias case. It also rejects a temporary &mut overlapping an active named reference and duplicate &mut roots in one call. These checks are not a general uniqueness proof. Taking a mutable reference to an existing mutable-reference binding creates a chain:

let mut a = [1, 2]
let mut b = &mut a       // b: &mut [i64; 2]
let c = &mut b           // c: &mut &mut [i64; 2]
c[0] = 0                 // auto-dereferences both layers
// a, b, and c all observe [0, 2]

c does not create independent storage or a second direct borrow of a; it aliases the reference slot b, which already points at a. This is allowed because references express access intent rather than ownership. Within one goroutine, coordinating such aliases is the programmer's responsibility. Across goroutines, shared mutation must be synchronized with channels, std::sync primitives, or atomics as described in the concurrency memory model.

How reclamation works

  • Reference counting, compiler-inserted. The MIR lowering inserts balanced retain/release pairs; a liveness pass releases owned values at their last use rather than at function exit, so a large structure does not pin memory while unrelated code runs.
  • Cycle collector. Reference cycles (a.next = Some(b); b.next = Some(a)) are reclaimed by a Bacon-Rajan style cycle collector that runs on demand (runtime::collect_cycles()) and from allocation pressure (a fixed candidate-count threshold, not a wall-clock timer, so collection is a deterministic function of allocation events). Acyclic data never pays for it. Because Gossamer has no user-visible finalizer, when a cycle is reclaimed is invisible to program output, so collection never changes a result or breaks cross-tier reproducibility.
  • Weak references. Weak<T> observes a value without keeping it alive; w.upgrade() returns Option<T> and answers None once the referent has been reclaimed. One cross-tier caveat: a Weak that points at a member of a strong cycle (an unusual shape - weak references normally break cycles, in which case there is no strong cycle and every tier agrees) observes that member as live on the interpreter (gos run, whose collector is a no-op and leaks the cycle) but as None on the compiled tiers once the collector has run. Do not branch on upgrade() of a known strong-cycle member if you need identical behavior under gos run and gos build.
  • Compact representation. A heap enum node carries an 8-byte runtime header. Enums with at most 4 variants store their discriminant in pointer tag bits, so a two-pointer tree node costs 24 bytes - and only 16 inside an arena.

Arenas: arena { }

An arena block bump-allocates everything created while it runs and frees the whole lot at once when the block exits:

fn main() {
    let mut total = 0
    let mut i = 0
    while i < 1000 {
        arena {
            let tree = build_tree(16)
            total += check(&tree)
        }
        i += 1
    }
    println!("{}", total)
}

Semantics:

  • Allocation is a pointer bump - a compare and an add, roughly an order of magnitude cheaper than a general heap allocation.
  • Reclamation is wholesale - the arena's slabs are released in O(slabs) when the block exits, with no per-object teardown walk. The exit is exact on every path: early return, ?, break, and normal fall-through all release the arena (the block desugars to a defer).
  • Headerless nodes. Enum nodes of tagged-repr types (at most 4 variants) allocated inside an arena carry no header at all: a Node(Box<Tree>, Box<Tree>) is exactly 16 bytes.
  • Arenas nest. An inner arena { } frees at its own close brace; the outer arena is unaffected. Slabs from finished arenas are recycled, so an arena per loop iteration costs a bump-pointer reset, not an mmap.
  • Retain/release become no-ops for arena values - the accounting entries recognize arena memory with a two-instruction range check.

The contract

Nothing allocated inside an arena { } may be referenced after the block exits. The block is statement-position only and yields unit, so the obvious escape (the block's value) cannot happen, and a tail expression inside it is deliberately discarded. The remaining escapes are checked for you: a conservative front-end analysis rejects, with error[GM0003], any value allocated in the block that

  • is assigned to a binding declared outside the block,
  • is pushed into a container that outlives the block,
  • is sent down a channel,
  • is returned or broken out of an enclosing loop, or
  • is captured in a closure or goroutine that outruns the block, or passed into a function that might stash it.

Reading an arena value through a method or a region-safe free function stays allowed, so build-and-discard code is unaffected. The check is sound by over-approximation: it may ask you to restructure a sound program, but it never lets an escaping one compile. Run gos explain GM0003 for the details. (The raw runtime::arena_push() / arena_pop() primitive below is the unchecked escape hatch; there the contract is yours to uphold.)

Use an arena when the block is pure computation over data that dies together - build, traverse, summarize, exit. If a value must survive, let the block compute a scalar/string summary, or build the surviving value before (outside) the arena.

Two deliberate edge-case rules: Weak references to arena values upgrade to None (the referent is not individually tracked), and a panic that unwinds out of a goroutine mid-arena abandons the arena's slabs to the goroutine's teardown rather than corrupting them.

runtime::arena_push() / runtime::arena_pop() remain available as the low-level primitive when block structure does not fit; the block form is the idiom because it cannot be left unbalanced.

Goroutine stacks

Each go expr launches a goroutine with its own stack. Captures are reference-counted exactly as regular struct fields would be.

When to reach for Rc<RefCell<T>>-like patterns

You generally don't. Shared aliasing works directly. If you need to mutate through a shared handle across goroutines, hold the value in a Mutex<T> (from std::sync) and lock around every mutation.

Stack vs heap - the pragmatic answer

  • Small value types live on the stack or inline inside their aggregate.
  • Aggregates (String, Vec<T>, structs, enums, closures) live on the heap, reference-counted; Box<T> / Rc<T> / Arc<T> spellings are accepted and transparent.
  • Short-lived object graphs belong in an arena { }.