Learn Zig Series (#114) - Memory Pools
Learn Zig Series (#114) - Memory Pools

What will I learn?
- Why a memory pool (a.k.a. object pool, fixed-size block allocator, or free-list allocator) is the idea episode 113 was quietly walking toward -- carve one big slab up front, hand out identically-sized cells on demand, take them back and reuse them, and never touch the general allocator on the hot path;
- The one trick that makes a pool cost zero bookkeeping memory -- a free cell stores its own "next free" pointer inside the very bytes it would otherwise use for an object, so the free list is threaded through the storage itself;
- How to build a generic
MemoryPool(T)from scratch in Zig, where bothcreateanddestroycollapse to a single pointer pop and a single pointer push -- O(1), branchless, no searching; - Why an untagged
union { item: T, next: ?*Cell }is the exact shape the problem wants, and how@fieldParentPtrlets you walk from an object pointer back to its cell for free; - How Zig's error handling makes "the pool is exhausted" either an
error.OutOfMemoryyou must handle or anullyou cannot ignore -- your choice, spelled in the type; - That Zig already ships
std.heap.MemoryPool, why it exists, and when to reach for it instead of rolling your own; - How to test a pool so that address reuse, block growth, and no-double-free are all provable, and how to benchmark it against a general-purpose allocator to see the difference;
- How the same pattern shows up in C (custom
mallocarenas), Rust (typed-arenaand slab crates), and Go (sync.Pool), and why the memory-safety story differs sharply across the four.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- Episode 113 fresh in mind -- we built a lock-free ring buffer and ended on the observation that it never calls the allocator on the hot path, because it recycles slots in a circle instead of allocating them;
- A solid grip on allocators from episode 7 and the custom allocator we wrote in episode 26 -- this episode is where those two finally pay off together;
- Comfort with unions (episode 6) and comptime generics (episode 14), because the pool leans on both;
- The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees
- Learn Zig Series (#109) - Red-Black Trees
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Zig Series (#111) - Bloom Filters
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn Zig Series (#114) - Memory Pools (this post)
Learn Zig Series (#114) - Memory Pools
Episode 113 ended on a promise. We built a lock-free ring buffer and I pointed at the thing it quietly refuses to do -- it never calls the allocator on the hot path. It grabs one slab of memory up front and then recycles slots forever, so the same bytes serve a million items without a single alloc/free round-trip. And I said that this is not a quirk of ring buffers, it is a strategy: when you know the shape and size of what you are storing, you stop begging the general-purpose allocator for memory one object at a time and start managing a fixed reservoir yourself.
The ring recycles slots in a FIFO circle. Today we generalise the idea to recycle memory itself, in any order, for thousands of same-sized objects churning through create-and-destroy cycles. Network connections, particle systems, game entities, AST nodes, tree cells -- anywhere you allocate and free a zillion identically-shaped things and the general allocator's per-call overhead and fragmentation are eating your lunch. The structure that fixes it is the memory pool, and it is almost embarrassingly simple once you see the trick. But first -- the ring buffer debt from last week, three exercises, all with real code ;-)
Solutions to Episode 113 Exercises
Exercise 1 -- batch the pops. Instead of one .acquire/.release pair per item, drain up to out.len items at once and advance head a single time. The wrinkle is the wrap: the available items may straddle the end of the array, so you may need two copies. Here is popSlice, a consumer-side method on the SPSC ring:
fn popSlice(self: *Self, out: []T) usize {
const head = self.head.load(.monotonic); // we are head's only writer
const tail = self.tail.load(.acquire); // acquire: pairs with the producer's release
const available = tail - head;
const n = @min(available, out.len);
if (n == 0) return 0;
const start = head & mask;
const first = @min(n, capacity - start); // how many run from `start` to the array's end
@memcpy(out[0..first], self.buf[start .. start + first]);
if (first < n) { // wrapped: the rest is at the front of buf
@memcpy(out[first..n], self.buf[0 .. n - first]);
}
self.head.store(head + n, .release); // ONE release, after both copies -- frees all n slots
return n;
}
Why is the single release after both copies still correct? Because the release store is what publishes "these slots are free" to the producer. As long as every read of slot contents (@memcpy out of buf) happens before that store in program order, the producer cannot observe the freed head until our reads are done -- so it cannot overwrite a slot we are still copying out of. Batching does not weaken the ordering guarantee at all; it just amortises one .release across n items instead of paying it n times. On a deep ring drained in bursts, that is a real win.
Exercise 2 -- prove the ordering matters. Take the working ring, weaken the producer's tail store from .release to .monotonic, and watch it pass on x86-64 anyway (x86 is strongly ordered and hides the bug), then try to make it fail on ARM. The one-line change:
// BROKEN ON PURPOSE: .monotonic drops the happens-before edge that .release provided.
// The slot write (1) is no longer guaranteed visible before the new tail (2).
self.buf[tail & mask] = item; // (1)
self.tail.store(tail +% 1, .monotonic); // (2) was .release -- now publishes nothing
Build the stress test for ARM with the target-triple machinery from episode 35 and run it under an emulator:
// zig build test -Dtarget=aarch64-linux -fqemu (needs qemu-aarch64 installed)
// On x86-64 the checksum test passes ~every run even while broken.
// Under aarch64 the consumer can read tail advanced BEFORE the slot write lands,
// pop an uninitialised slot, and the million-item checksum comes out wrong.
The honest lesson: a bug being possible under the memory model is not the same as it being observable on a given run. x86's strong ordering silently repairs a missing .release, so the test is green on your laptop forever -- and then corrupts one run in ten thousand on a phone. That asymmetry is exactly why you write the ordering annotations from the model, not from what your local hardware happens to tolerate.
Exercise 3 -- a bounded blocking variant. When you would rather the producer sleep than spin on a full ring, wrap the array with a mutex and two condition variables:
fn BlockingRing(comptime T: type, comptime capacity: usize) type {
comptime std.debug.assert(capacity != 0 and (capacity & (capacity - 1)) == 0);
return struct {
const Self = @This();
const mask = capacity - 1;
buf: [capacity]T = undefined,
head: usize = 0,
tail: usize = 0,
mutex: std.Thread.Mutex = .{},
not_full: std.Thread.Condition = .{},
not_empty: std.Thread.Condition = .{},
fn push(self: *Self, item: T) void {
self.mutex.lock();
defer self.mutex.unlock();
while (self.tail - self.head == capacity) // while, not if: guards spurious wakeups
self.not_full.wait(&self.mutex);
self.buf[self.tail & mask] = item;
self.tail += 1;
self.not_empty.signal(); // a slot appeared -- wake one consumer
}
fn pop(self: *Self) T {
self.mutex.lock();
defer self.mutex.unlock();
while (self.head == self.tail)
self.not_empty.wait(&self.mutex);
const item = self.buf[self.head & mask];
self.head += 1;
self.not_full.signal(); // a slot freed -- wake one producer
return item;
}
};
}
Which to pick? For a producer that outruns the consumer by a hair, lock-free-plus-spin wins: the spin is short, the cache line stays hot, and you never pay a syscall to sleep and wake. But that flips the moment the imbalance grows. If the ring sits almost always full (producer way ahead), the lock-free producer burns a whole core spinning uselessly, and the condvar version -- which puts that thread to sleep -- is strictly better for throughput-per-watt. Near-empty is the mirror image on the consumer side. The while around each wait is not optional, by the way: condition variables are allowed to wake spuriously, so you re-check the predicate every time you surface. Right. Debt paid -- on to the pool.
The core idea: one slab, a free list threaded through it
Here is the whole memory pool in one breath. Ask the general allocator for one big block of N identically-sized cells, once. Keep a pointer to the first free cell. To allocate, pop the head of that free list and hand it back. To free, push the cell back onto the head of the list. Both operations are a single pointer assignment -- O(1), no search, no size classes, no fragmentation, because every cell is interchangeable with every other.
The clever bit -- the part that makes it cost nothing in bookkeeping memory -- is where the free list lives. A cell that is currently free is, by definition, not holding an object. So its bytes are idle. We use those idle bytes to store the "next free cell" pointer. The free list is threaded through the storage itself; there is no separate array of pointers, no metadata block, no header per cell. A free cell is a list node; an in-use cell is your object. It is only ever one or the other, never both at once, which is the textbook definition of a union.
That is the entire concept. Everything below is just spelling it in Zig and being careful about the edges.
Building the pool from scratch
I want this generic over the element type, fixed at compile time -- the comptime-generics pattern from episode 14. The cell is an untagged union of "your object" and "a next pointer", and I let the backing allocator hand me blocks of them:
const std = @import("std");
fn MemoryPool(comptime T: type) type {
return struct {
const Self = @This();
// A cell is your object OR a free-list node -- never both at once.
// Untagged: we track which state it is in externally (it is free iff it is
// reachable from free_list), so we pay zero bytes for a tag.
const Cell = union {
item: T,
next: ?*Cell,
};
// Blocks are chained so deinit can walk and free every slab we ever grabbed.
const Block = struct {
cells: []Cell,
prev: ?*Block,
};
backing: std.mem.Allocator,
free_list: ?*Cell = null,
blocks: ?*Block = null,
per_block: usize,
fn init(backing: std.mem.Allocator, per_block: usize) Self {
return .{ .backing = backing, .per_block = per_block };
}
fn deinit(self: *Self) void {
var b = self.blocks;
while (b) |block| {
const prev = block.prev;
self.backing.free(block.cells);
self.backing.destroy(block);
b = prev;
}
self.* = undefined; // poison: using the pool after deinit is now an obvious crash
}
};
}
Notice what the pool owns: a backing allocator, the head of the free list, the head of the block chain, and how many cells to grab per growth. That is it. No per-cell metadata anywhere -- the Cell union is exactly @max(@sizeOf(T), @sizeOf(?*Cell)) bytes wide, which for any T at least pointer-sized is just @sizeOf(T). The free list is genuinely free.
Growth, create, destroy
When the free list runs dry, we grow: grab a fresh block of per_block cells and thread every one of them onto the free list. Then create and destroy are the one-liners the whole design promised:
fn grow(self: *Self) !void {
const cells = try self.backing.alloc(Cell, self.per_block);
const block = try self.backing.create(Block);
block.* = .{ .cells = cells, .prev = self.blocks };
self.blocks = block;
// Push every new cell onto the free list. Back-to-front so cell[0] ends up on top --
// purely cosmetic, but it means a fresh pool hands out cells in address order.
var i: usize = cells.len;
while (i > 0) {
i -= 1;
cells[i] = .{ .next = self.free_list }; // activate the cell AS a free-list node
self.free_list = &cells[i];
}
}
fn create(self: *Self) !*T {
if (self.free_list == null) try self.grow(); // amortised: one alloc buys per_block objects
const cell = self.free_list.?;
self.free_list = cell.next; // read the next pointer OUT before we clobber it
cell.* = .{ .item = undefined }; // now flip the cell to hold an object
return &cell.item; // hand back the object-typed view
}
fn destroy(self: *Self, ptr: *T) void {
const cell: *Cell = @fieldParentPtr("item", ptr); // walk from the object back to its cell
cell.* = .{ .next = self.free_list }; // flip it back to a free-list node
self.free_list = cell;
}
Two details carry this. First, @fieldParentPtr: the caller only ever sees a *T -- the &cell.item view we handed out. To recycle it we need the *Cell that contains that item field, and @fieldParentPtr("item", ptr) computes the address of the enclosing union from a pointer to one of its fields. Because item sits at offset 0 the address is literally the same, but expressing it this way is intention-revealing and stays correct even if you later wrap the cell in a header. Compare the C version: a raw (Cell*)ptr cast the compiler cannot check at all.
Second -- and this is the bit Zig makes you say out loud where C lets you cheat -- every transition assigns the whole union (cell.* = .{ .next = ... } and cell.* = .{ .item = undefined }) rather than poking a field. A union field is only legal to read while it is the active one, so we flip the active field explicitly: grow and destroy activate next, create reads next out and then activates item before handing back &cell.item. That whole-value assignment is exactly what tells Zig "this cell now means something different", and it is why the overlay is safe rather than a reinterpret-cast you have to get right in your head. The undefined payload is deliberate -- a recycled cell holds junk until the caller fills it, precisely like the bytes malloc hands you.
The amortisation is worth saying out loud. create calls the real allocator only when the free list is empty -- once per per_block objects. Set per_block to 1024 and 1023 out of every 1024 create calls are a pointer pop with no allocator involvement whatsoever. That is the entire performance story of a pool in one sentence.
Where Zig's type system earns its keep
The comptime T means one MemoryPool source serves a pool of 16-byte particles, a pool of 200-byte connection structs, or a pool of tree nodes -- each monomorphised into its own tight code with the cell size baked in as a compile-time constant, zero runtime dispatch. The union's width is computed at compile time, so there is never a moment where a cell is the wrong size for its T.
Error handling is the other half. Because create returns !*T, "the pool needed to grow and the OS said no" surfaces as a normal error.OutOfMemory that the caller must handle with try or catch -- it cannot be silently ignored the way a C malloc returning NULL routinely is. And if you would rather a fixed-capacity pool that refuses to grow (common in embedded or real-time code where you must never call the OS allocator after startup), you make exhaustion a null in stead of an error:
// A pool that never grows: exhaustion is an ordinary null, not an error.
fn createFixed(self: *Self) ?*T {
const cell = self.free_list orelse return null; // no grow() -- caller decides what "full" means
self.free_list = cell.next;
cell.* = .{ .item = undefined };
return &cell.item;
}
Same machinery, different contract -- the exact parallel to episode 113's bool-vs-error-union choice for a full ring. The optional says "being full is ordinary, handle it inline"; the error union says "growing failed, that is exceptional, propagate it". Zig lets the return type tell whichever story fits your domain, and the caller cannot forget to reckon with it either way.
Zig already ships one: std.heap.MemoryPool
Here is the honest disclosure a good tutorial owes you: you rarely need to write this yourself, because the standard library has std.heap.MemoryPool, built on exactly the union-free-list idea we just derived. Reach for it first:
test "std.heap.MemoryPool does the same job" {
const Particle = struct { x: f32, y: f32, vx: f32, vy: f32 };
var pool = std.heap.MemoryPool(Particle).init(std.testing.allocator);
defer pool.deinit();
const a = try pool.create(); // *Particle, carved from a pooled block
a.* = .{ .x = 0, .y = 0, .vx = 1, .vy = -1 };
pool.destroy(a); // back onto the free list
const b = try pool.create(); // reuses a's cell -- same address
try std.testing.expect(a == b);
}
So why did we build our own? Same reason we always do in this series: you cannot reason about a tool you have only ever called. Now that you have written the free-list threading and the @fieldParentPtr recycle by hand, std.heap.MemoryPool is not a black box -- you know precisely what its create costs (a pointer pop, or one backing allocation per block), why its cells are all one size, and when it will and will not help you. That understanding is the actual deliverable; the stdlib type is just the version you ship. Having said that, our hand-rolled pool has one thing the stdlib one does not by default: you control per_block, which lets you tune the allocation granularity to your workload.
Testing the pool
The three properties that matter for a pool are address reuse (a freed cell comes back), block growth (it survives more objects than one block holds), and that objects stay independent (no accidental aliasing). All three are checkable without any concurrency, which makes pool tests far gentler than last week's lock-free stress runs:
test "pool reuses the exact cell it just freed" {
const Point = struct { x: f64, y: f64 };
var pool = MemoryPool(Point).init(std.testing.allocator, 16);
defer pool.deinit();
const a = try pool.create();
const addr_a = @intFromPtr(a);
pool.destroy(a);
const b = try pool.create(); // free list is LIFO: b must be a's cell
try std.testing.expectEqual(addr_a, @intFromPtr(b));
pool.destroy(b);
}
test "pool grows past a single block and keeps every object distinct" {
var pool = MemoryPool(u64).init(std.testing.allocator, 4); // tiny blocks -> forces growth
defer pool.deinit();
var ptrs: [100]*u64 = undefined;
for (&ptrs, 0..) |*slot, i| {
slot.* = try pool.create();
slot.*.* = i; // stamp each object with its index
}
for (ptrs, 0..) |p, i| // if any two aliased, a stamp would be wrong
try std.testing.expectEqual(@as(u64, i), p.*);
for (ptrs) |p| pool.destroy(p);
}
The second test is the sneaky-important one. With per_block = 4 and 100 live objects, the pool is forced to grow two dozen times, and stamping each object with a unique index then reading it back proves that no two create calls ever handed out the same cell -- an aliasing bug that a lazier test (just counting allocations) would sail right past. And because we used std.testing.allocator, the test harness also verifies our deinit frees every block -- a leak would fail the test outright. Nota bene the pool does not zero cells on reuse: a recycled cell holds whatever the previous object left there, so your code must fully initialise every object after create, exactly as you would after malloc.
Performance: why the pool wins, and its two real costs
A quick benchmark makes the difference visceral. Write two identical hot loops -- one that hammers create/destroy through a general-purpose allocator, one through the pool -- and time each with your platform's monotonic clock (the timing API has moved around between Zig versions, so I keep the measured loops separate and leave the stopwatch to you):
// Time each of these around a million iterations. Both take a *plain* allocator interface,
// so you can hand hammerGeneral a GeneralPurposeAllocator (or a DebugAllocator on newer Zig)
// and hammerPool the pool, then compare wall-clock nanoseconds.
fn hammerGeneral(ga: std.mem.Allocator, n: usize) !void {
var i: usize = 0;
while (i < n) : (i += 1) {
const p = try ga.create(u64); // a full allocator round-trip, every single iteration
ga.destroy(p);
}
}
fn hammerPool(pool: *MemoryPool(u64), n: usize) !void {
var i: usize = 0;
while (i < n) : (i += 1) {
const p = try pool.create(); // a bare pointer pop 4095 times out of every 4096
pool.destroy(p);
}
}
Run hammerGeneral against your general-purpose allocator and hammerPool against a MemoryPool(u64) built with, say, per_block = 4096. The pool typically lands several times faster, and the gap widens the more work the general allocator has to do to find a free block of the right size. That is the first win: speed, from turning an allocator search into a pointer pop. The second, quieter win is no fragmentation -- every cell is the same size, so there are no awkward gaps a general allocator has to track and coalesce, and a freed cell is always immediately reusable by the next create. A third, often-decisive win is cache locality: because all cells come from a handful of contiguous blocks, objects that were allocated near each other in time also sit near each other in memory, and iterating a pool of live objects streams through cache the way the ring buffer's flat array did in episode 113.
But a pool is not a free lunch, and a tutorial that pretends otherwise is lying to you. The first cost is rigidity: a pool serves one size. The moment you need a different-sized object you need a different pool, and if your workload's object sizes are all over the map, you are back to wanting a general allocator (or a whole family of pools, which is where next episode is heading). The second cost is a safety footgun: the pool does not track which cells are live, so a double-free -- calling destroy twice on the same pointer -- silently corrupts the free list into a cycle, and a use-after-free reads a cell that has already been handed to someone else. The general allocator in debug mode catches some of that; a bare pool catches none of it. You trade a slice of safety for the speed, and you have to know you made that trade.
Real-world applications, and when NOT to reach for a pool
Pools shine wherever you have a high-frequency churn of same-sized objects with a clear lifetime. Game engines pool entities, particles, and components (this is half of why the ECS we built back in episodes 55-58 is fast). Network servers pool per-connection state structs, carving one on accept and recycling it on close. Interpreters and compilers pool AST and IR nodes. Kernels pool task_structs and network buffers -- Linux's slab allocator is a pool taken to its industrial conclusion. Any tree or graph whose nodes are all one type is a natural pool client: instead of a create per node hitting the general allocator, every node comes from one pool and the whole structure frees in one deinit.
When should you not? If your objects vary wildly in size, a single pool is the wrong shape. If your allocation volume is low -- a few dozen objects over a program's life -- the pool is premature optimisation and a plain allocator.create is clearer. And if you need the debug allocator's use-after-free and double-free detection more than you need the speed, stay on the general allocator until a profiler (episode 34) actually points at allocation as your bottleneck. Reach for a pool when the numbers tell you to, not because it is clever.
How this compares to C, Rust, and Go
In C, a memory pool is the canonical "custom allocator" exercise: a struct with a void *free_list, a block list, and pool_alloc/pool_free that cast the free cell to a struct node * to read the next pointer. It is the same union trick, but unchecked -- the cast from object to node is a raw reinterpret the compiler cannot verify, nothing stops you pooling the wrong-sized type, and a double-free corrupts the list exactly as in Zig but with zero tooling to notice. Every serious C codebase (nginx, Redis, the Linux kernel, PostgreSQL's memory contexts) has its own hand-rolled pool, because the language ships nothing and the pattern is too valuable to skip.
In Rust, the ownership model makes the naive version awkward for the same reason the ring buffer was awkward: a pool that hands out &mut T while still owning the backing storage fights the borrow checker head-on. The idiomatic answers are crates -- typed-arena (bump-allocates same-typed objects, frees them all at once, no individual destroy), slab (a Vec-backed pool that hands out integer keys instead of pointers, sidestepping lifetimes entirely), and bumpalo for arena allocation. Each buys memory safety by changing the API so the compiler can prove no dangling reference survives, at the cost of the raw pointer ergonomics C and Zig give you. Rust makes the safety a type; you pay for it in interface.
In Go, the standard library ships sync.Pool, but it is a different beast -- a concurrent, GC-aware pool of temporary objects that the garbage collector is allowed to empty at any time, meant to relieve GC pressure rather than to give you deterministic O(1) allocation. You Get and Put interface{} values, and you must never assume an object is still in the pool. It is genuinely useful for reducing allocation churn on hot paths (the standard library uses it internally for buffers), but it gives you none of the layout control or determinism our Zig pool does, because Go's whole point is to not think about memory.
Our Zig version sits where it always does: the element type fixed in the type so the cell size is a compile-time constant, the free list threaded through the storage at zero metadata cost, @fieldParentPtr making the object-to-cell walk explicit and checkable, and the !*T-versus-?*T return letting you choose whether exhaustion is an error or an ordinary value. Same pattern across four languages -- but only here is it a handful of lines you can read in one sitting, with no GC, no borrow-checker ceremony, and no unchecked cast ;-)
Exercises
Add a poison-on-free debug check. Extend
destroyso that, when built inDebugmode (check@import("builtin").mode), it first walks the free list to confirmptr's cell is not already on it -- turning a silent double-free into a loud@panic. Measure what this costs on the benchmark above (hint: an O(n) walk per free is brutal on a big pool), then propose a cheaper scheme -- a single "is-free" bit or a generation counter per cell -- that catches the same bug in O(1). Argue which you would actually ship.A pool that reclaims empty blocks. Our
deinitfrees everything, but while running, the pool only ever grows -- a burst of a million objects leaves a million cells allocated forever, even after they are all freed. Add bookkeeping so a block whose cells are all back on the free list can be returned to the backing allocator. Think hard about the cost: how do you know a whole block is free without an O(n) scan, and is the added per-cell bookkeeping worth it versus just accepting the high-water-mark memory? Write up the trade.Benchmark pool-of-nodes versus general-allocator-of-nodes on a real structure. Take the singly-linked list from episode 106, build it two ways -- once with every node from
allocator.create, once with every node from aMemoryPool-- insert a million nodes, iterate them summing a field, then tear down. Time all three phases (build, iterate, free) separately. Explain which phase the pool helps most and why the iterate phase differs even though neither version allocates during iteration (hint: where do the nodes physically live?).
Where this is heading
Step back and look at the load-bearing assumption under everything today: one size. The pool is fast precisely because every cell is interchangeable -- allocation is a pointer pop exactly because the allocator never has to ask "is this hole big enough?". That single constraint is what buys us O(1), zero fragmentation, and cache-friendly layout all at once.
But real programs are not so tidy. A kernel does not allocate only task_structs -- it allocates task structs and inodes and network buffers and file descriptors, dozens of distinct fixed sizes, each churning at its own rate. You could run a separate MemoryPool per type by hand, but the moment you have thirty of them you start wanting one system that manages a family of pools, routes each allocation to the pool for its size class, carves those pools out of larger page-sized runs, and hands whole pages back to the OS when a run goes cold. That is the design that actually sits under malloc in a modern libc and under the slab layer in Linux -- the forementioned pool idea, scaled up into a size-classed allocator with page-level bookkeeping. We built the single-size reservoir today. Next we build the machine that manages many of them at once, and the free-list trick you just learned becomes one gear in a much bigger clock ;-)
Bedankt voor het lezen, en tot de volgende keer!