Skip to content

Memory Model

Automatic Scope-Based Arena Management (ASBAM)

Section titled “Automatic Scope-Based Arena Management (ASBAM)”

Grayscale uses Automatic Scope-Based Arena Management (ASBAM), a memory management model that combines arena allocators with scope-driven lifecycle control and automatic escape detection. There is no garbage collector, no reference counting, and no ownership annotations. The compiler infers everything from scope structure.

ASBAM is built on four principles:

  1. Arena allocation — memory is allocated from arena regions and freed in bulk, not per-object. There is no per-allocation overhead.
  2. Scope-driven lifecycle — every scope boundary (function, loop iteration, conditional block) defines a memory region. When the scope ends, its region is reclaimed in a single operation.
  3. Automatic escape detection — when a value created inside a scope is stored somewhere that outlives that scope, the compiler automatically deep-copies it to the outer scope’s arena before the inner arena is reclaimed.
  4. Dual-arena separation — two arenas serve distinct roles: a default arena for scope-bound temporaries, and a heap arena for persistent allocations via new().

When a block of code ends, whether a function body, a loop iteration, or a conditional block, any memory it created is freed. If a value needs to survive because it escapes the scope, ASBAM handles it automatically.

import @strings
do process(name string) {
mut upper = strings.to_upper(name) // allocated in this scope
mut parts = strings.split(upper, ",") // allocated in this scope
println(parts[0])
}
// function ends -> upper and parts are freed
do main() {
process("a,b")
}

No imports, no annotations, no cleanup calls.

When a value is stored somewhere that outlives the current scope, Grayscale copies it to the outer scope:

import @strings
import @arrays
do main() {
mut lines [string] = {"a", "b"}
mut results [string] = {}
for_each line in lines {
mut upper = strings.to_upper(line)
arrays.append(results, upper) // upper escapes into results
}
// results lives until its scope ends
// each iteration's other temporaries are freed
println(results)
}

Composite types (arrays, maps) have value semantics for plain assignment and for function parameters (unless the parameter is declared mutable) — assigning one to a variable, into an existing struct field, or into a container element (grid[0] = row, m["k"] = arr, arrays.append(outer, row)), or reading one out of a container (mut e [i64] = grid[0]), copies it:

do main() {
mut a [i64] = {1, 2, 3}
mut b [i64] = a
b[0] = 99
println(a[0]) // 1 - b is an independent copy, not an alias
}

The one place a composite gets aliased instead of copied is a literal that embeds an existing value — a struct or array/map literal that names an existing variable as one of its fields/elements shares that variable’s backing storage rather than copying it:

const Box struct {
items [i64]
}
do main() {
mut arr [i64] = {1, 2, 3}
mut box Box = Box{items: arr} // struct literal embeds arr
box.items[0] = 99
println(arr[0]) // 99 - box.items aliases arr
}

The same happens for an array or map literal that embeds an existing array/map as one of its elements/values ({arr}, {"key": existing_map}). This aliasing is scope-local: if the literal crosses a scope boundary (returned, or otherwise escaping), ASBAM’s escape-copy (11.1) still deep-copies it, so it can’t produce a dangling reference — but two literals built from the same source within the same scope will unexpectedly share mutable storage. Use copy() (11.3) when a literal needs to be independent of the value it was built from.

The copy() function creates a deep copy of any value, including nested structures:

const Person struct {
name string
age i64
}
do main() {
mut original = Person{name: "Alice", age: 30}
mut duplicate = copy(original)
duplicate.age = 31 // original.age is still 30
println(original.age)
}

Pointer fields alias, not copy. A pointer-typed field is deliberately left pointing at its original referent — copy() does not follow it and duplicate the pointee. If a struct holds a pointer into itself (a self-referential field), the copy’s field still points at the original value, not the copy’s own:

const Node struct {
val i64
self_ptr ^i64
}
do main() {
mut n Node = Node{val: 1}
n.self_ptr = addr(n.val)
mut n2 = copy(n)
n2.val = 999
println(n2.self_ptr^) // 1, not 999 - still aliases n.val
}

This matches pointer-copy semantics in other systems languages. The pointer checker (11.7) still guarantees safety around it: a copy that carries a pointer aliasing its source is tied to the source’s lifetime, so it cannot escape to an outer scope while the source is reclaimed.

The new() function allocates a zero-initialized value of any type on the heap arena and returns a pointer to it:

Type Zero Value
integer types 0
f32 / f64 0.0
string ""
bool false
char '\0'
[T] Empty array (valid for append)
map[K:V] Empty map ({:})
enum First variant
struct All fields zero-initialized

