← Code Compare

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.

Show: CC++HolyCZigHareOdinForth
C
#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.

C++
#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
// 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.

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

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

Odin
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
\ 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 move

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