Skip to content

Memory

Manual memory management is the default in Dusk. There is no ambient garbage collector: nothing is collected unless a value opts in through the collector<T> wrapper, a conservative collected heap covered in the collector reference. By default the language gives you explicit allocation, deterministic cleanup with defer, swappable allocators, and a generational heap that turns a use after free or a double free into a deterministic fault instead of memory corruption.

This guide covers the day-to-day workflow. The memory reference states the rules precisely, and the std.memory pages document the shipped allocators.

A normal variable declaration is stack allocated. No annotation is needed, and nothing has to be freed.

x: int32 = 5 // stack allocated

Heap memory enters the picture only through alloc. There is no address-of operator for stack variables; pointers exist only as the result of an explicit heap allocation.

alloc heap-allocates a value and returns a managed pointer to it. The allocation size is inferred from the declared type on the left hand side, so you never pass a byte count, which prevents size and type mismatch bugs.

x: *int64 = alloc(100) // 8 bytes, initialized to 100
y: *char = alloc('c') // 1 byte, initialized to 'c'
z: *int64 = alloc() // 8 bytes, uninitialized

The uninitialized form requires the pointer annotation, since the annotation is what sizes the block. A bare x := alloc() is a compile error.

Heap values are dereferenced explicitly with the * prefix operator; there is no implicit dereference. free releases the allocation, and defer schedules cleanup to run when the enclosing function scope exits, in reverse order of registration, including on an early return.

defer_basics.dusk
func main() -> int32 {
p: *int64 = alloc(100)
defer free(p)
println(*p + 1)
return 0
}

defer makes deallocation deterministic and visible without any ownership tracking. One restriction: a defer sits at the top level of its function. Registration is lexical and every return replays the list, so a defer inside a conditional or a loop is a compile error. Dynamic registration is planned.

Pointers themselves are immutable. Once a pointer binding is assigned it cannot be reassigned to a different address; reassigning an owning pointer is rejected as a leak.

An allocator is any type that implements the built-in Allocator interface:

interface Allocator {
alloc(size: int64, align: int64) -> *void
free(p: *void) -> void
}

The standard library ships four allocators that implement it:

  • Heap: backed by libc, the default when no allocator is in scope.
  • Arena: frees everything at once; per-object free is a no-op.
  • FixedBuffer: a bump allocator over a buffer you provide, touching no heap. Good for embedded or scratch use.
  • Debug: tracks allocations to report leaks and catch a double free, and poisons freed memory.

You can write your own allocator by implementing the interface. You never redefine alloc itself. No builtin is overridable, and there is no function overloading.

alloc and free are builtins, but they are not a fixed implementation: they lower to a call on the allocator that is in scope. A function that allocates marks a parameter with using to designate it as the ambient allocator for that function body. The signature shows the function needs an allocator, while the body stays readable: you write alloc(...), not allocator.alloc(...).

The spec’s general shape takes the interface type:

func work(using allocator: Allocator) -> void {
p: *Point = alloc(Point { x: 1.0, y: 2.0 }) // uses the passed allocator
defer free(p)
}

In the current compiler, the repository’s examples pass a concrete allocator type through using, which also makes the dispatch static and zero cost. Dispatch falls back to a vtable call only when the allocator type is erased behind the interface. Here is a complete program routing allocations through an explicit Heap:

heap_using.dusk
@import std.memory.allocator
func work(using a: Heap) -> void {
p: *int64 = alloc(8)
*p = 41
println(*p + 1)
free(p)
}
func main() -> int32 {
h := heap()
work(h)
return 0
}

Two rules to keep in mind:

  • free must run under the allocator that produced the pointer, the same caller-matches rule C allocators follow. Inside a using scope, free goes to the scope’s allocator, so do not free a default heap block there.
  • main can declare using allocator: Allocator as its last parameter to set the program-wide ambient allocator, but that form is planned and the compiler rejects it today. With no allocator in scope, alloc and free use the default heap.

An arena is a bump allocator over one backing buffer. Allocations carve forward from the buffer, individual frees are a no-op, and the whole arena is reset or destroyed at once. Arenas are the ergonomic answer to threading an allocator through code: nothing inside needs an individual free.

std.memory.arena provides arena_new(cap), arena_alloc(a, size), arena_reset(a), and arena_destroy(a). Arena also implements Allocator, so it can be passed with using and the alloc builtin dispatches to it. Pass the arena by pointer so the bump offset persists across calls.

arena_fill.dusk
@paradigm procedural
@import std.memory.arena
func fill(using a: Arena) -> int64 {
p: *int64 = alloc(8)
*p = 1
q: *int64 = alloc(8)
*q = 2
return a.used
}
func main() -> int32 {
a: *Arena = alloc(arena_new(64))
println(fill(*a))
arena_destroy(a)
free(a)
return 0
}

The two eight-byte allocations leave used at 16, which the program prints. arena_reset rolls the offset back to zero and keeps the buffer; arena_destroy frees the backing buffer. Exhausting an arena aborts rather than handing out memory past its end.

The Debug allocator from std.memory.allocator tracks each allocation. It reports leaks and double frees through two counters, debug_leaks() and debug_double_frees(), and poisons freed memory with 0xDD. Route a section of code through it with using, then read the counters:

debug_faults.dusk
@paradigm procedural
@import std.memory.allocator
func work(using a: Debug) -> void {
p: *int64 = alloc(8)
*p = 1
q: *int64 = alloc(8)
*q = 2
free(q)
free(q)
}
func main() -> int32 {
mut d := debug()
work(d)
println(debug_leaks())
println(debug_double_frees())
return 0
}

