zig-skills/references/std-allocators.md

17 KiB

std.heap - Allocators

Zig has no default allocator. Functions that need heap memory accept an Allocator parameter.

Quick Reference

Allocator Use Case Thread-Safe
std.testing.allocator Unit tests (leak detection) No
std.heap.FixedBufferAllocator Stack-based, bounded size known Optional
std.heap.ArenaAllocator Batch free, CLI apps, request handlers No
std.heap.page_allocator Backing for other allocators Yes
std.heap.c_allocator Linking libc, interop Yes
std.heap.raw_c_allocator Libc arena backing (no alignment overhead) Yes
std.heap.DebugAllocator Debug builds, leak/corruption detection Configurable
std.heap.smp_allocator ReleaseFast production multithreaded Yes
std.heap.MemoryPool High-frequency same-type allocations No
std.heap.ThreadSafeAllocator Wrap non-thread-safe allocator Yes
std.heap.StackFallbackAllocator Stack buffer with heap fallback Depends
std.heap.wasm_allocator WebAssembly targets Yes

Allocator Naming Conventions

Using a generic allocator name hides memory ownership contracts. Name allocators by their memory contract to make code self-documenting:

Name Contract Can Return Data?
gpa Caller must free with defer gpa.free() Yes
arena Bulk-deallocated at system boundary Yes
scratch Function-private temporary space Never

The Problem

// BAD - "allocator" says nothing about ownership
fn process(allocator: Allocator) ![]u8 {
    const temp = try allocator.alloc(u8, 100);  // Who frees this?
    const result = try allocator.dupe(u8, temp); // Who owns this?
    allocator.free(temp);  // Is this correct?
    return result;  // Can caller free with same allocator?
}

The Solution

Name allocators by their contract:

// GOOD - names communicate ownership contracts
fn process(
    gpa: Allocator,      // General-purpose: caller must free returned data
    scratch: Allocator,  // Temporary: never return data allocated here
) ![]u8 {
    // scratch is for intermediate computation only
    const temp = try scratch.alloc(u8, 100);
    defer scratch.free(temp);

    // gpa for data that outlives this function
    return try gpa.dupe(u8, computeResult(temp));
}

Full Example with All Three

fn handleRequest(
    request: *Request,
    arena: Allocator,   // Response lifetime - bulk freed after response sent
    gpa: Allocator,     // Long-lived data - cache, shared state
    scratch: Allocator, // This function only - intermediate computation
) !Response {
    // Scratch: temporary parsing buffers (never escapes this function)
    const parsed = try parseBody(request.body, scratch);

    // GPA: update shared cache (outlives request)
    try updateCache(gpa, parsed.cache_key, parsed.value);

    // Arena: response data (freed when response completes)
    const response_body = try formatResponse(arena, parsed);

    return Response{ .body = response_body };
}

Common Patterns

CLI applications - arena for everything, freed at exit:

pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    try run(arena.allocator());  // Name as "arena" - bulk freed at end
}

Request handlers - arena per request, gpa for shared state:

fn handleRequest(gpa: Allocator, request: Request) !Response {
    var request_arena = std.heap.ArenaAllocator.init(gpa);
    defer request_arena.deinit();
    const arena = request_arena.allocator();

    // arena: request-scoped data
    // gpa: data that outlives the request (caches, connections)
}

Functions with temporary allocations - scratch parameter:

/// Computes result using scratch for intermediate work.
/// Caller owns returned slice (allocated from gpa).
fn compute(gpa: Allocator, scratch: Allocator, input: []const u8) ![]u8 {
    const temp = try scratch.alloc(u8, input.len * 2);
    defer scratch.free(temp);
    // ... use temp for intermediate computation ...
    return try gpa.dupe(u8, result);
}

Allocator Interface

const Allocator = std.mem.Allocator;

// Single items: create/destroy
const ptr: *T = try allocator.create(T);
defer allocator.destroy(ptr);

