I’m relatively new to game dev, but I had a question regarding the calls to [some struct].deinit() everywhere. Are these doing anything? I typically don’t bother with destructor calls if the struct lifetime is the same as the program lifetime (as most of these calls seem to be) Especially with arenas, I usually just call reset() or resetRetainCapacity() instead of deinit().
var root_system_allocator = std.heap.GeneralPurposeAllocator(.{}){};
var arena_system_lifetime = std.heap.ArenaAllocator.init(root_system_allocator.allocator());
var arena_system_update = std.heap.ArenaAllocator.init(root_system_allocator.allocator());
var arena_frame = std.heap.ArenaAllocator.init(root_system_allocator.allocator());
defer {
const check = root_system_allocator.deinit();
\_ = check; // autofix
// std.debug.assert(check == .ok);
}
// NOTE: the below calls seem superfluous since they'll just free
// things in the immediately cleared virtual address space. I think of
// these methods as specialized tools for embedded really, since most
// arenas will be reused or else program-permanent.
defer arena_system_lifetime.deinit();
defer arena_system_update.deinit();
defer arena_frame.deinit();
///
I’m wondering if there is something I’m missing here as a lot of projects I’ve been reviewing have these deinit() calls everywhere even though it’s basically deferred to the end of main (aka shouldn’t the OS handle it? seems like waste) Though I know a major design goal of Zig is portability, so it makes sense that they exist in the std, but that doesn’t explain why everyone is using them.
defer x.deinit() is pretty useful in the case where you’re calling a function which can error out, and you want to ensure that the memory is post-freed no matter how the function returns.
But, yeah, if it’s living at the root of your main module, deinitting a struct with heap-allocated memory probably isn’t doing much.
My guess is it’s intended for weird edge cases like people using your program as a build system dependency and importing your (always-public) main function (in which case, the deinit will ensure correct operation without memory leaks even if their process continues running after calling your main function and/or they call it multiple times).
The first case makes some sense, even if it’s usually avoidable (allocate upfront, then do work). The second case is a bit outlandish (especially for a game!). Are there any examples of this occurring in an actual program? I can understand if you wanted to distribute your main function as a lib that can be called, although in that case I would generally prefer to have an allocator passed in explicitly so the user can decide how/when to free. I was more interested in whether or not anyone made an explicit decision to free or clean up some used resource in this case, or if it was just a force of habit.
This is really part of a broader conundrum of interop between individual and group style allocation paradigms. When using arenas for example, if i’m writing code that still has to call free() on everything created, that defeats the purpose of using an arena in the first place! And then if I’m using init/deinit, or malloc/free, or new/delete or whatever, I can’t use a function that assumes an arena, since it won’t provide a mechanism for exposing the objects to be freed. Just something I’m puzzling through.
I appreciate the rule of thumb, but this is deeply unsatisfying to me as an engineering solution. If we wanted to really elaborate the hygiene metaphor, this is more like wiping your counters down before you vaporize your house.
Indicate that we are now terminating with a successful exit code. In debug builds, this is a no-op, so that the calling code’s cleanup mechanisms are tested and so that external tools that check for resource leaks can be accurate. In release builds, this calls exit(0), and does not return.
So if you use external tooling that checks for resource leaks, then having a way to explicitly/manually free can be beneficial and that would still allow you to directly exit in non-debug builds without actually calling deinit, by directly calling cleanExit (which calls exit(0)), basically just giving you more options. Of course you can decide that that doesn’t matter for your use case.
I think it is largely a matter of deciding whether a piece of code requires arena usage or not.
By using deinit even when not technically needed because the process will exit before that cleanup happens, it’s useful to still do full resource management for these reasons:
If you move that code into a library, or somewhere else, it won’t cause bugs. In other words it maintains the property of being copy pastable.
When using tools such as std.heap.DebugAllocator or Valgrind to detect resource management leaks, or the upcoming enhancement to std.Io.Threaded to detect file descriptor leaks, those tools will not fire false positives.
However it’s good to skip the pointless work when the OS is going to clean up anyway. So that’s why std.process.cleanExit exists. In Debug mode it simply returns so that your resource cleanup paths are checked, but in other optimization modes it calls exit(0).
The recent(ish) switch from managed-by-default to unmanaged-by-default data structures across the std implies to me (and i agree btw) that allocation strategies aren’t really portable (in total). Allocating new stuff is generally the same across paradigms but the difference of free-ing styles makes it hard to have a function that works with, say, a scratch arena, and also works with explicit deinit() calls. If you’re using a scratch arena, you just reset/deinit the entire arena when your function has finished its work.
The connection back to your post is that I don’t understand how std.process.cleanExit would work for arena paradigms. I wrote up a quick example of how I generally allocate things (simplified) and was wondering how std.process.cleanExit would help find leaks in this paradigm, especially since there’s never any need to deinit the scratch arena (presuming you can reuse it wherever you need it, if it’s local to some sub function that’s a different story but i’ve never done that personally). Curious to know your thoughts on this as I love discussing different approaches to these problems.
const std = @import("std");
const InputData = struct {
foo_count: usize = 0,
};
const IntermediateData = struct {
foo_list: std.ArrayList(u8) = .empty,
};
// Assume this is size unknown for sake of example
const OutputData = struct {
foo_sum: usize = 0,
// a slice of len `foo_sum` of increasing integers, idk
bar_list: []u8 = &.{},
};
pub fn work(
result_arena: std.heap.ArenaAllocator,
scratch_arena: std.heap.ArenaAllocator,
input: InputData,
) *OutputData {
defer scratch_arena.reset(.retain_capacity);
const scratch_allocator = scratch_arena.allocator();
const result_allocator = result_arena.allocator();
// digest the input data (temporary allocation, scratch lifetime)
const intermediate_data: IntermediateData = digest(scratch_allocator, input);
// do intermediate work
const output_data: *OutputData = calculateOutput(result_allocator, intermediate_data);
// return output data
return &output_data;
}
fn digest(scratch: std.mem.Allocator, input: InputData) IntermediateData {
var result: IntermediateData = .{};
const foo_list: std.ArrayList(u8) = .initCapacity(scratch, input.foo_count) catch .empty;
result.foo_list = foo_list;
return result;
}
fn calculateOutput(allocator: std.mem.Allocator, intermediate: IntermediateData) OutputData {
var result: OutputData = .{};
for (intermediate.foo_list.items) |foo| {
result.foo_sum += foo;
}
result.bar_list = allocator.alloc(u8, result.foo_sum) catch |err| blk: {
std.log.err("Unable to allocate bar_list: {s}", .{err});
break :blk result.bar_list;
};
return result;
}
I don’t really follow. If you know you are passing an arena allocator, you can skip deinit functions that are known to only free memory from the passed in allocator.
If you are writing a function that accepts an Allocator you can choose whether it must be an arena allocator in which case you can leak everything, or you can choose to support all Allocator implementations by freeing as necessary. This works fine also for arena allocators.
Even arena allocators can reclaim memory sometimes on free, although it’s not necessary.
I think I tend to view arenas as a specific allocator for a specific job, where the allocations can be grouped, and thus simplified. It’s not a question of supporting both, because in the case where allocations can be grouped, it’s strictly better to do so for both performance and cognitive overhead. If I have many objects with separate lifetimes, I can’t use an arena anyway, and so it wouldn’t really make sense to accept one.
I struggle to think of a situation where I want scratch allocations for some defined lifetime yet I also want to support individual object management, it just seems strictly worse. Is this idea of multiple allocation strategy support limited (or at least most relevant to) public API design in shared libraries? While the benefit of parameterized arenas/pools/whatever is immediately obvious to me, I think I’m fundamentally missing the value of allocation-strategy-agnostic functions in the programs I’m writing. Apologies for the rambling…
This is confusing to me, so I’d love to hear why you think this.
My understanding was that unmanaged containers
are two pointers smaller in memory
sit together in bigger structs more nicely (less duplication of allocator fields)
are a little more likely to let LLVM devirtualize the Allocator.VTable calls, since an allocator.vtable passed as a parameter is const, while one sitting as a field of a *T is mutable.
I don’t disagree that one quickly discovers, upon being prompted to think for five seconds about allocation, that the most naive method is suboptimal. to me, this is the runaway success of Zig’s std.mem.Allocator: the fact that it prompts programmers, at scale, to think for five seconds about allocation.
What would perhaps help is that the example in the original post, with arena_system_lifetime is completely different from your second digest example. Although both have substring “arena” in them, this is “false” sharing.
What happens in the first example is that you are allocating some objects using a general purpose allocator. Although the objects live as long as the program, it is advisable to explicitly free them, so that you can assert the absence of leaks assert(check == .ok). You really do not care about freeing those three objects, that is true. But you do care about freeing anything you allocate in a loop, and, if you intentionally leak your permanent objects, than those intentional leaks would mask any unintentional leaks.
Still, this isn’t fully optimal because, once you convince yourself that there are no leaks, you want to just abruptly tear down the process, to save time and electricity. And that’s where .cleanExit comes from.
In that specific example, the “permanent object” happens to be an arena, but that really isn’t germane to the example.
The second example is different. While in the first example you used gpa (general purpose allocator), in the second example you use arena. It’s the contract of arena that you don’t need to free individual objects, and that they’ll be freed in bulk.
That’s why I am advocating for using gpa: Allocator and arena: Allocator (and, depending on circumstances, subdividing the arena use case into scratch and perm), this allows you to regain copy-paste safety: if you paste code with arena name into the code with gpa, you’ll have to fix up the names and add missing defers.
Those technical details are news to me, and definitely make a ton of sense. I’ve only learned about the deprecation through reading comments in std, so I was extrapolating from that. When I’ve used libraries that allocate memory internally, it always frustrates me because it makes usage very difficult; when the allocation is parameterized, it’s literally trivial and I have complete control, no compromises really.
Passing the allocator as a parameter always makes for better API design IME (+perf/const benefits you noted)
Imagine a scenario where you need to adjust the memory layout for whatever reason (avoiding copies, cache locality, centralizing frees, etc) but the library doesn’t give you any access to such details. That’s why I assumed the explicit requirement of allocator passing rather than embedding was a style/architecture choice to encourage these designs.
All excellent points and I will steal those style choices. I do wonder if this style of leak checking works for arenas though, as say in the digest code I put above, if I forget to reset, then deinit at the end of the program (trivial to do, just set it up when you first start writing your program), would leaks even be detected? What does it mean to leak from an arena by not calling reset?
The arena contains the heap allocations that have been made by that arena. When you call reset it frees those heap allocations, in a manner that depends on the setting of ResetMode.
I meant more this type of usage, where you’re reusing an arena in a loop, and you don’t want to free the entire arena and pay the OS cost to allocate fresh pages, so you just reset. My intuition is that this leak will not be detected, even though it technically leaks between loop iterations. Imagine you’re parsing many json objects from a file but only need to collect some subset of the data permanently, you could use this scratch arena to allocate the temporary structs, then reuse the same memory page by calling arena.reset(.retain_capacity);
const std = @import("std");
pub fn main(init: std.process.Init) !void {
// Prints to stderr, unbuffered, ignoring potential errors.
var arena: std.heap.ArenaAllocator = .init(init.gpa);
defer arena.deinit(); // uncomment to avoid leak
for (0..outer_loop) |_| {
const tmp = try arena.allocator().alloc(u8, 10);
for (0..tmp.len) |i| {
tmp[i] = @intCast(i + 96);
}
std.debug.print("{s}", .{tmp});
// uncomment to avoid 'temporary' leak
// arena.reset(.retain_capacity);
}
}
Yes, you can do that, but then you would reset this scratch arena every loop iteration. The other arena for the things you save (assuming you use an arena for that also) will also need to be reset when it is no longer needed, to avoid a leak.
arenas with program lifetime do not need to be freed, and cannot leak (in virtual memory systems)
arenas with temporary lifetimes can be appropriately deinitialized, and yet not be reset correctly, ‘leaking’ memory
It follows:
you cannot detect this type of leak in the manner described earlier in this thread (with std.process.cleanExit())
the leaks you can detect will never ever matter for the program, as to not be leaked, they must be freed at program close (which the OS handles)
Therefore:
There is no need to use arena.deinit() for program lifetimes.
It doesn’t necessarily follow, but might be true:
scratch arenas should be reused, likely for the duration of the program (to maintain memory performance with hot pages) and should also never be deinitialized, but only reset.
one should initialize scratch space at program startup, and pass it around, thus incurring OS kernel call penalties at most once (ideally)