Three block types create memory scopes:

  • Function bodies: temporaries freed on return, return values survive by copying to the caller’s scope
  • Loop iterations (for, for_each, as_long_as): each iteration’s temporaries freed, values that escape into outer-scope containers survive
  • Conditional blocks (if, or, otherwise): temporaries freed on block exit, values assigned to outer-scope variables survive

Nested scopes work correctly; a loop inside an if inside a function creates three scope levels, each cleaning up independently.

The @mem module provides explicit arena control for users who need it:

import @mem
const Node struct {
val i64
}
do main() {
mut scratch = mem.arena(4096)
mut node = mem.init(scratch, Node)
// ... use node ...
mem.reset(scratch)
mem.destroy(scratch)
}

Grayscale is memory safe by default. ASBAM prevents common memory errors automatically, and the pointer checker — a compile-time pass that traces the lifetime of every pointer value — catches the rest. Memory safety is not unconditionally guaranteed — opting into the @mem module, raw pointers, unsynchronized threading, or C interop (extern import) introduces hazards that the programmer is responsible for. But for programs that stay within Grayscale’s defaults, memory safety holds without annotations or manual management: the pointer checker proves no pointer is ever readable after the memory it points to has been reclaimed.

Compile-time checked:

Hazard Grayscale Behavior
Returning a pointer to a local variable return addr(local) is rejected, however the address is laundered — through an intermediate variable, a struct field, an array/map literal, a function call that forwards it, or a returned new() object’s pointer field
Storing a pointer where the destination outlives its referent Assigning addr() of a shorter-lived value into a longer-lived variable, struct field, or array element is rejected; so is passing it to arrays.append/prepend/insert_at/fill, and writing it through a pointer or &mut parameter (which hands the caller a dangling pointer once the callee returns)
Writing through a pointer to a const-declared variable addr() on a const-declared variable produces a read-only pointer; assignment through it is rejected
Dangling pointer into a relocated container addr(), raw(), or ref() on a dynamic [T] array element or a map value is rejected; the backing store relocates when the array grows or the map rehashes. Fixed-size [T,N] array elements are allowed — their storage never moves
Pointer-type reinterpretation cast() between two pointer types is rejected; cast() converts values, not pointer identity
Double-destroy/reset of a @mem arena A second mem.destroy() or mem.reset() on an arena already destroyed is rejected. Flow-sensitive within the function: a destroy on only one branch of an if/when, or on an earlier loop iteration, is still seen. Also traced across a function call: a helper that destroys or resets its own arena parameter (directly, or by forwarding it to another helper that does) is treated as destroying/resetting the caller’s arena at the call site
Use of a @mem pointer after mem.destroy() or mem.reset() Dereferencing a pointer into an arena that has been destroyed, or reset past the point the pointer was taken, is rejected — including when the destroy/reset happened on only one branch, on a prior iteration of an enclosing loop, or inside a helper the arena was passed to
A pointer allocated inside a called function and handed back to the caller Traced the same way as a destroy/reset: a helper that allocates on its own arena parameter and returns the pointer is followed at the call site, so do make(a Arena) -> ^i64 { return mem.alloc(a, 1) } followed by mem.destroy(a); use(p) in the caller is a compile error, not a runtime fallback
An arena reached through a struct field An arena stored in a field and destroyed/reset by reading it back through that same field — including across a function call that takes the struct (or a pointer to it) and destroys the field itself — is traced

Prevented by ASBAM:

Hazard How
Memory leaks in long-running programs Scopes free allocations on exit
Use-after-free (default arena) Out-of-scope values can’t be named — if you can’t reach it, it’s freed
Dangling returns (default arena) Return values are copied to the caller’s scope — the data moves, the pointer stays valid
Loop memory accumulation Each iteration is a scope; temporaries cleaned up on iteration end

Runtime-checked (safe by default):

Hazard Grayscale Behavior
Nil pointer dereference Runtime panic
Array out-of-bounds Runtime panic
Map key not found Runtime panic
Division by zero Runtime panic
Integer overflow Runtime panic (checked arithmetic)
Stack overflow (deep recursion) Detected and reported
Double-destroy on a @mem arena the checker can’t trace to a named arena parameter Runtime panic (P0002)
Allocating (mem.init()/mem.alloc()) from a @mem arena the checker can’t trace as already destroyed Runtime panic (P0001)
Dereferencing a @mem pointer after its arena was destroy()ed, when the checker can’t trace the arena at compile time Runtime panic (P0117) whenever the variable holding the pointer was itself directly assigned from mem.init()/mem.alloc() — the same arena expression used at that call is re-checked at every dereference of that variable, however the arena is reached (global, struct field, chained pointer deref). Not caught this way if the pointer is copied into another variable, struct field, or container before being dereferenced there instead — see “Not checked” below

