Arena
An arena hands out memory by moving a cursor forward, and frees it by moving the cursor back. You never free one object at a time. You decide when a whole group of objects dies, and you release the group in one operation.
An arena replaces the question “who frees this object?” with a question you answer once for thousands of objects: “when does this group die?”
Why lifetimes instead of owners
Section titled “Why lifetimes instead of owners”With malloc and free, every allocation creates an obligation. Some code must free the block, exactly once, at the right moment. No compiler checks this. Most memory bugs are a failure of that bookkeeping: a leak, a double free, or a pointer used after the free.
An arena removes the obligation. You put allocations that share a lifetime into the same arena. When the lifetime ends, you release the arena, and every object inside it disappears together.
So you stop tracking ownership for each object, and start grouping objects by how long they must live. In a real program these groups are few and usually obvious: data that lives for the whole run, data that lives for one frame, and data that lives for one function call.
How an arena hands out memory
Section titled “How an arena hands out memory”An arena owns a large region of memory and a cursor. Push aligns the cursor, returns its address, then advances it past the new allocation.
region base cursor reserve limit | | | v v v +--------+----------+--------+------------------------------------+ | header | mesh | string | unused | +--------+----------+--------+------------------------------------+ <-------- allocated -------->The allocation path is four steps: align the cursor, check that the request fits, advance, return the old cursor. There is no free list, no size class, no search, and no per-allocation header. The cost does not grow with the number of allocations already made.
Reserve is a ceiling, not a cost
Section titled “Reserve is a ceiling, not a cost”A new arena reserves address space before it uses any memory. Reserving marks a range of addresses as belonging to the arena, and consumes no physical memory. The arena then commits small chunks of that range as the cursor advances. Committing is the step that costs real memory.
| Constant | Default | What it means |
|---|---|---|
ARENA_DEFAULT_RESERVE |
64 MB | Address space claimed per block. Costs no physical memory. |
ARENA_DEFAULT_COMMIT |
64 KB | Physical memory added per growth step. This is the real cost. |
A fresh arena costs one commit chunk, whatever its reserve. So do not size an arena tightly. Choose a reserve you do not expect to exceed, and let the commit follow actual use.
Exceeding the reserve is not a failure. The arena reserves a new block and chains it to the previous one. Two guarantees hold across that boundary:
- Pointers already returned never move.
- A single allocation is never split across two blocks. If one allocation is larger than
reserve_size, the new block is enlarged to hold it whole.
Pointers stay valid
Section titled “Pointers stay valid”When Push returns a pointer, that pointer stays valid until you rewind past it or release the arena. Later pushes never move earlier data.
An array that grows by reallocating moves its elements to a larger block, so it invalidates every pointer into itself. An arena never does. Three things follow:
- You can store direct pointers between objects. Trees, linked lists and graphs need no handles, no indices and no indirection table.
- You can hand a
Span<T>to another subsystem and keep pushing into the same arena. - You can parse input into a structure whose nodes point into the same arena as the text they describe.
The arena handle is stable too
Section titled “The arena handle is stable too”An arena keeps its own bookkeeping in the first bytes of the region it owns. The Arena* returned by Create therefore points at the base of that region and never changes for the life of the arena. You can store it in a long-lived structure and pass it deep into a call tree with no risk of it dangling.
An arena cannot be copied and cannot be moved: a move would relocate the bookkeeping away from its own region base and break that stable identity. So you always pass Arena& or Arena*, never an arena by value. The compiler enforces this, so the mistake is a build error rather than a dangling pointer at run time.
Freeing is rewinding
Section titled “Freeing is rewinding”An arena gives memory back by moving the cursor backwards, at four levels of granularity:
| You want to | Use | Effect |
|---|---|---|
| Undo the allocations made since a known point | GetPosition then PopTo |
Rewinds to the saved position |
| Undo a known number of bytes | Pop |
Rewinds by an amount |
| Reuse the arena from empty | Clear |
Releases chained blocks, keeps the first block |
| Give everything back to the OS | Release |
Releases every block; the Arena* dangles |
None of these operations walk the objects in the arena. Push<T> requires a trivially-constructible type and calls no constructor, so there is nothing to destroy. A rewind stays a cursor assignment whether the arena holds ten objects or ten million.
Prefer a scope guard to a manual rewind
Section titled “Prefer a scope guard to a manual rewind”A manual GetPosition / PopTo pair is easy to write and easy to skip on an early return. ArenaScope records the position when it is created and rewinds in its destructor.
void DrawFrame(Arena& frame_arena, const Scene& scene){ ArenaScope scope(frame_arena); // marks the current position
Span<DrawCommand> commands = BuildCommands(frame_arena, scene); SubmitCommands(commands);
} // scope rewinds frame_arena here, on every exit pathOrganise a program around three lifetimes
Section titled “Organise a program around three lifetimes”Most programs need only three kinds of arena.
| Lifetime | Lives for | Typical contents | How it is reclaimed |
|---|---|---|---|
| Permanent | The whole run | Loaded assets, the window, subsystem state | Release at shutdown |
| Task or frame | One iteration | Draw commands, per-frame lists, one request | Clear at the end of each iteration |
| Scratch | One function call | Intermediate results, temporary buffers | Automatically, by ScratchScope |
With a per-frame arena you build a frame’s worth of data with plain pushes, submit it, then reset the whole arena with one call. The cost of reclaiming a frame does not depend on how much the frame allocated.
Scratch arenas
Section titled “Scratch arenas”A function often needs memory for its own intermediate work, which the caller must never see. The alternative is to ask the caller for a second arena to hold rubbish.
Every thread has ARENA_SCRATCH_COUNT scratch arenas, created on first use. ScratchScope takes one and rewinds it when the scope closes.
usize CountWords(String8 text){ ScratchScope scratch; // no conflict: nothing is returned Arena& arena = scratch.GetArena();
Span<Range> ranges = arena.Push<Range>(text.size); // ... use ranges ...
return count;} // scratch memory is reclaimed hereName the arenas the scratch must avoid
Section titled “Name the arenas the scratch must avoid”A function that returns data usually takes the destination arena as a parameter and also wants scratch memory. If the scratch arena it picks happens to be that same destination arena, the rewind at scope exit destroys the result it just built.
You prevent this by naming the arenas the scratch must differ from. ScratchScope then hands back a different one.
Span<Token> Tokenize(Arena& out, String8 source){ ScratchScope scratch(out); // never selects `out` Arena& work = scratch.GetArena();
Span<Range> spans = work.Push<Range>(source.size); // thrown away at exit // ... scan into spans ...
Span<Token> tokens = out.Push<Token>(span_count); // survives; the caller owns it // ... fill tokens ... return tokens;}Pass up to two conflicting arenas. That covers the usual shape: one output arena, and one arena the caller already gave you.
Nesting on one scratch arena is safe. A rewind restores a saved position, so an inner scope cannot damage an outer one. You do not need one scratch arena per call level.
Arenas and threads
Section titled “Arenas and threads”An arena is not internally synchronised. Bumping the cursor is a plain increment, not an atomic operation. Making it atomic would add an atomic instruction to the hottest path in the framework, paid on every allocation whether or not the arena is ever shared.
Pick one of these three:
- Use the arena from a single thread. This is the common case.
- Synchronise access yourself, outside the arena.
- Give each thread its own arena. This is the preferred answer for parallel work, because it removes the contention instead of managing it.
Scratch arenas are already thread-local, so ScratchScope is safe to use from any thread with no extra work.
When an arena is the wrong tool
Section titled “When an arena is the wrong tool”The arena is Basalt’s default allocator, not a rule. Reach for something else when the shape of the problem does not match:
| Situation | Better fit |
|---|---|
| Objects die individually, at unpredictable times | Not an arena — an arena only grows until a rewind |
| One buffer that must grow, with an unknown final size | VirtualArray<T> |
| A sequence of unknown length, built once then read | ChunkList<T> |
| A fixed number of elements in a buffer you already have | Array<T> |
| A type with a destructor, or one that owns an OS resource | Not an arena — no destructor runs on rewind |
API summary
Section titled “API summary”Parameters, failure behaviour and invariants are documented in basalt/core/Arena.h.
Creation and release
Section titled “Creation and release”| Signature | Purpose |
|---|---|
Arena::Create(ArenaParams) |
Reserves a region and returns the handle, or MemoryError::OutOfMemory. |
Release() |
Returns every block to the OS. The Arena* dangles afterwards. |
Allocation
Section titled “Allocation”| Signature | Purpose |
|---|---|
Push<T>(count) |
Allocates count zeroed elements, aligned to alignof(T). Returns Span<T>. |
Push(size, align) |
Allocates raw, uninitialised bytes. Returns a nullable pointer. |
Position and reuse
Section titled “Position and reuse”| Signature | Purpose |
|---|---|
GetPosition() |
The current absolute position. Increases until a rewind. |
IsEmpty() |
True when the arena holds no allocations. |
PopTo(position) |
Rewinds to a position from an earlier GetPosition. |
Pop(amount) |
Rewinds by a number of bytes. |
Clear() |
Resets to empty and keeps the first block reserved. |
Scratch
Section titled “Scratch”| Signature | Purpose |
|---|---|
ScratchScope() |
Takes a scratch arena for a frame that returns nothing. |
ScratchScope(conflict) |
Takes a scratch arena that is not conflict. |
ScratchScope(first, second) |
Takes a scratch arena that is neither one. |
Arena::GetScratch(index) |
The raw slot accessor. Skips conflict avoidance; prefer ScratchScope. |
Configuration
Section titled “Configuration”Field of ArenaParams |
Default | Purpose |
|---|---|---|
reserve_size |
ARENA_DEFAULT_RESERVE |
Address space per block. |
commit_size |
ARENA_DEFAULT_COMMIT |
Physical memory added per growth step. |
flags |
ArenaFlags::None |
NoChain asserts on overflow instead of chaining. |
name |
empty | Debug name. Not retained yet. |