The collected heap
Dusk ships a second managed heap beside the generational one: a conservative, mark and sweep collected heap. You opt into it per value through the collector<T> wrapper type and its minting expression, collector<T>(e). Nothing lands on the collected heap by default. A value is collected only because a program wrote collector<T>(e) naming it, so the ambient allocator, alloc, and the generational dereference check are untouched by any program that never mentions collector.
This page is the normative reference for the collected heap. For the manual toolkit it sits beside, see memory and the memory guide. For the control and gauge functions, see std.memory.
The collector wrapper and the mint
Section titled “The collector wrapper and the mint”collector<T> is a wrapper type. collector<T>(e) is its mint: one allocation on the collected heap holding the value of e. A collected block carries the same sixteen byte header a generational block carries, an eight byte size word followed by an eight byte generation word ahead of the payload, so the generational dereference check that faults on a stale generation reads a collected block’s header exactly as it reads a generational one’s.
The two heaps differ only in how a block retires. An explicit free retires a generational block by bumping its generation and parking it. A collected block retires only through a collection, which scans the roots, marks what a root can still reach, and bumps the generation of everything left unmarked.
n := collector<int64>(10) // one collected block holding 10println(*n) // deref like a managed pointer, prints 10Three kinds of collected value
Section titled “Three kinds of collected value”collector<T> mints one of three kinds, chosen by the element type T.
- Plain.
Tis a scalar, a managed*T, a string, or a struct built only of those. The block holds the value the way a managed pointer’s block holds its pointee.*cor a field read oncderefs through it exactly as an ordinary managed pointer would, the same generation check firing on every dereference. - Closure.
collector<F>(lambda ...), whereFis a function type. The lambda’s environment is built on the collected heap instead of the frame, so the closure keeps working after the frame that wrote it has returned. - Slice.
collector<U[]>(e). The backing is deep copied onto the collected heap, one level, so a slice into a frame local array becomes a legal source: the copy severs the view from the frame that built it. This kind is legal only whenUis immortal safe. A slice of slices, a slice of closures, or a slice of interfaces is rejected, since the one level copy immortalizes the outer buffer and nothing an element of it points at in turn.
This complete program mints the plain kind, reads and stores through the deref, copies it, and returns a collected block that outlives the frame that made it:
@paradigm procedural
struct Point { x: int64, y: int64,}
// A collector minted in one frame outlives it. The block lives on the// collected heap, so the caller reads it after this frame is gone.func make() -> collector<Point> { return collector<Point>(Point { x: 3, y: 4 })}
func main() -> int32 { n := collector<int64>(10) // plain scalar mint println(*n)
c := collector<Point>(Point { x: 1, y: 2 }) // plain struct mint println((*c).x) (*c).x = 30 // field store through the deref println((*c).x)
d := c // a copy views the same block println((*d).x)
p := make() // the block survives the return println((*p).x) return 0}Minting is escape neutral
Section titled “Minting is escape neutral”A collected value is not a frame view. Its block sits on a heap that outlives every frame, so a collector<T> value returns cleanly, bare or embedded in a tuple, struct, or array, exactly like any other clean value. The escape check that rejects a frame view leaving a function does not fire on a collected result.
The mint itself, though, is an outliving sink, the same kind of sink a return is. An argument to collector<T>(e) that carries a frame view, a closure over a frame local, or a managed pointer whose pointee a store has already tainted, is rejected at the mint, since collecting it would copy that view onto a heap the view’s own backing does not outlive. An opaque call whose result is minted reuses the return escape wording, this call may return a view of argument N, which views the current frame, even though the value is being collected rather than returned.
The one exception is a slice source. collector<U[]>(e) deep copies the backing onto the collected heap, so a slice into a frame local array is a legal argument there, the copy severing the view from the frame that built it.
The closure kind carries the matching capture rule. Every capture in a collector<F>(lambda ...) must be immortal safe: a scalar, a managed pointer, a string, a nested collector<..>, or an aggregate of those. A slice, a closure, or an interface capture fails outright. A managed pointer capture whose pointee already stores a frame view is rejected too, since the pointer is immortal safe but the view behind it is not:
cannot collect a closure that captures '<name>': it may view a frame; collect '<name>' first or capture heap owned data
A slice source is checked the same way one level down. A managed pointer buried in the copied elements that itself carries a tainted pointee is rejected:
a collected slice element holds a pointer to an object that stores a view of the current frame; the collected block outlives the frame, so heap own the pointee or collect it first
No free, no move, no ref
Section titled “No free, no move, no ref”A collected value is never freed, moved, or borrowed with ref. All three are compile errors:
a collected value is not freed; the collector reclaims ita collected value is not owned; copy it directlya collected value is not borrowed with ref; copy it directly
Passing or storing a collected value copies it by value, the same rule an ordinary managed pointer or scalar follows. There is no explicit release to hand off, so there is no ownership to transfer. Reclamation happens only when a collection finds no root reaching the block.
Thread confinement
Section titled “Thread confinement”The collector is single mutator. It runs only on the one thread it anchors to the first time a collected block is minted or a collection is forced, in practice the thread that runs main, since the collector’s root scan walks that thread’s stack and no other. A collected value is only sound to hold on that same thread, and the checker enforces the confinement at compile time rather than leaving it to an off thread runtime abort.
Rejected outright:
- A
Channel<collector<T>>, since a channel’s ring buffer sits outside every root the collector scans:a collected value stays on the main thread; it cannot cross through a channel to another thread. - A
spawnorsubmitcapture of a collector value, since a worker thread’s private environment is the same kind of unrooted store:<fn> cannot capture '<name>': a collected value stays on the main thread; it cannot cross to another thread. - Boxing a bare collector value into an interface, since the boxed payload would need to travel wherever the interface value travels:
a collected value cannot be boxed into an interface; it stays on the main thread. - A managed pointer whose pointee reaches a collector value across any of those same crossings, so the ban does not stop at a bare collector argument.
Allowed:
- A
Future<collector<T>>and an async func that returns a collector value. A future completes on the loop thread, andasync_runis that same anchor thread’s own bridge into the loop, so a collector value crossing a suspension never leaves the thread it is confined to. A task frame is a registered root region, so a collector minted before anawaitand read after it survives a forced collection on either side of the suspension. - A same thread container,
Vector<collector<T>>among them, since the container’s backing buffer is itself a generational block the collector’s registry already scans as a root.
The confinement checks a value that is directly a collector<T>, not a struct that merely carries one as a field. Boxing a struct with a collected field into an interface is allowed: the interface value is itself barred from crossing a spawn, a submit, or a channel, so the collected field behind it can never reach another thread, and confinement holds transitively without a separate check on the field. The direct case, boxing a bare collector<T> value into an interface, stays rejected. That reject is a deferred boxing path rather than a confinement rule: a bare collected payload has no stable home in an interface’s fat pointer yet, and lifting the limit is later work.
An allocation or collection asked for off the anchor thread does not corrupt silently. It aborts by name:
fatal: the collector runs on the main thread onlycollector is a contextual reserved word
Section titled “collector is a contextual reserved word”collector< opening a type or an expression position starts a collector<T> type or a collector<T>(e) mint. A named binding called collector compared against something else still parses as a plain identifier. The parser looks far enough ahead to tell a mint from a comparison before it commits to either reading, so naming a variable collector stays legal everywhere outside that one ambiguous shape.
collector := 3 // an ordinary binding named collectorn := 5if collector < n { // still an identifier comparison, not a mint println(1)}Widening is one way
Section titled “Widening is one way”A collector<F> value passes anywhere a plain F is expected, and a collector<U[]> value passes anywhere a plain U[] is expected, since a collected value’s representation is exactly the value it wraps and no conversion runs. The reverse direction does not hold: a plain F or U[] never becomes a collector<F> or collector<U[]> implicitly.
A bare lambda literal handed where a collector<F> parameter is expected is accepted only at a direct top level call, where the compiler rewrites it into the equivalent mint. At a method argument or through an indirect call the same bare lambda is rejected, with the explicit mint named as the fix:
a bare lambda cannot become a closure collector at a method argument; write the mint explicitly: collector<F>(lambda ...)
Only the explicit mint runs the escape and capture checks that make a wrapped value immortal safe, so writing it out is what keeps an indirect call sound.
std.memory.collector
Section titled “std.memory.collector”std.memory.collector wraps the collector’s control and gauges. It does not offer a Collector type you pass with using. You reach the collected heap only by minting a collector<T> value, and this module is the collection trigger plus four read only counters over that heap.
| Function | Description |
|---|---|
gc_collect() -> void | Force one full mark and sweep now. Main thread only. |
gc_live_blocks() -> int64 | How many collected blocks are live. |
gc_live_bytes() -> int64 | Total live collected payload bytes, an upper bound. |
gc_collections() -> int64 | How many collections have run since start, monotonic. |
Collection is amortized: it runs automatically once the byte debt since the last collection crosses a threshold that doubles with the live set, at whichever mint trips it. A program forces one directly through gc_collect. The scan is conservative, so gc_live_bytes reports an upper bound: a stray stack word that merely looks like a pointer keeps a block alive one collection longer than it needed, never the reverse.
@paradigm procedural
@import std.memory.collector
func main() -> int32 { // A block held on the stack for the whole run: a root the scan keeps. keep := collector<int64>(99)
// Eight more blocks, each read so its value is used, none held past the loop. mut sum: int64 = 0 mut i: int64 = 0 while i < 8 { c := collector<int64>(i) sum = sum + *c i = i + 1 }
before := gc_live_blocks() bytes := gc_live_bytes() g0 := gc_collections()
gc_collect()
g1 := gc_collections()
println(before) // nine blocks live before the collection println(bytes) // their total payload bytes println(g1 - g0) // exactly one collection ran println(sum) // 0 + 1 + ... + 7, each junk block was read println(*keep) // the held block survived the sweep, value intact return 0}Why no Collector allocator
Section titled “Why no Collector allocator”There is no Collector type implementing Allocator. The Allocator interface hands back an untyped *void, which would erase the collector<T> tracking the checker relies on to keep a collected reference confined to its anchor thread. A collected block routed through the allocator seam could then cross a channel or a spawn boundary as a bare pointer with no diagnostic and be swept while a worker thread still held it. Closing that hole needs the checker to track whether a value is collected through the allocator seam itself, and is left for later work. The typed collector<T> mint stays the one checked surface for collected memory.
A function parameter declared with an undeclared type name is itself rejected at check, so a phantom Collector parameter written to probe for a collector allocator type is a compile error rather than a silently accepted unknown:
unknown type '<name>'; no type of that name is declared or imported
Cost and collection
Section titled “Cost and collection”A mint is one allocation on the collected heap. Collection is mark and sweep and non moving: a collected block never relocates, so a raw address into one stays valid across a collection for as long as the block itself stays live. A precise, moving collector is not this one.
Dusk’s build passes no optimization flag to clang, and the collector depends on that. Its root scan brackets the anchor thread’s stack under the frame layout the unoptimized build guarantees, where a local variable keeps a stack home a register allocator could otherwise remove. Adding an optimization flag is a collector soundness change, not a speed change, and must land with a precise root map alongside it.
Where to go next
Section titled “Where to go next”- Memory reference: the manual toolkit the collected heap sits beside.
- std.memory:
gc_collectand the gauge functions in the standard library. - Memory guide: when to reach for the collector in day to day code.