Custom Allocators¶
General-purpose malloc is a marvel of engineering, but it pays for its generality with every allocation. If you know the shape of your allocations — they’re all the same size, or they all live and die together, or they’re recycled aggressively — a domain-specific allocator can be 5-50x faster and completely eliminate whole classes of bugs. This file gives you three you’ll actually use: bump/arena, pool, and freelist. Each fits in ~100 lines. Once you have them, you’ll reach for them more often than you expect.
Why Custom Allocators Are Suddenly Mainstream¶
The C style promoted by Chris Wellons (nullprogram.com) and Ryan Fleury (rfleury.com’s arena allocator post) — and echoed by Casey Muratori’s Handmade Hero series and Jonathan Blow’s Jai language — argues that most of the pain in “manual memory management” isn’t intrinsic to C; it comes from choosing per-object lifetime as the default. Switch to arena-per-scope as the default and 90% of leaks and UAFs literally cannot happen. The arena style has taken over enough of the C-writing internet that in mid-2026, if you say “C memory management is a nightmare,” someone will link you to Wellons and Fleury and ask if you’ve tried arenas yet.
That’s not the whole story — you’ll still use malloc for objects with truly per-object lifetimes, and you’ll still use freelists for hot allocation paths in database and kernel code. But arena-first is the modern default worth adopting.
Allocator 1: The Arena (Bump) Allocator¶
Idea: One big buffer, one offset. Allocating is “bump the offset.” Freeing individual objects is impossible; freeing everything is resetting the offset to zero.
When it wins: any scope where allocations share a lifetime. Per-request in a server. Per-frame in a game. Per-parse in a compiler. Per-batch in an ML pipeline.
Cost: allocation is ~5 cycles (bump, align, return). No metadata per object. No fragmentation. No free bookkeeping.
// arena.h
typedef struct {
unsigned char *base;
size_t used;
size_t cap;
} Arena;
void arena_init(Arena *a, void *buf, size_t cap);
void *arena_alloc(Arena *a, size_t size, size_t align);
void arena_reset(Arena *a);
#define arena_new(a, T) ((T*)arena_alloc((a), sizeof(T), _Alignof(T)))
#define arena_new_n(a, T, n) ((T*)arena_alloc((a), sizeof(T)*(n), _Alignof(T)))
// arena.c
#include "arena.h"
#include <stdint.h>
#include <string.h>
void arena_init(Arena *a, void *buf, size_t cap) {
a->base = (unsigned char*)buf;
a->used = 0;
a->cap = cap;
}
void *arena_alloc(Arena *a, size_t size, size_t align) {
uintptr_t curr = (uintptr_t)a->base + a->used;
uintptr_t aligned = (curr + align - 1) & ~(uintptr_t)(align - 1);
size_t pad = aligned - curr;
if (a->used + pad + size > a->cap) return NULL; // OOM in the arena
void *p = a->base + a->used + pad;
a->used += pad + size;
return p;
}
void arena_reset(Arena *a) { a->used = 0; }
That’s it. Fewer than 30 lines. Wellons’ 2023 post extends it with virtual-memory reservation (reserve gigabytes of address space, commit pages lazily) and a scratch arena pattern for scoped temporary allocations — read it, twice. In the projects (see projects.md) you’ll build a full version with tests.
Gotcha: never take a pointer to an arena-allocated object and store it past arena_reset. That’s your only rule.
Allocator 2: The Pool Allocator (Fixed-Size Slab)¶
Idea: All allocations are the same size. Maintain a freelist. Alloc pops the head; free pushes to the head.
When it wins: containers with uniform node size — linked list nodes, tree nodes, hashmap entries. Anywhere you’d think “I’m going to allocate a million of these.”
Cost: allocation is one pointer read + one write. Free is one pointer write. Zero fragmentation for the pool’s type.
typedef struct Pool {
unsigned char *base;
size_t block_size;
size_t block_count;
void *free_list; // linked list embedded in free blocks
} Pool;
void pool_init(Pool *p, void *buf, size_t block_size, size_t count) {
// block_size must be >= sizeof(void*)
p->base = buf;
p->block_size = block_size;
p->block_count = count;
p->free_list = NULL;
for (size_t i = 0; i < count; i++) {
void *block = p->base + i * block_size;
*(void**)block = p->free_list;
p->free_list = block;
}
}
void *pool_alloc(Pool *p) {
if (!p->free_list) return NULL;
void *block = p->free_list;
p->free_list = *(void**)block;
return block;
}
void pool_free(Pool *p, void *block) {
*(void**)block = p->free_list;
p->free_list = block;
}
The trick: freed blocks store the freelist pointer in their own memory. That’s why block_size >= sizeof(void*). Zero per-block metadata.
Allocator 3: The Freelist / Multi-Size Slab¶
A generalization of the pool. Keep multiple pools, one for each of a few power-of-two size classes (16, 32, 64, 128, 256, …). Route each allocation to the smallest pool that fits. Route each free back based on the pointer’s origin.
This is roughly what production allocators (tcmalloc, jemalloc, mimalloc) do internally, plus per-thread caches and lots of tuning. You will not out-implement them in a weekend. What you can do is understand their shape by writing a toy version:
// Two-line intuition: an array of Pool, one per size class.
typedef struct { Pool pools[NCLASSES]; } SlabAllocator;
void *slab_alloc(SlabAllocator *s, size_t n) {
int c = size_class_for(n);
return pool_alloc(&s->pools[c]);
}
The real work is size-class table design and thread-local caching. Read jemalloc’s paper if you’re curious; use jemalloc in prod.
Production Alternatives: jemalloc, mimalloc, tcmalloc¶
These are drop-in replacements for glibc’s ptmalloc2. As of 2026:
mimalloc (Microsoft, active) — typically best for small-allocation-heavy workloads. 15%-ish P99 latency wins reported by 2026 benchmarks on FIX-parser workloads (stratcraft.ai 2026 benchmark). Currently at 2.x.
jemalloc (Meta — formerly Facebook, still active). The battle-tested default at Meta, ScyllaDB, Rust’s
allocwhen you enable the feature, older Redis versions. Excellent for long-running services with varied allocation patterns.tcmalloc (Google, active) — great throughput; especially good with many threads. Slightly behind mimalloc on some P99 metrics, ahead on some throughput ones. Depends on your workload.
glibc
ptmalloc2(the default on Linux) — fine for the median case, notably worse than the above three under multi-threaded contention. ScyllaDB reported ~40% throughput improvements switching off of it.
None of these are deprecated as of 2026. mimalloc is the fashionable choice for new projects; jemalloc remains the pragmatic default at large scale. You typically enable them via LD_PRELOAD or by linking -ljemalloc — no code changes.
When To Reach For Which¶
Situation |
Reach for |
|---|---|
Per-request web handler, per-frame game loop, per-parse compiler |
Arena |
Millions of same-size objects (list nodes, tree nodes) |
Pool |
General-purpose service, don’t know allocation shape |
jemalloc or mimalloc as global allocator |
Hot inner loop, known small sizes |
Arena + slab hybrid |
Debugging |
glibc’s default + ASan (best diagnostics) |
What Most People Get Wrong About This¶
They think custom allocators are premature optimization. They’re not — they’re simplification. An arena isn’t just faster than malloc/free; it’s simpler. You cannot leak from an arena. You cannot double-free from an arena. You cannot use-after-free within an arena’s lifetime because everything is alive until the reset. The performance is a bonus. Once you feel this, malloc/free starts to look like the special case, not the default.
Return to README.md · Next: 07_pointer_wizardry.md