20 KiB
FixedArena
A bump allocator over one block that is allocated up front, fully committed, and used as-is.
fixed_arena.jai, no platform layer.
- Model
- Choosing between FixedArena and Arena
- Quick start
- Constants and types
- Lifecycle
- Allocation
- Resizing
- Resetting
- Scoped helpers
- Allocator integration
- Thread safety
- Correctness notes and limitations
- Testing
Model
A FixedArena takes one block of memory, reserves its first bytes for a header, and hands out
the rest by advancing a cursor. It never grows. When the block is full, allocation returns
null — that is the normal, expected way for a fixed arena to fail, and callers are meant to
check for it.
Three regions describe an arena, separated by two boundaries:
memory.data fixed_arena_start() memory.data + cursor memory.data + memory.count
| | | |
+---- header --------+---- live allocations -----+------- unallocated --------+
| FIXED_ARENA_ | handed out | never handed out |
| HEADER_BYTES | | |
| Boundary | Meaning |
|---|---|
memory.data |
Start of the block. Null exactly when the arena is uninitialized. |
fixed_arena_start() |
First allocatable byte. Always memory.data + FIXED_ARENA_HEADER_BYTES. |
memory.data + cursor |
Bump position. Everything below it has been handed out. |
memory.data + memory.count |
End of the block. Nothing is ever handed out at or past this. |
The header bytes are reserved whether or not the arena is bootstrapped. For a bootstrapped
arena that is where the FixedArena struct actually lives; for one set up over a caller's
buffer those bytes go unused. Paying them either way buys a single address layout, so
fixed_arena_start, IS_THIS_YOURS and the reset path need no special case. arena.jai makes
the same trade for the same reason.
Individual allocations cannot be freed. You reclaim in bulk with fixed_arena_reset, or
scope it with auto_reset.
Alignment padding comes out of the block. Each allocation begins at the next
FIXED_ARENA_DEFAULT_ALIGNMENT-aligned address, and the bytes skipped to reach it are consumed
like any others. So the room actually left is not memory.count - cursor — that overstates it
by up to FIXED_ARENA_DEFAULT_ALIGNMENT - 1 bytes. Ask fixed_arena_available instead.
Allocations are not zeroed. Zeroing the block on creation would touch every page up front, and it would be a guarantee the first reset silently withdrew, since a reset does not re-zero. Zero what you need yourself.
Choosing between FixedArena and Arena
FixedArena |
Arena |
|
|---|---|---|
| Backing memory | One block from an allocator, or a buffer you own | A large virtual reservation |
| Growth | None; full means null |
Commits pages on demand up to the reserve |
| Platform calls | None | VirtualAlloc / mmap |
| Alignment | Fixed at FIXED_ARENA_DEFAULT_ALIGNMENT (16) |
Per-arena, settable, default 8 |
| Reclaim | fixed_arena_reset, auto_reset |
Reset, release, and the secure-erase variants |
| Struct size | 40 bytes | 32 bytes in release, larger under ARENA_DEBUG |
Reach for FixedArena when you know the budget up front, when you want the arena to sit inside
memory you already have (a static buffer, a slab from somewhere else), or when you cannot make
platform virtual-memory calls. Reach for Arena when the ceiling is unknown and you would
rather reserve big and commit lazily.
Quick start
// Bootstrapped: the arena allocates its own block and lives inside it.
arena := bootstrap_fixed_arena(64 * 1024);
if !arena return; // too small, or the allocation failed
defer delete_fixed_arena(arena); // `arena` dangles after this -- see below
{
push_arena(arena); // context.allocator is the arena for this scope
auto_reset(arena); // and the cursor rewinds when the scope ends
data := NewArray(100, float32);
if !data.data return; // a full arena returns null; check it
}
// Over memory you already own. The struct lives outside the block.
backing: [8192] u8;
arena: FixedArena;
if !fixed_arena_init(*arena, backing) return;
p := fixed_arena_alloc(*arena, 256);
if !p return;
Constants and types
Constants
| Name | Value | Meaning |
|---|---|---|
FIXED_ARENA_DEFAULT_ALIGNMENT |
16 |
Every allocation is aligned to this, as an address. |
FIXED_ARENA_HEADER_BYTES |
size_of(FixedArena) |
Reserved at the base of every block. |
FIXED_ARENA_MINIMUM_SIZE |
header + alignment | Smallest block either constructor accepts. |
Note that FIXED_ARENA_DEFAULT_ALIGNMENT is 16 while ARENA_DEFAULT_ALIGNMENT in arena.jai
is 8. The difference is deliberate but it matters: memory handed out by an Arena is only
8-aligned, so a FixedArena placed in it starts from a block whose base is not 16-aligned.
The allocator aligns the address it returns rather than the offset into the block, so this
works — but it is why the distinction exists.
FixedArena
FixedArena :: struct {
memory: []u8;
cursor: s64;
allocator: Allocator;
}
| Field | Meaning |
|---|---|
memory |
The block. data is null exactly when the arena is uninitialized. |
cursor |
Offset of the bump position from memory.data. Starts at FIXED_ARENA_HEADER_BYTES. |
allocator |
The allocator that owns memory. Read by delete_fixed_arena; null when the caller owns the block. |
Do not write cursor directly. In particular cursor = 0 is not a reset: for a bootstrapped
arena it lets the next allocation overwrite the struct describing the block being allocated
from. Use fixed_arena_reset.
Lifecycle
bootstrap_fixed_arena
bootstrap_fixed_arena :: (size: s64) -> *FixedArena
Allocates a size-byte block from context.allocator and puts the arena's own header at the
base of it, so nothing else has to own the struct.
size covers the header as well as the memory you get to hand out, so the usable capacity is
at most size - FIXED_ARENA_HEADER_BYTES, and less once alignment padding between allocations is
accounted for. fixed_arena_available reports the real figure at any moment.
Returns null if size is below FIXED_ARENA_MINIMUM_SIZE, or if the allocation fails. Both
log. There is no partial success.
The pointer dangles after delete_fixed_arena. The struct is inside the block being
released, so it cannot be nulled for you. Do not read any field afterwards — is_valid
included, since it dereferences and will fault rather than report anything useful. Null your
own copy, and make sure no other copy outlives the delete.
fixed_arena_init
fixed_arena_init :: (arena: *FixedArena, memory: []u8,
allocator := Allocator.{}) -> bool
Sets an arena up over memory the caller already owns — a static array, a slab from another
arena — with the FixedArena struct outside the block rather than at its base.
allocator is the allocator that owns memory. Leave it at its default when the caller owns
the memory and nothing should ever free it: delete_fixed_arena then does nothing, which is
the correct answer for a static buffer.
Returns false, with an error logged, for a null arena, a null block, or a block below
FIXED_ARENA_MINIMUM_SIZE. A failed init leaves the arena invalid rather than half-set-up.
Unlike the bootstrapped case, the struct survives delete_fixed_arena and can be initialized
again and reused.
delete_fixed_arena
delete_fixed_arena :: (arena: *FixedArena)
Releases the block through arena.allocator — the allocator that made it, which is not
necessarily context.allocator at the point of the call. Tolerates null.
Does nothing when arena.allocator.proc is null, which is the caller-owned-buffer case from
fixed_arena_init.
Do not call this inside a push_arena(arena) scope. It would work, but it reads as though the
arena frees itself, and what actually happens depends on which allocator is installed.
is_valid
is_valid :: (arena: *FixedArena) -> bool
True when the arena has a block large enough to hold its header and a cursor inside that block.
False for null. Every allocating entry point checks this first, so you do not need to.
Not usable on a bootstrapped arena after delete_fixed_arena: it dereferences the pointer.
Allocation
fixed_arena_alloc
fixed_arena_alloc :: (arena: *FixedArena, byte_count: s64) -> *void
Bump-allocates byte_count bytes, aligned to FIXED_ARENA_DEFAULT_ALIGNMENT. Returns null
rather than a pointer past the end of the block.
| Condition | Result |
|---|---|
| Fits | Aligned pointer; cursor advances to the end of the allocation. |
| Does not fit | null, logged. The cursor does not move, so a retry with a smaller size is not penalised. |
byte_count is 0 |
A valid pointer. No memory is handed out; the cursor moves only by alignment padding. |
byte_count is negative |
null, logged. |
byte_count near S64_MAX |
null, logged. The capacity test is written so it cannot overflow. |
arena is null or invalid |
null, logged. |
A zero-byte request always succeeds, including against an exactly full arena and regardless of
where the block starts, because nothing is handed out and so there is nothing to align. It is
the one request fixed_arena_available does not describe. Worth knowing if you are checking
alloc(0) for null as a proxy for exhaustion — it is not one, in either direction.
fixed_arena_available
fixed_arena_available :: (arena: *FixedArena) -> s64
The largest byte_count the next fixed_arena_alloc can satisfy.
This is not memory.count - cursor. The next allocation starts at the next aligned address,
and the padding to reach it comes out of the block, so the raw gap overstates what will fit by
up to FIXED_ARENA_DEFAULT_ALIGNMENT - 1 bytes. Asking for the raw gap is the mistake this
function exists to prevent; it is what the test suite got wrong at three separate sites on its
first run.
Returns 0 for a null or invalid arena and for a block with no room left, so it is always safe to compare against without a null check first.
A zero-byte request is exempt: it succeeds even when this reports 0.
// Fill the rest of the block exactly.
remaining := fixed_arena_available(arena);
p := fixed_arena_alloc(arena, remaining); // succeeds; cursor now equals memory.count
q := fixed_arena_alloc(arena, 1); // null
fixed_arena_start
fixed_arena_start :: (arena: *FixedArena) -> *u8
The first allocatable byte: memory.data + FIXED_ARENA_HEADER_BYTES. This is where
fixed_arena_reset rewinds to, and the lower bound IS_THIS_YOURS tests against.
Resizing
fixed_arena_resize
fixed_arena_resize :: (arena: *FixedArena, old_memory: *void,
old_size: s64, requested_size: s64) -> *void
Resizes a block, extending or shrinking it where it sits when it is the most recent
allocation — that is, when old_memory + old_size is exactly the current cursor. Anything else
allocates a fresh block and copies min(old_size, requested_size) bytes.
The in-place case matters more here than it would for a growable arena. Array growth is the
dominant caller of RESIZE, and without it, growing an array to N elements consumes O(N²)
bytes of a block that by definition cannot grow.
Shrinking the most recent allocation reclaims the difference.
A null old_memory is legal — growing an array that has not allocated yet — and is treated
as a plain allocation. Returns null on a negative size, an invalid arena, or a request that
does not fit.
Resetting
fixed_arena_reset
fixed_arena_reset :: (arena: *FixedArena)
Hands the whole block back for reuse by rewinding the cursor to fixed_arena_start — not to
offset zero. The header bytes stay reserved, which is what stops the next allocation from
overwriting a bootstrapped arena's own struct.
Previously allocated memory is not overwritten, only forgotten. If it held anything sensitive,
wipe it yourself before resetting; unlike arena.jai, there is no
fixed_arena_reset_overwriting_memory.
Does nothing on a null or invalid arena.
Scoped helpers
push_arena
push_arena :: (arena: *FixedArena) #expand
Installs the arena as context.allocator for the enclosing scope and restores the previous
allocator on exit. Everything that allocates through the context — New, NewArray,
array_add, String_Builder — comes out of the arena.
It restores the allocator, not the cursor. A scope that allocates without an auto_reset
alongside consumes from the block for the life of the program.
auto_reset
auto_reset :: (arena: *FixedArena) #expand
Records the cursor and restores it when the enclosing scope exits. Nests correctly. The usual pairing is both macros together:
{
push_arena(arena);
auto_reset(arena);
...
}
The mark holds the allocation position and nothing else, matching how Arena_State is scoped
in arena.jai. Alignment and the backing allocator are configuration rather than position, and
restoring them would silently undo unrelated changes a caller had made.
Allocator integration
get_allocator
get_allocator :: (arena: *FixedArena) -> Allocator
Wraps the arena as an Allocator for passing to ,, call sites, storing in a struct, or
installing manually.
fixed_arena_allocator_proc
fixed_arena_allocator_proc :: (mode: Allocator_Mode, requested_size: s64, old_size: s64,
old_memory: *void, allocator_data: *void) -> *void
The Allocator_Proc. Every mode is handled explicitly, under #complete, so a mode added
upstream is a compile error here rather than a silent null.
| Mode | Behaviour |
|---|---|
.ALLOCATE |
fixed_arena_alloc. |
.RESIZE |
fixed_arena_resize. |
.FREE |
null. A bump allocator cannot release an individual allocation. |
.IS_THIS_YOURS |
True only for memory that is both allocated by this arena and currently live — between fixed_arena_start and the cursor. The header and the unallocated tail are not yours, and a reset un-owns everything it handed out. |
.CAPS |
IS_THIS_YOURS | HINT_I_AM_A_FAST_BUMP_ALLOCATOR, and fills in the name. |
.STARTUP, .SHUTDOWN |
null. |
.THREAD_START, .THREAD_STOP |
null. No per-thread state is built. |
.CREATE_HEAP, .DESTROY_HEAP |
null. |
A null allocator_data is survivable in every mode. get_capabilities in module.jai passes
null outright, because capabilities are a property of the proc rather than of any one heap.
Memory debugger
Memory_Debugger deliberately ignores this allocator, the same way it ignores the pool and
temporary allocators. Allocations from a FixedArena are not tracked, and an overrun here
would not be caught for you — the only allocation the debugger sees is the block
bootstrap_fixed_arena takes from context.allocator.
That is why every bound this allocator cares about is checked inside fixed_arena.jai rather
than left to tooling, and why tests/fixed_arena_tests.jai is the only regression net there is.
Thread safety
None. Nothing in this file is thread-safe.
Allocation is an unsynchronized read-modify-write of cursor, so two threads allocating from
one FixedArena will hand out overlapping memory. .CAPS correctly omits
Allocator_Caps.MULTIPLE_THREADS, and THREAD_START / THREAD_STOP are accepted as no-ops
rather than being used to build per-thread state.
The dangerous part is that nothing stops you. push_arena installs the arena as
context.allocator, and a context is exactly the sort of thing that gets copied to a worker.
Safe patterns:
- One arena per thread. The usual answer.
- Hand off ownership. One thread fills an arena and passes it to another, provided there is a real happens-before edge and only one thread touches it at a time.
- Read-only sharing. Multiple threads reading memory already allocated, as long as none of them allocates or resets.
Unsafe, including cases that look benign:
- Two threads calling
fixed_arena_allocon one arena, however briefly. - One thread allocating while another calls
fixed_arena_resetor exits anauto_resetscope — the rewind can hand out live memory again. - Sharing a context that carries a fixed arena allocator.
A lock around the allocating calls alone is not enough. It has to cover everything that touches
the cursor: fixed_arena_alloc, fixed_arena_resize, fixed_arena_reset, auto_reset's scope
exit, and the allocator proc.
Correctness notes and limitations
- A full arena returns
null, it does not abort. This is the normal failure mode and callers are expected to handle it. Nothing in the file asserts on exhaustion. - Allocations are not zeroed, and a reset does not zero either.
- There is no secure-erase path.
arena.jaihasarena_wipeand the*_overwriting_memoryvariants; this file has no equivalent. Wipe sensitive memory yourself before resetting. cursoris an offset, not a pointer, and the header bytes are reserved in both layouts. Do not persist a cursor value across a change of build configuration —size_of(FixedArena)is what sets the start, and a struct layout change moves it.- The bootstrapped pointer dangles after delete. Covered above; it is the single easiest way to misuse this file.
- No alignment control. Alignment is fixed at
FIXED_ARENA_DEFAULT_ALIGNMENT. There is no equivalent ofarena_set_alignmentorpush_alignment. If you need a larger alignment for a particular allocation, over-allocate and align by hand. - In-place resize only helps the most recent allocation. Resizing anything buried under a later allocation copies, and the original is stranded until the next reset.
Testing
tests/fixed_arena_tests.jai covers every exported symbol across seven groups, each behind a
RUN_*_TESTS constant so groups can be enabled individually. The module-scope state machinery
(Fixed_Arena_State and its pair) is exercised through auto_reset.
Each test names the review finding it guards, so a failure points at the reasoning as well as
the line. The reasoning lives in fixed-arena-code-review.html.
Two conventions worth knowing before reading the output:
- Tests that drive failure paths deliberately log. Those are bracketed by
(expecting logged errors: ...)banners — noise inside a banner is the test working, noise outside one is not. - The suite reports per group rather than as one total. A check is one
expectcall reached at runtime, not one distinct property, so a check inside a loop counts once per iteration.
The suite passes: 165 checks over seven groups. Its first run failed 8 of 152 checks, all of them the same defect and
all of them in the tests: three sites computed the room left as memory.count - cursor and so
asked for more than could fit. The allocator was right to refuse every time. What the failures
actually exposed was a missing piece of API — there was no way for a caller to ask how much
would fit — which is why fixed_arena_available exists and why the raw-gap calculation is
called out twice above. With that closed, and with a zero-byte request made independent of the
block's base address, the suite runs clean.
The cases most worth keeping if the file is ever trimmed are the boundary ones: allocating to exact capacity and one byte past it, a zero-byte request against a full arena, and the alignment group's deliberately 8-but-not-16-aligned block. Those are the three places where the obvious implementation is wrong in a way that ordinary use does not reveal.