// Slices: alloc/free
const slice: []T = try allocator.alloc(T, count);
defer allocator.free(slice);

// Duplicate existing slice
const copy = try allocator.dupe(u8, source);
defer allocator.free(copy);

// Resize (returns bool - true if resized in place)
if (allocator.resize(slice, new_len)) {
    // slice is now new_len (pointer unchanged)
}

// Reallocate (may move, returns new slice)
slice = try allocator.realloc(slice, new_len);

Choosing an Allocator

Decision flow:

  1. Library code? Accept Allocator parameter - let caller decide
  2. Unit test? Use std.testing.allocator (has leak detection)
  3. Size known at comptime? Use FixedBufferAllocator with stack buffer
  4. Stack with heap fallback? Use stackFallback(N, backing_allocator)
  5. CLI app / one-shot? Use ArenaAllocator wrapping page_allocator
  6. Request loop (web/game)? Use ArenaAllocator, reset per iteration
  7. Many same-type objects? Use MemoryPool(T) for fast create/destroy
  8. Debug build? Use DebugAllocator for leak/corruption detection
  9. ReleaseFast production? Use std.heap.smp_allocator
  10. Linking libc? Use c_allocator or raw_c_allocator (as arena backing)

Common Allocators

Testing Allocator

test "example" {
    const allocator = std.testing.allocator;
    const data = try allocator.alloc(u8, 100);
    defer allocator.free(data);  // Leak detected if missing!
}

FixedBufferAllocator

No heap allocations - allocates into a fixed buffer. Useful for kernels, embedded, or performance-critical code. Returns OutOfMemory when buffer exhausted:

var buffer: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const allocator = fba.allocator();

const data = try allocator.alloc(u8, 100);
// Free/resize only works for most recent allocation
allocator.free(data);

// Reset to reuse buffer
fba.reset();

Thread-safe variant (allocate only - no resize/free):

const ts_allocator = fba.threadSafeAllocator();

Ownership checks:

if (fba.ownsPtr(ptr)) { ... }    // Check if pointer is within buffer
if (fba.ownsSlice(slice)) { ... } // Check if slice is within buffer

ArenaAllocator

Wraps a child allocator. Allocate many times, free all at once with .deinit(). Individual free() only works for most recent allocation:

// CLI app pattern - allocate freely, free all at end
pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const allocator = arena.allocator();

    const data = try allocator.alloc(u8, 1000);
    const more = try allocator.alloc(u8, 2000);
    // No need to free individual allocations
}

// Request loop pattern - reset per iteration
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();

while (running) {
    _ = arena.reset(.retain_capacity);  // Keep memory, reset state
    const allocator = arena.allocator();
    try handleRequest(allocator);
}

Reset modes:

  • .free_all - Release all memory to backing allocator
  • .retain_capacity - Keep allocated pages for reuse (faster)
  • .{ .retain_with_limit = N } - Retain up to N bytes

Query current usage:

const bytes_used = arena.queryCapacity();  // Excludes internal overhead

State optimization - store just the state to save memory:

const State = std.heap.ArenaAllocator.State;
var state: State = .{};

// Promote to full allocator when needed
var arena = state.promote(std.heap.page_allocator);
defer arena.deinit();

DebugAllocator

Detects leaks, double-free, use-after-free. Designed for safety over performance, but still faster than page_allocator. Safety checks and thread safety configurable:

var gpa: std.heap.DebugAllocator(.{}) = .init;
defer {
    const check = gpa.deinit();
    if (check == .leak) {
        std.debug.print("Memory leak detected!\n", .{});
    }
}
const allocator = gpa.allocator();

Configuration options:

var gpa: std.heap.DebugAllocator(.{
    .stack_trace_frames = 10,     // Capture more frames
    .enable_memory_limit = true,  // Track total bytes
    .safety = true,               // Enable safety checks
    .thread_safe = true,          // Multi-thread support
    .never_unmap = true,          // Debug use-after-free
    .retain_metadata = true,      // Better double-free detection
}) = .init;

