Zig Allocators Explained

A dive into how Zig handles memory allocation and how you can use Allocators to control it.

What is zig

Zig is a relatively new sys programming language that serves as an alternative to C. It is designed to be simple straightforward and modern, with relatively few footguns when compared to something like C++ or Rust. It combines the niceties of Go with some of the cooler aspects of Rust and C. It's been my favorite language for quite some time now and I've been using it a lot in my hobby projects on my Raspberry Pi and home servers.

How does Zig handle Memory Allocation?

Zig has a very unique way of handling memory allocation which is drastically different from other languages such as Rust or C++ which give you a lot of control over memory allocation but still do a fair bit of behind the scenes allocation for you. Zig on the other hand lets you fully control how memory is allocated and deallocated via the use of Allocators.

What are Allocators?

Essentially an allocator is a struct that implements a set of functions that allow you to allocate and deallocate memory. Basically every time you declare a variable or function in any language, you are allocating memory on your system to store that variable or function, in many other languages including C (malloc) that is automatically done for you, but this is where Zig differs, it requires you to manually allocate and deallocate memory. You can do so by using Allocators, out of the box zig comes with a few standard allocators.

What Allocators does Zig come with?

  • std.heap.page_allocator: the most basic allocator, whenever it makes an allocation it will ask the OS for entire pages of memory. This is of course fairly inefficient and slow.

  • std.heap.FixedBufferAllocator: allocates memory into a fixed buffer and does not make any heap allocations. Very performant but requires you to know the size of the buffer ahead of time.

  • std.heap.ArenaAllocator: takes in a child allocator and allocates memory multiply times but only free it once. This is useful for when you wish to allocate multiple things and free them all at once since they are all allocated in the same memory block.

  • std.heap.GeneralPurposeAllocator: a general purpose safe allocator that can be used for most cases if you are just starting out your journey with zig, just experimenting or unsure about what allocator to use. This is not the most performant or efficient allocator but it can be made much faster than the page allocator by turning off features such as thread safety and other safety checks(use only if you know what you are doing).

  • std.heap.c_allocator: a very high performance allocator with limited to no safety features, requires you to link LibC to your project. It will essentially call malloc and free from the C standard library, which basically removes the main benefit of using zig allocators which is no behind the scenes allocations and deallocations.

How do I use Allocators in Zig?

Let's start with the simplest allocator, the std.heap.page_allocator. To use it you simply need to import and instantiate it like so:

const std = @import("std");

pub fn main() !void {
  const allocator = std.heap.page_allocator;

  const memory = try allocator.alloc(u8, 100);
  defer allocator.free(memory);
}

This will allocate 100 bytes of memory using the page allocator and then free it when the function exits. Couple of super cool things to note here:

  • The defer keyword is used to defer the execution of a statement until the end of the current scope. This is super useful for cleaning up resources like memory or file handles.

  • the ! operator is used to denote that a function can return an error, this is a very common pattern in zig and is used to handle errors in a very clean and straightforward way.

  • The u8 is the type of the memory we are allocating, in this case it's an unsigned 8 bit integer, you can change this to any type you want to allocate.

  • try may seem familiar to you from other languages that use a try catch pattern, but do not confuse it with that it is completely different, a try in zig will return an error upward to the caller of the function this is where the ! operator comes in if the type of the function is prefixed with a ! it means that it can return an error.

Zig Fixed Buffer Allocator Example

const std = @import("std");

pub fn main() !void {
  var buffer: [100]u8 = undefined;
  var fixedBufferAllocator = std.heap.FixedBufferAllocator.init(&buffer);

  const memory = try fixedBufferAllocator.alloc(u8, 100);
  defer fixedBufferAllocator.free(memory);
}

Notice how the above example differs from the previous one, in this case we need to first declare and initialize a buffer of a fixed size and then pass a reference to that buffer to the FixedBufferAllocator instead of just telling the allocator how much memory we want to allocate. This is because the FixedBufferAllocator does not allocate memory on the heap, it allocates memory on the stack.

Zig Arena Allocator Example

const std = @import("std");

pub fn main() !void {
  var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
  defer arena.deinit();
  const allocator = arena.allocator;

  const memory1 = try allocator.alloc(u8, 8);
  const memory2 = try allocator.alloc(u8, 16);
  const memory3 = try allocator.alloc(u8, 32);

}

In this example we first initialize an ArenaAllocator with a child allocator, in this case the page allocator. We then get the allocator from the arena and use it to allocate memory. Notice how we only call deinit on the arena and not on the allocator, this is because the arena will automatically free all memory allocated by the allocator when it is deinitialized and because the defer statement will automatically call the deinit function when the function exits we do not need to call it manually.

Zig General Purpose Allocator Example

const std = @import("std");

pub fn main() !void {
  const allocator = std.heap.GeneralPurposeAllocator.init(std.heap.page_allocator);

  const memory = try allocator.alloc(u8, 100);
  defer allocator.free(memory);
}

Simple and straightforward, just like the page allocator but with a few more features and safety checks.

So what's the point of all this?

Well the main point of using allocators in zig is to give you full control over how your memory is allocated and deallocated, this is similar to how rust forces you to be conscious of memory allocations by freeing up memory when it is no longer needed whenever it exits the scope, unless you use one of the many mechanisms it has to prevent that from happening. Zig kinda flips the script and does nothing for you under the hood but also forces you to be conscious of memory allocations by making you do it yourself however in an easy and straightforward way.

This gives it the memory safety of Rust but with the simplicity of something like C or Go. It's a very unique and interesting approach to memory management and one of the many reasons why I love Zig so much.

Conclusion

I hope this article has given you a good understanding of what Allocators are and how Zig uses them to handle memory allocation. If you have any questions or feedback feel free to reach out via twitter. I'm always happy to help out, receive feedback, or just chat about this kind of stuff in general. Thanks for reading and happy coding!