Advanced Memory Managment in Zig.
const data = arena.allocator().dupe("
What does it mean to exist? Today, I am going to ask the zig compiler what it thinks of that question. What enriching perspectives will it share. What elusive and effusive extravagencies will it excrete? Let's jump into it!
Actually nevermind, I get a syntax error for putting that in my main.zig. I suppose we'll have to talk about something else instead.
What do you say to a discussion on the intracicies of memory managment, kiddo?
Yeah, I won't blame you if you're no longer interested.
That's what I'm actually going to talk about - so if you're interested, stay a while. If it's you're cup of tea, maybe go make a cup of tea instead.
You will be blown
I'm going to start by blowing your mind. In zig, everything is passed by reference by default.
This subtle choice sets the scene for zig's model of memory.
By that, I mean the compiler does not copy things you pass in by value. This is apparent if you try to modify a variable passed into a function. You are not allowed to modify it because it would break the semantics.
This is at a language design level - as usual the exact semantics will vary according to the compiler's optimization choices and the platform archtechture.
Try not to over think it, you just need to know that it is different to C in that respect. It is different in a lot of respects actually.
What is a Zig not?
There are more clones of C than you can poke a stick at. Not even the widest of sticks can reach them all [*1].
Every system's programming language inevitably ends up being defined by what it does differently to C[*3].
It is a how the world works, every new technology must compete with every old technology and the alternatives end up become part of its identity.
Systems programming languages are defined by what they are not.
C++ is not just C, but C and the chunks of vomitum that it has collected when congealed into its own identity.
Rust is C with C++ ergonomics, plus hindsight, plus a sprinkle of systematized pedantry.
Go is C with a specialization in distributed concurrency.
Where is Zig?
So Where does zig fall in this spectrum[*2] exactly?
The language design speaks to 3 points:
- more design consistency
- more explicitness
- more control
These 3 themes all intercept cleanly upon memory managment.
In zig, memory does exactly what you ask of it all the time. It may not be for everyone, but it's certainly for me.
The language design nudges you in the direction of self-documenting this decicively too.
Every function that allocates takes an allocator and returns an error type, again hinting that memory is being allocated and could possibly fail.
How is a Zig?
My favorite effect of this is how it encourages you to write your code just better. What's better about it? Let me explan.
The zig standard library has a handful of builtin composable allocators for you to choose from.
At any point that allocation becomes a speed bottleneck, you can modularly swap out for an allocator that better fits the nature of the problem you're solving.
For an advent of code problem I recently solved, I turned a sluggish solution that was hanging indefinately due to memory contention
into a snappy utility that was done in half a scond.
All I did was swap out the allocator to an ArenaAllocator in a hot path.
All done! Problem solved, now I can go onto the next thing and keep practicing my skills.
This is a microcosm of the way zig influences the way I program.
How does the cat Zig?
Let's zero in on some conventions I have settled into to make memory managment easier.
A useful convention I have settled on:
- use struct default fields wheneve possible.
struct { ok: bool = false; }
This means you don't have to think about constructing trivial types and they will most always be well-defined - Always set nullable types to null by default. For any other primitive types pick a sane default your code will respond straightforwardly to.
- when default fields aren't enough, or you should call deinit, only then have an init method on your type that initializes it in place.
For examplefn init(this: *@This()) void { this.* = @This() {} }.
In particular, the.* =allows you ensure all fields are defined correctly. - avoid using
undefined. Making a field nullable is often the better choice for debugability and safety.
It has a microscopic perfomance cost in most circumstances. - If you have to use
undefined, keep it to one place, and immediately overwrite it if possible.
If you pass around undefined values or put them in composite datastructures they are very easy to slip through and become non-deterministic bugs. - A deinit function should be used to free any allocated data. It should also be in-place.
It is rarely neccesary to set the left-over values to known defaults here.
Deinit is conventionally called in a defer block which means the lifetime of the variable is already over. - A deinit should always be paired with an init and vica versa.
It makes the intention clearer and fully contains the memory rules in one place. - Wherever possible, avoid allocation altogether.
In zig, there are many situations where you simply do not need it due to the language's flexibility.
For example, a list indexes into a list of structs is enough to treat those sort those structs as sorted without copying all the structs. - Reuse objects by re-assigning values where possible. This is as opposed to freeing, or moving them around.
- Store data of a single type in centralised places according to purpose and lifetime.
For example, a json deserializer makes a lot of temporary strings. You can put that in an arena that is
created when serialization begins and destroyed when the output has been copied to the cannonical output string.
Alright, this is good but it's still not the full picture.
Let's zoom out again and talk about garbage.
What might a Zig do Later?
Recently, I watched a talk called "Approaching zig with a garbage collection mindset" by @quantumdeveloper2733. He talks about how he solved memory
managment in his game engine. If that premise interests you, go watch it now and forget about this article, I don't mind, the video is worth it.
A solution quantumdeveloper explored was reference counting pointers.
It didn't prove the correct choice for him, but I am interested to try it out nonetheless.
I was surprised to see it's not only possible in zig, but possible to do ergonomically.
Zig just makes it work, that's something that is clear to me. The language is extremely un-opinionated about how you use memory.
Overall, the thing I resonated with most in the talk was his ending thesis. Perhaps more important than choosing an easy memory strategy,
is observing and thinking carefully about your data's lifetime in aggregate and letting that inform your choice.
One hundread single structs is very hard to keep track of, but 1 or 2 memory durations is not.
After all, memory speed is the slowest component of computers today.
");
defer alloc.deinit();
std.debug.print("{s}", .{data});
*1 You would need a really wide stick and herculean strength. If you're an aspiring philosopher or engineer, consider it challenge to disprove this statement.
*2 Someone should try modelling programming languages in N-dimensional space according to metrics on similiarities and differences to C. That would be a really interesting visualisation.
*3 Who am I? I am not a teacup. That is who.