SmpAllocator

Maximum performance for multithreaded ReleaseFast builds. Few safety features:

const allocator = std.heap.smp_allocator;
const data = try allocator.alloc(u8, 1000);
allocator.free(data);

C Allocator

Alternative when smp_allocator is not available. Requires linking libc (-lc):

const allocator = std.heap.c_allocator;

Page Allocator

Requests entire pages from OS via syscall. A 1-byte allocation reserves multiple kibibytes - inefficient for small allocations. Use as backing allocator for ArenaAllocator or DebugAllocator:

const allocator = std.heap.page_allocator;

MemoryPool

Fast allocator for many objects of the same type. Outperforms general-purpose allocators when allocating/freeing objects in rapid succession:

var pool = std.heap.MemoryPool(MyStruct).init(std.heap.page_allocator);
defer pool.deinit();

// Allocate objects (very fast)
const obj1 = try pool.create();
const obj2 = try pool.create();

// Free returns to pool for reuse (not to backing allocator)
pool.destroy(obj1);

// Reuses freed slot
const obj3 = try pool.create();  // likely same address as obj1

// Reset all - batch destroy without individual frees
_ = pool.reset(.retain_capacity);

Options:

// Pre-allocate slots
var pool = try std.heap.MemoryPool(T).initPreheated(allocator, 100);

// Custom alignment
var pool = std.heap.MemoryPoolAligned(T, .@"64").init(allocator);

// Non-growable (fixed capacity)
var pool = try std.heap.MemoryPoolExtra(T, .{ .growable = false }).initPreheated(allocator, 50);

ThreadSafeAllocator

Wraps any allocator with mutex for thread safety:

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();

var ts = std.heap.ThreadSafeAllocator{
    .child_allocator = arena.allocator(),
};
const allocator = ts.allocator();  // Safe to use from multiple threads

StackFallbackAllocator

Allocates from stack buffer first, falls back to another allocator when exhausted:

var fallback = std.heap.stackFallback(4096, std.heap.page_allocator);
const allocator = fallback.get();

// First 4KB comes from stack (no heap allocation)
const small = try allocator.alloc(u8, 100);

// Falls back to page_allocator if stack buffer exhausted
const large = try allocator.alloc(u8, 10000);

raw_c_allocator

Direct malloc/free without alignment overhead. Use as ArenaAllocator backing when linking libc:

// More efficient than c_allocator when wrapping with ArenaAllocator
var arena = std.heap.ArenaAllocator.init(std.heap.raw_c_allocator);
defer arena.deinit();

Requires linking libc. Does not support custom alignment - asserts alignment <= @alignOf(std.c.max_align_t).

Wasm Allocator

Optimized for WebAssembly. Uses @wasmMemoryGrow:

const allocator = std.heap.wasm_allocator;  // Only on wasm32/wasm64

Page Size Constants

std.heap.page_size_min  // Comptime minimum page size for target
std.heap.page_size_max  // Comptime maximum page size for target
std.heap.pageSize()     // Runtime page size (may be comptime if min == max)

Passing Allocators

In libraries - accept allocator parameter:

pub fn MyContainer(comptime T: type) type {
    return struct {
        allocator: std.mem.Allocator,
        data: []T,

        pub fn init(allocator: std.mem.Allocator) @This() {
            return .{ .allocator = allocator, .data = &.{} };
        }

        pub fn deinit(self: *@This()) void {
            if (self.data.len > 0) {
                self.allocator.free(self.data);
            }
        }

        pub fn add(self: *@This(), item: T) !void {
            // Use self.allocator for internal allocations
        }
    };
}

Functions returning allocated memory - document ownership:

/// Caller owns returned memory.
pub fn readFile(allocator: Allocator, path: []const u8) ![]u8 {
    // ...
    return try allocator.dupe(u8, content);
}