Not checked (programmer responsibility):

Hazard When It Can Happen
An arena reached through an array/map element, or through a global whose value came from a call result The compile-time trace follows a plain parameter, a struct field, and a pointer-dereference chain of those; an arena reached by indexing a container, or produced by a function call, is not traced. Falls back to the runtime checks below rather than a compile error
A @mem pointer copied into another variable, struct field, or container before being dereferenced Both the compile-time trace and the runtime dereference check (P0117) follow the variable a mem.init()/mem.alloc() result was directly assigned to. Assign that pointer to a second variable, store it in a struct field or container, and dereference it from there instead, and neither catches a subsequent use after the arena is destroyed
Use of a @mem pointer after mem.reset() (as opposed to mem.destroy()) on a path the compile-time checker can’t trace The runtime dereference check only inspects an arena’s destroyed flag, which mem.reset() does not set. A pointer taken before a reset the checker couldn’t trace, then dereferenced after it, is not caught at compile time or at runtime — the memory may already have been handed out again by a later allocation
Data races Multiple threads accessing shared data without sync.lock()
Aliased pointer mutation Two or more pointers to the same variable created via addr() or raw(). Changes through one are visible through all others. Safe in single-threaded code; requires sync.lock() in threaded code.
Nil dereference via raw() raw() pointers skip nil checks on dereference. If a raw() pointer is nil, behavior is undefined.
Const mutation via raw() raw() bypasses const-source write protection. The programmer is responsible for correctness.
Pointer retained by a C function Passing addr() of a Grayscale value to a C function (extern import) that stores the pointer. The value is freed when its scope ends; the pointer the C side still holds dangles. The pointer checker does not cross the extern. call.
Memory freed across the C boundary A C function frees memory Grayscale still references, or a Grayscale-owned allocation is passed to C free(). Neither side tracks the other’s lifetimes.
Out-of-bounds write by a C function A C function writes past the end of a buffer passed from Grayscale. Bounds checking does not cross the extern. call.
Pointer arithmetic Not supported in the language (disallowed by design)

For programs that stay in the safe subset — no @mem, no raw(), no threading, no C interop — the pointer checker makes use-after-free a compile error, not a runtime hazard. @mem, raw(), threading, and extern import are Grayscale’s explicit unsafe opt-outs; reaching for one of them is what puts memory safety back in the programmer’s hands.

ASBAM is implemented using arena allocators. An arena is a block of memory that grows as needed and is freed all at once. There is no per-object deallocation — when a scope ends, its entire arena is discarded in a single O(1) operation.

Every Grayscale program starts with two arenas, and each thread gets its own independent pair:

  • The default arena — used by all runtime allocations: strings, arrays, maps, and temporaries. This is the arena that scopes swap in and out. When a scope ends, this arena is either watermark-reset (void functions, blocks) or destroyed entirely (non-void functions).
  • The heap arena — used exclusively by new(). It lives for the entire program and is never swapped or reset. This is why pointers returned by new() are always valid until the program exits.

The per-thread isolation means multithreaded programs never contend over memory allocation.

When a non-void function is called, a fresh arena is created and set as the active arena. All allocations inside that function go to this new arena. When the function returns, the return value is copied to the caller’s arena, and the function’s arena is destroyed.

Void functions use a cheaper strategy: they save a watermark on the current arena and reset to that point on exit, reclaiming memory without creating or destroying anything.

Functions that accept mutable reference parameters (&) skip scoping entirely and run directly in the caller’s arena. This is necessary because writes to passed arrays or maps must survive the function call.

Conditional blocks (if, otherwise) and loop iterations (for, for_each) each get their own arena. For loops, a new arena is created and destroyed on every iteration, which is why temporaries inside a loop never accumulate regardless of how many iterations run.

When a value created inside a scoped block needs to survive — for example, appending to an outer array inside a loop — Grayscale automatically copies it to the outer scope’s arena before the inner arena is destroyed.

When return, break, or continue exits through nested scopes, the runtime unwinds all live arenas in reverse order. A break inside an if inside a loop will clean up the if-block arena and the loop iteration arena before jumping out. This ensures no memory is leaked regardless of control flow.

Arenas start at a fixed size but are not limited by it. If an allocation exceeds the remaining space, the arena chains a new, larger block automatically. An arena never fails due to its initial size being too small.

By default, the runtime’s managed arenas (default and heap) are capped at 1 GB each. If a program attempts to grow beyond this limit, it panics with P0104. Use the --arena-limit flag to adjust the cap:

Terminal window
gray build main.gray --arena-limit=256MB # restrict to 256 MB
gray build main.gray --arena-limit=2GB # allow up to 2 GB

User-created arenas (via mem.arena()) are not subject to this limit.