Custom Allocators & Arenas
The default heap allocator (malloc/free, new/delete) is general-purpose but slow and easy to leak: every object is tracked, freed, and reclaimed individually. Arena (a.k.a. region or bump) allocators flip the model - you carve allocations off a contiguous block by simply advancing a pointer, never free anything individually, and then reclaim the entire region in one operation. This makes per-allocation cost nearly free, eliminates whole classes of use-after-free and leak bugs for same-lifetime data, and is why explicit, swappable allocators are a first-class concern across systems languages.
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
#include <stdint.h>
#include <assert.h>
/* A hand-rolled bump (arena) allocator over one owned block. */
typedef struct {
uint8_t *base; /* start of the backing buffer (we own it) */
size_t cap; /* total bytes available */
size_t used; /* bump offset: next free byte */
} Arena;
static int arena_init(Arena *a, size_t cap) {
a->base = malloc(cap); /* the ONE heap allocation */
if (!a->base) return -1;
a->cap = cap;
a->used = 0;
return 0;
}
/* Bump-allocate `size` bytes aligned to `align` (power of two). */
static void *arena_alloc(Arena *a, size_t size, size_t align) {
size_t off = (a->used + (align - 1)) & ~(align - 1); /* round up */
if (off + size > a->cap) return NULL; /* arena full */
void *p = a->base + off;
a->used = off + size; /* just move the pointer */
return p;
}
/* Free EVERYTHING at once: no per-object free, just rewind. */
static void arena_reset(Arena *a) { a->used = 0; }
static void arena_destroy(Arena *a) { free(a->base); a->base = NULL; a->cap = a->used = 0; }
int main(void) {
Arena a;
if (arena_init(&a, 1024) != 0) return 1;
/* Many short-lived allocations, none freed individually. */
int *xs = arena_alloc(&a, 16 * sizeof(int), _Alignof(int));
char *name = arena_alloc(&a, 32, _Alignof(char));
assert(xs && name);
for (int i = 0; i < 16; i++) xs[i] = i * i;
snprintf(name, 32, "arena");
printf("%s: xs[15]=%d, used=%zu/%zu bytes\n", name, xs[15], a.used, a.cap);
arena_destroy(&a); /* one free() reclaims all of the above */
return 0;
}A bump allocator owns a single malloc'd block and serves requests by rounding used up for alignment and advancing it - O(1) with no bookkeeping. Individual objects are never freed; one free(base) in arena_destroy reclaims the whole region at once.
#include <cstddef>
#include <memory>
#include <vector>
#include <string>
#include <memory_resource>
#include <print>
int main() {
// A fixed 4 KiB stack buffer - no heap involved at all.
std::array<std::byte, 4096> buffer{};
// monotonic_buffer_resource is the STL's arena: it bump-allocates
// out of `buffer` and frees NOTHING until the resource itself dies.
std::pmr::monotonic_buffer_resource arena{buffer.data(), buffer.size()};
// PMR containers take the resource and route every allocation through it.
std::pmr::vector<int> xs{&arena};
for (int i = 0; i < 16; ++i) xs.push_back(i * i);
std::pmr::string name{"arena", &arena};
// Even when the vector reallocates, the storage comes from `arena`,
// so the old buffers are simply abandoned (the monotonic resource
// never reuses freed blocks) and reclaimed wholesale at scope exit.
std::println("{}: xs[15]={}, capacity={}", name, xs[15], xs.capacity());
// `arena` is RAII: when it goes out of scope its (here, stack) memory
// is released in one shot. No per-element delete, no leaks.
return 0;
}std::pmr::monotonic_buffer_resource is the standard library's arena: it bump-allocates from a buffer (here, on the stack) and never recycles freed blocks. PMR containers accept it via std::pmr::polymorphic_allocator, and RAII reclaims the whole region when the resource is destroyed.
// HolyC has no allocator interface, so model an arena over one MAlloc'd block.
class Arena {
U8 *base; // backing buffer (we own it)
I64 cap; // total bytes
I64 used; // bump offset: next free byte
};
U0 ArenaInit(Arena *a, I64 cap=1024) {
a->base = MAlloc(cap); // the ONE heap allocation
a->cap = cap;
a->used = 0;
}
// Bump-allocate `size` bytes, 8-byte aligned.
U8 *ArenaAlloc(Arena *a, I64 size) {
I64 off = (a->used + 7) & ~7; // round up to 8
if (off + size > a->cap) return NULL; // arena full
U8 *p = a->base + off;
a->used = off + size; // just move the pointer
return p;
}
U0 ArenaReset(Arena *a) { a->used = 0; } // free all: rewind
U0 ArenaDestroy(Arena *a) { Free(a->base); } // free all: one Free
Arena a;
ArenaInit(&a); // default cap = 1024
I64 *xs = ArenaAlloc(&a, 16 * sizeof(I64));
U8 *name = ArenaAlloc(&a, 32);
I64 i;
for (i = 0; i < 16; i++) xs[i] = i * i;
StrCpy(name, "arena");
Print("%s: xs[15]=%d, used=%d/%d bytes\n", name, xs[15], a.used, a.cap);
ArenaDestroy(&a); // one Free reclaims everything
TempleOS exposes only MAlloc/Free, so an arena is hand-built over one MAlloc'd block exactly as in C. ArenaAlloc bump-advances used (note the cap=1024 default arg, a HolyC feature), and a single Free(base) reclaims it all.
const std = @import("std");
pub fn main() !void {
// 1) FixedBufferAllocator: a pure bump allocator over a stack buffer.
// No heap, no syscalls; allocation is just advancing an offset.
var buffer: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const a = fba.allocator(); // the std.mem.Allocator interface
const xs = try a.alloc(i32, 16); // bump-allocated; no defer free needed -
for (xs, 0..) |*x, i| x.* = @intCast(i * i); // the whole buffer is reused
fba.reset(); // "free" everything at once: rewind the offset
// 2) ArenaAllocator: wraps ANY child allocator and frees in one deinit().
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit(); // detects leaks of the backing allocator
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit(); // frees EVERY arena allocation in one shot
const aa = arena.allocator();
// Many allocations, none freed individually:
const list = try aa.alloc(i32, 16);
const name = try aa.dupe(u8, "arena");
for (list, 0..) |*v, i| v.* = @intCast(i);
std.debug.print("{s}: xs[15]={d}, list[15]={d}\n", .{ name, xs[15], list[15] });
}Zig makes allocators explicit values: FixedBufferAllocator bump-allocates from a stack array (reset rewinds it), while ArenaAllocator wraps a child allocator and reclaims every allocation with one arena.deinit() - scheduled by defer, so no per-object frees and no leaks.
use fmt;
// A hand-rolled bump arena over one owned slice.
type arena = struct {
base: []u8, // backing buffer (we own it)
used: size, // bump offset: next free byte
};
fn arena_init(cap: size) arena = {
return arena { base = alloc([0u8...], cap), used = 0 };
};
// Bump-allocate `n` bytes, 8-byte aligned; null if the arena is full.
fn arena_alloc(a: *arena, n: size) nullable *[*]u8 = {
const off = (a.used + 7) & ~7z; // round up to 8
if (off + n > len(a.base)) return null;
const p = &a.base[off]: *[*]u8;
a.used = off + n; // just move the pointer
return p;
};
fn arena_reset(a: *arena) void = a.used = 0; // free all: rewind
fn arena_destroy(a: *arena) void = free(a.base); // free all: one free
export fn main() void = {
let a = arena_init(1024);
defer arena_destroy(&a); // one free reclaims everything
const xs = match (arena_alloc(&a, 16 * size(i32))) {
case let p: *[*]u8 => yield (p: *[*]i32)[..16];
case null => abort("arena out of memory");
};
for (let i = 0z; i < 16; i += 1) xs[i] = (i * i): i32;
fmt::printfln("arena: xs[15]={}, used={}/{} bytes",
xs[15], a.used, len(a.base))!;
};Hare's alloc/free are manual, so an arena is built over one owned []u8 slice. arena_alloc returns a nullable pointer (nullable *[*]u8) handled with match, and defer arena_destroy runs a single free(a.base) at scope exit to reclaim all allocations together.
package main
import "core:fmt"
import "core:mem"
main :: proc() {
// 1) Arena over a fixed stack buffer - pure bump allocation, no heap.
buf: [4096]byte
arena: mem.Arena
mem.arena_init(&arena, buf[:])
arena_alloc := mem.arena_allocator(&arena)
// Run a block with `context.allocator` swapped to the arena: EVERY
// implicit allocation (new, make, append, fmt) now bump-allocates.
{
context.allocator = arena_alloc
xs := make([]int, 16) // bump-allocated from `buf`
for i in 0 ..< 16 do xs[i] = i * i
list: [dynamic]int // dynamic array, also from the arena
for i in 0 ..< 16 do append(&list, i)
name := fmt.aprint("arena") // even string formatting uses the arena
fmt.println(name, "xs[15] =", xs[15], "list[15] =", list[15])
// No delete/free calls: nothing is freed individually here.
}
// Free EVERYTHING the arena handed out, in one operation.
mem.arena_free_all(&arena)
}Odin threads an allocator through the implicit context. Assigning context.allocator = mem.arena_allocator(...) makes new, make, append, and fmt.aprint all bump-allocate from the arena's buffer; mem.arena_free_all then reclaims every allocation at once with no per-object delete.
\ Forth's dictionary data space IS a bump allocator: HERE is the
\ bump pointer and ALLOT advances it. We carve a private arena out of it.
CREATE ARENA 1024 ALLOT \ reserve 1024 bytes of data space (we own it)
VARIABLE AP \ arena pointer: bytes used so far
0 AP !
: ARENA-ALLOC ( n -- addr ) \ bump-allocate n bytes, return their address
AP @ OVER + 1024 > ABORT" arena full" \ would offset+n overflow?
ARENA AP @ + ( n addr ) \ address = base + old offset
SWAP AP +! ( addr ) \ bump: offset += n
;
: ARENA-RESET ( -- ) 0 AP ! ; \ free ALL at once: rewind the pointer
\ Allocate room for 16 cells, fill with squares, no per-cell free:
16 CELLS ARENA-ALLOC ( xs )
16 0 DO I I * OVER I CELLS + ! LOOP ( xs )
15 CELLS + @ . \ print xs[15] = 225
ARENA-RESET \ reclaim the whole arena in one moveForth has no malloc by default - its dictionary data space is already a bump arena, with HERE as the live bump pointer and ALLOT advancing it. Here we reserve a private 1 KiB region with CREATE ... ALLOT, hand out aligned cells by adding to an offset, and ARENA-RESET reclaims everything by zeroing that offset (the closest idiom; ALLOT itself only grows, so a separate offset variable gives us reset).