// Caller must free:
const content = try readFile(allocator, "file.txt");
defer allocator.free(content);

Common Patterns

Wrapping Allocators (Sub-Allocators)

// Arena on top of debug allocator
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();

var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();

const allocator = arena.allocator();

Temporary Allocations in Loops

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();

for (items) |item| {
    // Reset arena each iteration for automatic cleanup
    _ = arena.reset(.retain_capacity);
    const temp = try arena.allocator().alloc(u8, item.size);
    // temp is automatically "freed" on next reset
}

Sentinel-Terminated Allocations

// Allocate with null terminator
const str = try allocator.allocSentinel(u8, len, 0);
defer allocator.free(str);

// Duplicate with sentinel
const c_str = try allocator.dupeZ(u8, "hello");  // [:0]u8
defer allocator.free(c_str);

Error Handling

Always handle error.OutOfMemory:

// Option 1: Propagate
fn process(allocator: Allocator) !void {
    const data = try allocator.alloc(u8, size);
    defer allocator.free(data);
}

// Option 2: Handle gracefully
fn process(allocator: Allocator) void {
    const data = allocator.alloc(u8, size) catch {
        log.err("Out of memory", .{});
        return;
    };
    defer allocator.free(data);
}

Zig 0.16 Notes

Primary release-note source: https://ziglang.org/download/0.16.0/release-notes.html

  • std.heap.ArenaAllocator is thread-safe and lock-free in Zig 0.16.
  • std.heap.ThreadSafe allocator was removed.
  • Allocators that perform blocking synchronization or file/entropy/time work should accept/store std.Io at initialization rather than constructing a local backend.
  • Keep allocator naming by memory contract (gpa, arena, scratch) and expose stored io through a small accessor only when callsites need it.

Initialization (0.16.x)

Use .init not .{}:

// WRONG - deprecated
var gpa: std.heap.DebugAllocator(.{}) = .{};

// CORRECT
var gpa: std.heap.DebugAllocator(.{}) = .init;

Debugging Memory Issues

Leak Detection

test "check for leaks" {
    // std.testing.allocator automatically reports leaks
    var list: std.ArrayList(u32) = .empty;
    try list.append(std.testing.allocator, 42);
    // Missing: list.deinit(std.testing.allocator);
    // Test will FAIL with leak report
}

DebugAllocator in Main

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer {
        const check = gpa.deinit();
        if (check == .leak) @panic("memory leak");
    }
    try run(gpa.allocator());
}

Implementing Custom Allocators

Allocators implement std.mem.Allocator.VTable:

const MyAllocator = struct {
    // State fields here

    pub fn allocator(self: *MyAllocator) std.mem.Allocator {
        return .{
            .ptr = self,
            .vtable = &vtable,
        };
    }

    const vtable: std.mem.Allocator.VTable = .{
        .alloc = alloc,
        .resize = resize,
        .remap = remap,
        .free = free,
    };

    fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ra: usize) ?[*]u8 {
        const self: *MyAllocator = @ptrCast(@alignCast(ctx));
        _ = ra;  // return address for stack traces
        // Return aligned pointer or null
    }

    fn resize(ctx: *anyopaque, buf: []u8, alignment: std.mem.Alignment, new_len: usize, ra: usize) bool {
        // Return true if resize succeeded in-place
    }

    fn remap(ctx: *anyopaque, buf: []u8, alignment: std.mem.Alignment, new_len: usize, ra: usize) ?[*]u8 {
        // Return new pointer (may move) or null if can't remap
    }

    fn free(ctx: *anyopaque, buf: []u8, alignment: std.mem.Alignment, ra: usize) void {
        // Free memory
    }
};

Validation wrapper - for testing allocators:

var my_alloc = MyAllocator.init();
var validated = std.mem.validationWrap(my_alloc.allocator());
const allocator = validated.allocator();  // Adds safety checks