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:
- Arena allocation — memory is allocated from arena regions and freed in bulk, not per-object. There is no per-allocation overhead.
- 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.
- 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.
- 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 @stringsimport @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)}Reference Semantics
Section titled “Reference Semantics”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] = ab[0] = 99println(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 arrbox.items[0] = 99println(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.
Deep Copy
Section titled “Deep Copy”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 = 999println(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.
Zero Values
Section titled “Zero Values”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 |
Scoped Blocks
Section titled “Scoped Blocks”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.
Manual Control
Section titled “Manual Control”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)}Memory Safety
Section titled “Memory Safety”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.
Under the Hood
Section titled “Under the Hood”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.
Dual-Arena Architecture
Section titled “Dual-Arena Architecture”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 bynew()are always valid until the program exits.
The per-thread isolation means multithreaded programs never contend over memory allocation.
How Scopes Work
Section titled “How Scopes Work”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.
Block and Loop Scopes
Section titled “Block and Loop Scopes”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.
Early Exits
Section titled “Early Exits”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.
Growth
Section titled “Growth”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:
gray build main.gray --arena-limit=256MB # restrict to 256 MBgray build main.gray --arena-limit=2GB # allow up to 2 GBUser-created arenas (via mem.arena()) are not subject to this limit.