Memory model¶
Gossamer is garbage-collected from the programmer's point of view:
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 pause times. The
arena { } block (below) layers bulk allocation on top for
short-lived object graphs.
Values vs references¶
- Value-semantic types are copied on assignment and passed
by value:
bool,char,i8..i128,u8..u128,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". The type checker rejects:
- A
&mutoverlapping with another&or&mutin a visible scope. - A
&muttaken on a non-mutbinding.
These are correctness rules, not lifetime proofs. You never
write 'a.
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. Acyclic data never pays for it. - Weak references.
Weak<T>observes a value without keeping it alive;w.upgrade()returnsOption<T>and answersNoneonce the referent has been reclaimed. - 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 adefer). - 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 anmmap. - 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 ways to
leak a pointer out are yours to avoid:
- assigning an arena value to a binding declared outside the block,
- pushing arena values into a container that outlives the block,
- sending arena values down a channel,
- capturing them in a closure or goroutine that outruns the block.
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 { }.