Skip to content

The book · 08

Memory

book.md · 102 lines · 3 min read

Goal: understand why you almost never think about memory (colorless + GC default), and how to go down to the metal (@mm, @transfer/@promote) when you need to.

Allocation is colorless: each object carries an implicit pointer to its MemoryManager, and the runtime calls the interface without knowing which strategy is underneath. With the default GC, you write normal code and never touch @mm:

fn build() -> Report {
r := Report.new() // allocates through the context's @mm (GC, by default)
r.add(linha)
return r // no manual free; the GC handles it
}

Mutability follows chapter 01: var mutates, immutable aliases for free (shared without a copy). To mutate the caller’s value, use mut (write-back, inout style):

fn accumulate(total: mut int, values: []int) {
loop v in values { total += v }
}
var soma := 0
accumulate(mut soma, dados) // 'mut' at the call site; the target has to be 'var'

A *T pointer (a single type, no *const/*mut) serves to hold a persistent reference (graphs, FFI); it is born from &place with identity on the heap, and deref is explicit (*p). Collections use [*]T (many-pointer) as backing, something you rarely see directly.

@mm defines the allocator of the context (process, function, or object). The strategies: gc (default), arena (frees in a block), none (manual, C style), c (memory coming from C).

fn handle(req: Request) -> Response {
@mm(arena) {
... // a fresh arena: everything allocated here (callees too) dies at the `}`
}
}
@mm(arena) fn parse(line: string) -> int { ... } // one fresh arena per call, gone when it returns

An arena is a region: a block’s dies at the block’s end, an @mm(arena) fn’s at each return, an @mm(arena) object’s when its scope ends. mm.reset(p) empties p’s region (and keeps it), mm.release(p) gives it back early, mm.used(p) reports its bytes. Using a pointer into a region that has ended is a use-after-free; the compiler catches it in the local scope (copy a value out with @promote to keep it). Beyond that, under arena/none you own the lifetime (unsafe territory); the GC is the safety net by construction.

Moving between processes: @transfer / @promote

Section titled “Moving between processes: @transfer / @promote”

Since processes do not share memory, passing data from one to another is explicit. @transfer moves (invalidates the source; the object travels with its @mm intact); @promote(mm) copies to another MemoryManager:

@mm(arena) spawn producer(data @transfer) // moves; 'data' is invalid here
@mm(gc) spawn consumer(chan)
result @promote(gc, deep) // copies the subgraph to the GC (cuts the cord with the arena)

(A plain copy is the unmarked default; there is no @copy.)

@promote copies the object at the pointer. A gc block carries its length in its header, so promoting a gc [*]T copies the whole block, however many elements it holds. A raw [*]T (a non-gc many-pointer) carries no length – like a C pointer – so it promotes as a single object. To promote a multi-element collection out of a non-gc region, promote a length-carrying value (a List, or a gc block) rather than a raw many-pointer.

OOM is a process event, not a per-call error

Section titled “OOM is a process event, not a per-call error”

The high-level conveniences (List.push, string.append, encode) do not thread OOM, because that would be coloring. The rule is BEAM’s: the allocation that fails kills the process, and the supervisor handles it (ch. 07). The recoverable case with a budget (an arena with a ceiling, a @limit) drops down to mem.alloc, which returns Result[Ptr, error{OOM}]:

@limit(16mb)
spawn worker(data) // blew the ceiling → an OOM error becomes the supervisor's |e|

The ceiling counts everything the process keeps alive (strings, lists, slices, struct payloads, mm.alloc blocks, its copy of the mutable globals), measured on live memory: garbage it already dropped is collected before the limit refuses an allocation. “Alive” means held by a variable in scope; a temporary dies with its statement, a block’s locals with the block.

It is judged after every statement and on entry to every function (a process dies on the statement that crossed it), before every operation that can block (a process never waits over its limit), and at once for a single allocation bigger than the whole limit. --no-strict-limit relaxes the first to where the runtime collects (every mm.alloc, loop iteration and recursive call).

Two worlds without conflict: high-level colorless that crashes (99% of the code), and low-level with explicit Result (an allocator author, OOM with a budget).

Next: 09 · Network channels