I got involved to multiple debates regarding recent events, then I realized that some people just came to this forum to watch this language development, and judge it by its cover, without actually using it. After judging, unfortunately, they joined the debates, and provided others with inaccurate information. Also I realized that many serious users still complain about the memory management and potential leaking, especially spamming defer free() and ArenaAllocator growing… And very unfortunately, some of them even suggest the language to implement strict rule (like the one we can’t call the name), which nobody would love it… I wrote a very long explanation before in some replies, but many people asked me for examples or further explanations…

Let’s use a simple example here. Assuming that we are developing a game engine, which will load 3D objects into the game. First we have this struct.

const ModelData = struct {
    name: []u8,
    model: []u8,
};

Each model has a name and a model, the name and its length are unknown, and the model is from binary format, length unknown, so we use u8 here.

To give the ArenaAllocator a more important role here, we create a very complicated struct, but it’s very common in game development:

const ModelDataManager = struct {
    aa: *ArenaAllocator,
    io: Io,
    data_list: std.ArrayListUnmanaged(std.MultiArrayList(ModelData)),
    name_map: std.StringHashMapUnmanaged(usize),
}

If multiple models share the same name, they will be group into one object. When users searching the name, they should get the object, and an object contains many models. We have a nested multi array list with a string hashmap here. Apparently, they are all on heap, so they will need an allocator. We know that to perform the game, all those data need to be alive most of time together, so we use an ArenaAllocator to group them together. If the Arena has not yet been deinited, all the data is alive.

We are going to make an init and deinit function:

    pub fn init(allocator: Allocator, io: Io) !ModelDataManager {
        const aa = try allocator.create(ArenaAllocator);
        aa.* = ArenaAllocator.init(allocator);
        const data_list = std.ArrayListUnmanaged(std.MultiArrayList(
            ModelData,
        ));
        const name_map = std.StringHashMapUnmanaged(usize);

        return .{
            .aa = aa,
            .io = io,
            .data_list = data_list.empty,
            .name_map = name_map.empty,
        };
    }

    pub fn deinit(self: *ModelDataManager) void {
        const child_allocator = self.aa.child_allocator;
        self.aa.deinit();
        child_allocator.destroy(self.aa);
    }

It is very simple, and it has to be in this way. You cannot copy the Arena itself into the field of the struct. The Arena has to be alive in heap, not stack, and the field has to hold the Arena’s pointer. I don’t explain this here. There are a lot of articles and examples explain this.

Let’s continue. We make a simple function to “load the data”. Well, here, to be simplify, I use a randomizer the to simulate the process of loading…

    pub fn loadData(
        self: *ModelDataManager,
    ) !void {
        defer std.debug.print("Loading is completed.\n", .{});
        var i: usize = 0;
        while (i < 100) {
            const name = try self.aa.allocator().alloc(u8, 16);
            std.Io.random(self.io, name);
            var md_multi_array = std.MultiArrayList(ModelData).empty;
            var j: usize = 0;
            while (j < 1000) {
                var model: []u8 = undefined;
                if (@mod(j, 9) == 0) {
                    model = try self.aa.allocator().alloc(u8, 600000);
                    std.Io.random(self.io, model);
                } else {
                    model = try self.aa.allocator().alloc(u8, 40000);
                    std.Io.random(self.io, model);
                }
                const model_duped = try std.mem.Allocator.dupe(
                    self.aa.allocator(),
                    u8,
                    model[0..10000],
                );
                const md: ModelData = .{
                    .name = name,
                    .model = model_duped,
                };
                try md_multi_array.append(self.aa.allocator(), md);
                j += 1;
            }
            try self.data_list.append(self.aa.allocator(), md_multi_array);
            try self.name_map.put(
                self.aa.allocator(),
                name,
                self.data_list.items.len - 1,
            );
            i += 1;
        }
    }

For simplifying purpose, in this function, I assume that every name has 16 bytes, and every object has 1000 models. most models are 40000 bytes, some are 600000 bytes.

Now we add a main function and some imports on the top:

const std = @import("std");
const Io = std.Io;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;

