Skip to content

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?”

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.

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.

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.

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.

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.

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.

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 path

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.

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 here

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.

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.

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

Parameters, failure behaviour and invariants are documented in basalt/core/Arena.h.

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.
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.
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.
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.
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.