p is never freed, so it leaks; q is freed twice. The program prints 1 and 1. This is a diagnostic tool you opt into per scope. The always-on safety story is the generational heap below.

Since 0.2.1, the default heap is generational. A managed pointer *T is a fat pointer: the data pointer paired with a remembered generation. The heap writes a live generation in a header before each block, and free bumps it and parks the block on a size-matched free list. Every managed dereference compares the remembered generation against the header (in every build, not just debug) and faults on a use after free, a double free, or a stale pointer to a reused block, instead of corrupting memory. The generation rides inside the pointer value, so the check survives copies.

use_after_free.dusk
func main() -> int32 {
p: *int64 = alloc(7)
free(p)
println(*p)
return 0
}

This program compiles, and at run time it dies with a named fault rather than reading freed memory:

fatal: use of a freed or stale pointer

A generation of zero is the untracked sentinel: a using allocator hands back unchecked memory, which is how custom allocators keep working without a generation header.

The pointer layer splits in two:

  • Managed *T: a fat pointer carrying a generation, checked at every dereference. This is what alloc returns and what your own data structures should use.
  • Raw *raw T and *void: one-word pointers with no generation, used for strings, slice data, receivers, and collection buffers, with ptr_add for byte arithmetic. The raw layer is the honor system: no check runs.

The raw layer is also the foreign function boundary. A foreign "C" parameter or return type is a scalar, a *raw T, or a *void; a managed *T is rejected, since it is a fat value carrying a generation that C cannot read. A *raw T passes anywhere *void is expected, but not the reverse, and a managed pointer that round-trips through *void comes back untracked, so keep managed pointers on the managed layer. See the memory reference for the full rules.

Since 0.2.2, a managed pointer is single owner, tracked statically by the checker:

  • The binding created from alloc is the owner. Only the owner frees or moves.
  • A plain copy of an owner is a compile error: cannot copy an owning pointer; bind a ref alias or move it.
  • ref binds a non-owning alias.
  • move(x) transfers ownership and invalidates the source; a later use is rejected with use of a moved pointer.
  • Passing a pointer to a function borrows it. Freeing or moving a borrow is a compile error.
ownership.dusk
@paradigm procedural
struct Box {
n: int64,
}
func bump(b: *Box) -> void {
(*b).n = (*b).n + 1
}
func main() -> int32 {
owner: *Box = alloc(Box { n: 1 })
bump(owner) // borrows; main still owns
ref alias: *Box = owner // non-owning alias
println((*alias).n)
moved: *Box = move(owner) // owner is now dead
println((*moved).n)
free(moved) // the new owner frees
return 0
}

The static pass covers the clear cases, and since 0.2.3 a return that lets a frame local escape (a slice viewing a frame-local array, a closure capturing a frame local) is a compile error too. The 0.4.2 line made that thorough across every by-value carrier at a return, whether bare or buried in a tuple, a struct, an enum, a fixed array, or a generic field at any depth, flow-sensitively through a binding, an alias, or a match arm. Since 0.5.0 the check is interprocedural: it no longer stops at a call boundary, so a frame view laundered out through a call, a store, a channel send, a closure, or a pointer alias is caught too. Every function and lambda carries a summary of how a view can leave it, and an opaque callee the checker cannot see through defaults to rejecting a polluted argument. The messages are unchanged, a slice into a local array escapes its frame; put the backing on the heap among them, and this call may return a view of argument 1, which views the current frame names the new interprocedural case. The raw layer is exempt from ownership tracking, and the runtime generation check backstops whatever the static pass cannot see, such as a pointer laundered through an aggregate a call returns.

Ownership also crosses threads: chan_send(c, move(p)) hands a heap record to a receiving thread with the sender’s name dead at compile time. See Concurrency.

Some values want to outlive the frame that made them without a clean owner to free them: a closure that has to survive its frame, a slice you want to detach from a local array, a small graph with shared lifetimes. For those, opt a single value onto the collected heap with the collector<T> mint. It is per value and never ambient, so nothing else about your program changes.

The mint is collector<T>(e), and its kind follows the element type:

  • Plain, a scalar, a managed pointer, a string, or a struct of those. It derefs like an ordinary managed pointer, *c.
  • Closure, collector<F>(lambda ...). The environment lands on the collected heap, so the closure keeps working after its frame returns.
  • Slice, collector<U[]>(e). The backing is deep copied one level onto the collected heap, so a slice into a local array becomes a legal source.

You never free, move, or ref a collected value; the collector reclaims it, and passing or storing one copies it by value. Collection is automatic and amortized, and you can force one with gc_collect() from std.memory.collector, then read gc_live_blocks, gc_live_bytes, and gc_collections.

collector_intro.dusk
@paradigm procedural
@import std.memory.collector
func main() -> int32 {
// A collected closure: its environment lives on the collected heap, so it
// outlives the frame that built it.
n := 41
greet := collector<() -> int64>(lambda () -> int64 {
return n + 1
})
println(greet()) // 42
// A block held on the stack is a root, so it survives a forced collection.
keep := collector<int64>(99)
gc_collect()
println(*keep) // 99
println(gc_collections()) // 1
return 0
}

One rule to hold onto: the collector runs on the main thread only. A collector<T> cannot cross a channel, a spawn, or a submit, and it cannot be boxed into an interface. A Future<collector<T>>, an async func returning a collector value, and a same-thread Vector<collector<T>> are all fine. The collector reference has the full rules and the exact rejects.

  • Memory reference: the precise rules for pointers, allocators, and ownership.
  • The collected heap: the collector<T> wrapper, its three kinds, and the main-thread rule.
  • std.memory: the shipped allocators and their APIs.
  • Errors: the other half of writing robust dusk code.