pub fn main() !void {
    var sa = std.heap.SafeAllocator.init(std.heap.page_allocator, .{});
    defer {
        const leaks = sa.deinit();
        if (leaks > 0) {
            std.debug.print("This program has {d} memory leaks.\n", .{leaks});
        }
    }
    const allocator = sa.allocator();
    var threaded: std.Io.Threaded = .init(allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    var mdm = try ModelDataManager.init(allocator, io);
    try mdm.loadData();
    defer mdm.deinit();
}

Now we run the codes… It costs 5.2 GB memory… Something is wrong in this codes apparently. Let’s assume that every binary model contains something else, but only the from 0 to 10000 are the real model. We are not going to use other things in the game, as our game doesn’t support those things, like high poly modifier, complicated and non-instant shaders etc.. So we duplicated the 0 to 10000 bytes into our list, but we never freed all those model binaries.

What if we add a defer free on the model?

            while (j < 1000) {
                var model: []u8 = undefined;
                defer self.aa.allocator().free(model);
                if (@mod(j, 301) == 0) {

This doesn’t work, because ArenaAllocator.allocator().free() only works when using it in an exact reversed sequence to the last allocation, otherwise, it will not do anything.

What we can do is that we can create another ArenaAllocator to only handle the model loading while (j < 1000) loop. We change the codes like this:

            var ld_aa = ArenaAllocator.init(self.aa.allocator());
            defer ld_aa.deinit();
            var j: usize = 0;
            while (j < 1000) {
                defer _ = ld_aa.reset(.retain_capacity);
                var model: []u8 = undefined;
                if (@mod(j, 301) == 0) {
                    model = try ld_aa.allocator().alloc(u8, 600000);
                    std.Io.random(self.io, model);
                } else {
                    model = try ld_aa.allocator().alloc(u8, 40000);
                    std.Io.random(self.io, model);
                }
                const model_duped = try std.mem.Allocator.dupe(
                    self.aa.allocator(),
                    u8,
                    model[0..10000],
                );
                const md: ModelData = .{
                    .name = name,
                    .model = model_duped,
                };
                try md_multi_array.append(self.aa.allocator(), md);
                j += 1;
            }

Remember, the dupe and the md_multi_array.append still need our original Arena, because those are what we need for the rest of the game. Whenever the struct alive, those need to be alive. Only the model loading uses the loading arena (we named it ld_aa here).

The defer _ = ld_aa.reset(.retain_capacity); will give you a very fast speed, because during the 1000 loops, this part of memory will never allocate again.

Now we run the codes. The memory reduces to 1.1 GB!

Now we have another problem. The defer ld_aa.deinit(); is an no-op. The game will forever hold some memory that will never be used, unless you will use this struct to do something else, or you add more model later. The reason is that, the child allocator of the ld_aa is our original ArenaAllocator, which means that when the ld_aa was deinit(), it will only return the memory back to the original ArenaAllocator. It will not go back to the operating system.

Normally this is not a big problem because piratically, you are very possible to reuse the memory in the game, and it makes the game faster without doing syscall for page allocating. However, if you really want to fine tune your memory, it is very possible and very convenient. All we need to do is to change the child_allocator of ld_aa to be any other allocator that is not an ArenaAllocator, in this example, to be simplified, I just use page_allocator. If you want more assured for safety, you can create another local SafeAllocator too.

var ld_aa = ArenaAllocator.init(std.heap.page_allocator);

Now we run the codes again. It costs 1.0 GB. It releases 100 MB back to the system.

In summary, we created an ArenaAllocator to handle a complicated data structure, which includes a lot of strings, binary data, amd multiple arrays and a hashmap. If we use single operating allocator such as page allocator or smp allocator, we will need a lot of defer free. By using the ArenaAllocator, we significantly reduce the defer free/deinit. What I wish you to take from this article is that ArenaAllocator is the magic of this language. It won’t leak, it won’t grow. Just don’t use a single ArenaAllocator to handle everything. It is just a tool to group memory operations together. If you want to fine tune your memory, you can always init another ArenaAllocator, nested or not depending on your situation, to operate some local specific tasks.

The full codes in this article is here. You can use Zig 0.17 master version to play around.
main.zig (3.2 KB)