zig / under the hood
DUE 000 DECK 000 STREAK 0d LESSONS 0/14 saved in this browser only
0x0B
Part II · Zig

Allocators in Zig

No hidden allocations: every function that allocates says so, and you choose the strategy.

The question every allocation raises

In the heap lesson you saw that heap memory comes from an allocator, and that different allocators (bump, free list, pool) suit different jobs. In C, malloc is one global allocator that any function can call at any time, so you can’t tell from a function’s signature whether it allocates, or how. Zig’s answer is: there is no global allocator. Any function that needs heap memory takes an allocator as a parameter. That has real consequences:

  • You can see from a signature whether a function might allocate, and therefore might fail with error.OutOfMemory.
  • The caller chooses the strategy. The same parsing function can use an arena in a web server, a fixed buffer on a microcontroller, and a leak-detecting allocator in tests.
  • Running out of memory is an ordinary error you can handle, not a crash.

Using an allocator

An allocator is a value of type std.mem.Allocator, usually named gpa or allocator. The main operations:

// many items: returns a slice
const buf = try gpa.alloc(u8, 1024);
defer gpa.free(buf);

// one item: returns a pointer
const p = try gpa.create(Point);
defer gpa.destroy(p);
p.* = .{ .x = 1, .y = 2 };

// copy existing data into new memory
const copy = try gpa.dupe(u8, "hello");
defer gpa.free(copy);

Every call can fail with error.OutOfMemory, hence the try. And every allocation is followed immediately by the defer that frees it, the pattern from the last lesson. Pair them correctly: alloc with free, create with destroy. The memory you get back is not zeroed. Its contents are undefined until you write to them.

How the interface works

std.mem.Allocator is a small struct with two fields: a pointer to the real allocator’s state (with its type hidden), and a pointer to a table of functions, called a vtable, that implement allocate, free and resize for that particular allocator. When you call gpa.alloc(...), it calls through the table. It’s the same idea as an interface in TypeScript, done by hand: anything that can produce one of these structs can be used wherever an allocator is expected.

The allocators you’ll reach for

| Allocator | Use it for | std.heap.DebugAllocator | development: catches leaks, double frees and use-after-free (called GeneralPurposeAllocator before 0.14) | std.heap.ArenaAllocator | many allocations freed together (a request, a frame, a parse) | std.heap.FixedBufferAllocator | allocating from a buffer you provide, such as a stack array; no syscalls | std.heap.page_allocator | whole pages straight from the OS; a backing allocator for others | std.heap.c_allocator | libc’s malloc, when you link libc | std.testing.allocator | tests; fails the test if anything leaks

A complete program

const std = @import("std");

pub fn main() !void {
    var debug: std.heap.DebugAllocator(.{}) = .init;
    defer _ = debug.deinit(); // reports leaks on exit
    const gpa = debug.allocator();

    const nums = try gpa.alloc(u32, 8);
    defer gpa.free(nums);
    for (nums, 0..) |*n, i| n.* = @intCast(i * i);

    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit(); // frees every arena allocation at once
    const scratch = arena.allocator();
    const msg = try std.fmt.allocPrint(scratch, "last = {d}", .{nums[7]});
    std.debug.print("{s}\n", .{msg});
}

Step by step:

  1. DebugAllocator is the allocator for development. .init creates it, and defer _ = debug.deinit() checks for leaks when main ends.
  2. debug.allocator() gives you the std.mem.Allocator interface to pass around.
  3. We allocate 8 u32s and immediately defer freeing them.
  4. An ArenaAllocator is built on top of another allocator. It grabs big chunks from gpa and hands out pieces bump-style. Nothing from the arena is freed individually; arena.deinit() releases everything at once.
  5. std.fmt.allocPrint formats a string into newly allocated memory. Since we gave it the arena, we don’t need a separate free for msg. Now delete the line defer gpa.free(nums); and run it again. The DebugAllocator prints a leak report with a stack trace of the line that made the allocation. That one feature makes leaks easy to track down.

Testing for leaks

In a test block, use std.testing.allocator. If anything you allocated isn’t freed by the end of the test, the test fails:

test "no leaks" {
    const gpa = std.testing.allocator;
    const buf = try gpa.alloc(u8, 16);
    defer gpa.free(buf); // remove this line and the test fails
}

Choosing an allocator

A simple rule of thumb:

  • In main, create a DebugAllocator while developing (you can swap in a faster one for release builds).
  • For work with a clear end (one request, one frame, loading one file), put an arena on top and free it all at the end.
  • In tests, use std.testing.allocator.
  • Library functions never choose an allocator; they take a std.mem.Allocator parameter.

Containers take the allocator on each call

Since Zig 0.15, std.ArrayList doesn’t store its allocator. You pass it to every call that might need memory:

var list: std.ArrayList(u8) = .empty;
defer list.deinit(gpa);
try list.append(gpa, 'z');

.empty starts the list with no memory at all. append might need to grow it, so it needs the allocator; always pass the same one for the same list. If a tutorial shows std.ArrayList(u8).init(allocator), it was written for Zig 0.14 or earlier.

Recap

  • No global allocator: functions that allocate take a std.mem.Allocator.
  • alloc/free for slices, create/destroy for single items; new memory isn’t zeroed.
  • DebugAllocator in development, arenas for shared lifetimes, std.testing.allocator in tests.
  • Allocate, then defer the free on the next line.
  • Allocation can fail with error.OutOfMemory, an ordinary error.
Try it

Write your own allocator: wrap another std.mem.Allocator and count the bytes allocated and freed. Start by reading how std.heap.FixedBufferAllocator builds its vtable in your installed version of std.

14 flashcards for this lesson

not started
See the questions
  1. Why does Zig pass allocators explicitly?
  2. What is std.mem.Allocator internally?
  3. alloc/free vs create/destroy?
  4. When is an ArenaAllocator a good fit?
  5. What does FixedBufferAllocator do?
  6. Which allocator makes a test fail on a leak?
  7. What was DebugAllocator called before Zig 0.14?
  8. How do you append to a Zig 0.15 std.ArrayList?
  9. Is memory from gpa.alloc zeroed?
  10. Why can an ArenaAllocator skip individual frees?
  11. Which error can every allocation return?
  12. Is memory from gpa.alloc zeroed?
  13. Why can an ArenaAllocator skip individual frees?
  14. Which error can every allocation return?