Allocators in Zig: Which One and When
In Zig the convention is that every function that needs heap memory takes an allocator as a parameter and you decide which one to give it. The language does not enforce this and any function can use a global allocator such as std.heap.page_allocator directly, but the Standard Library follows this convention and it is a good idea to follow it in your own code too. When I started with Zig this was my first real question, because the Standard Library has about ten allocators. In this post I put the four that I use in one program and then explain when I use each one.
const std = @import("std");
fn fill(allocator: std.mem.Allocator, name: []const u8) !void {
const buf = try allocator.alloc(u8, 32);
defer allocator.free(buf);
@memset(buf, 'z');
std.debug.print("{s}: allocated {d} bytes\n", .{ name, buf.len });
}
pub fn main(init: std.process.Init) !void {
// 1. The general purpose allocator of the process
try fill(init.gpa, "init.gpa");
// 2. The arena of the process: no free() needed
const arena = init.arena.allocator();
const greeting = try std.fmt.allocPrint(arena, "Hello from the {s}", .{"process arena"});
std.debug.print("{s}\n", .{greeting});
// 3. An arena of your own
var my_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer my_arena.deinit();
const temp = my_arena.allocator();
var total: usize = 0;
for (0..1000) |i| {
const s = try std.fmt.allocPrint(temp, "item {d}", .{i});
total += s.len;
}
std.debug.print("my arena: {d} bytes in 1000 strings, freed all at once\n", .{total});
// 4. A fixed buffer on the stack
var buffer: [256]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const fixed = fba.allocator();
const first = try fixed.alloc(u8, 200);
std.debug.print("fixed buffer: got {d} bytes\n", .{first.len});
const second = fixed.alloc(u8, 200) catch |err| {
std.debug.print("fixed buffer: second request failed with {s}\n", .{@errorName(err)});
return;
};
std.debug.print("fixed buffer: got {d} more bytes\n", .{second.len});
}
The fill() function takes a std.mem.Allocator, which is the interface that every allocator implements. The function does not know which allocator it gets. You write functions that take an Allocator and you make the choice at the call site.
The init.gpa field is the general purpose allocator that the Zig runtime creates for the process. What is behind it depends on how you build. Zig has four build modes that you choose with the -O option. The debug mode is the default. The safe mode optimizes the code and keeps the safety checks. The fast mode optimizes for speed and the small mode for size and both of them turn the safety checks off. In the debug and safe modes init.gpa is std.heap.SafeAllocator, which records every allocation and reports leaks and double frees when the program exits. Every alloc() needs a matching free(), which is what the defer in fill() does.
In the fast and small modes the runtime drops these checks and picks an allocator that is built for speed. If the program links libc it is std.heap.c_allocator, which is a wrapper around malloc() and free(). If it does not, it is std.heap.SmpAllocator, which is written in Zig and gives every thread its own free list, so threads rarely wait for each other. Which of the two you get depends mostly on the operating system. On Linux Zig talks to the kernel directly, so a program does not link libc unless you ask for it with -lc or with .link_libc = true in build.zig. On macOS Apple does not keep the system call interface stable and the only supported way to talk to the kernel is through libSystem, which is the C library of macOS. So Zig links libSystem into every macOS program, which means that on a Mac init.gpa in the fast and small modes is always the C allocator. Neither of them checks for leaks.
The init.arena field is an arena that lives for the whole process. The string that allocPrint() returns is never freed by us. The runtime frees the whole arena at exit. I use it for things that live as long as the program does, such as command line arguments, configuration and a file that you read once and keep.
An arena of your own is built with ArenaAllocator.init() on top of another allocator, here std.heap.page_allocator, which asks the operating system for memory in whole pages. The program creates 1000 strings and never frees one of them. The single deinit() call frees all of them at once. I use it when I make many small allocations that I can free at the same time, for example while parsing a file. The page_allocator on its own is rarely the right choice for small objects, because every request costs at least one page, which is 16 KB on Apple Silicon.
The FixedBufferAllocator type hands out memory from a buffer that you give it, here 256 bytes on the stack. It never asks the operating system for more memory and it cannot grow past the end of the buffer. The first request for 200 bytes succeeds and the second one fails with error.OutOfMemory. I use it when I know the maximum amount of memory that a piece of code needs or when there is no heap at all.
Save the code as allocators.zig and try it:
$ zig version
0.17.0
$ zig run allocators.zig
init.gpa: allocated 32 bytes
Hello from the process arena
my arena: 7890 bytes in 1000 strings, freed all at once
fixed buffer: got 200 bytes
fixed buffer: second request failed with OutOfMemory
Finding leaks Link to heading
While I develop I use init.gpa because it tells me when I forget a free(). This program allocates 100 bytes and never frees them:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const data = try init.gpa.alloc(u8, 100);
std.debug.print("Allocated {d} bytes and forgot to free them\n", .{data.len});
}
Save it as leak.zig and run it in debug mode, which is the default:
$ zig run leak.zig
Allocated 100 bytes and forgot to free them
error(SafeAllocator): leaked [addr: 1012b5fb8, len: 100 (0x64) align: 1] allocated at:
leak.zig:4:36: 0x100d435c3 in main (leak)
const data = try init.gpa.alloc(u8, 100);
^
$ echo $?
0
The report comes with the line of the allocation. If you run it with -O fast there is no report, because the fast allocators do not keep track of anything. So I always develop in debug mode.
Notice that the program leaked memory and still exited with 0. The runtime checks for leaks after main() returns and prints what it finds, but it does not change the exit code. A script or a CI job that only looks at the exit code will never notice the leak. If you want a leak to count as a failure, use tests. Inside test blocks, std.testing.allocator does the same checks but fails the test and zig test exits with 1. You can also create a std.heap.SafeAllocator of your own instead of using init.gpa. Its deinit() method returns the number of leaks, so you can return a nonzero exit code from main() when that number is not 0.
Which one Link to heading
- The
init.gpafield is the default. Use it for objects with their own lifetime and for data structures such asArrayListand hash maps. - The allocator that
init.arena.allocator()returns is for anything that must live until the process exits. - Your own
ArenaAllocatoris for many allocations that you free with a singledeinit()call. - The
FixedBufferAllocatortype is for a known upper bound, code with no heap, or a hard limit. - The page allocator,
std.heap.page_allocator, is the backing allocator for an arena, not an allocator for small objects. - The C allocator,
std.heap.c_allocator, is for programs that link libc and hand memory to C code. - The testing allocator,
std.testing.allocator, is fortestblocks.
If you are not sure, start with init.gpa and a defer for the free(). Switch to an arena when you notice that you write the same defer ten times for objects that are freed at the same time.
Happy coding in Zig!
Want to learn Zig? Look at my book Systems Programming with Zig.