Oh nice, more from Fil himself!
Zig’s [bounds-checks] are guaranteed only if you don’t race or use [*]T directly.
…or if you use ‘Release’ optimisation. I can’t think of a good reason to do so, but maybe there is one, and Fil-C would still protect your program at the coarse level. You get inter-Arena protection, but not intra-Arena protection.
So I wonder: is it possible to make the allocator interface special in this way:
-
all calls to alloc just call zgc_alloc in Fil-C mode
-
all calls to resize/remap just call zgc_realloc (though resize might need a new intrinsic on the Fil-C side)
-
calls to free just call zgc_free or do nothing
Long idea that eventually led to an epiphany.
Someone correct me if I’m wrong, but couldn’t this be done by using a compile-time switch to replace each Allocator with a dummy version? e.g. for ArenaAllocator.
// std.heap
const std = @import("std.zig");
const builtin = @import("builtin");
const ArenaAllocator = if (builtin.os.abi == .fil1) @import("fil1/heap/ArenaAllocator.zig") else @import("heap/DummyArenaAllocator.zig");
That’s probably a a very ugly way to do it, but you get the idea.
From the outside, the DummyArenaAllocator looks like an ArenaAllocator - it has the same methods. However, every call to alloc() simply allocates from the child alloc. It also keeps an internal ArrayList [1] of all the allocation pointers, so you can run arena.reset() and free all the allocations at the same time - a lot of code uses that pattern instead of individual free calls, so this way the garbage collector gets informed when that happens.
[1] Arraylist is its own separate allocation so that an attacker can’t jump from one allocation to all the rest, like they could if linked-list nodes were stored intrusively with each allocation.
Important behaviour:
alloc() is passed straight through to the child allocator, except the pointer is also recorded in its internal list.
resize() still fails if you try to increase the size of an allocation that is not the most recent one - even though they’re really just individual allocations.
free() is not a noop - it actually calls child_allocator.free(). It also removes it from the ArrayList, so they don’t get double-free’d.
ArenaAllocator.reset() frees all the allocations no matter what option you pass to it.
Advantages:
- No change to the std.mem.Allocator Interface [edit].
- Quirks of the Allocator are preserved. - e.g.
ArenaAllocator.reset().
- Does not change the behaviour of user-defined allocators.
Disadvantages:
- May confuse someone who is looking for the source code of ArenaAllocator.
- Code must be kept in lockstep with the original.
- Needs to manually written for each ‘overridden’ allocator.
…
That last point got me thinking - how often do people write Allocators that are (1) wrappers around a generic Allocator and (2) non-trivial?
- Allocators that get their memory from syscalls (directly or via
std.heap.PageAllocator) need to changed anyway, to get memory from Fil-C instead.
- By ‘non-trivial’, I mean allocators which don’t just pass-through the results from the child allocator, but either (1) combine allocations into a single child allocation or (2) store allocator meta-data in the same child allocation. ArenaAllocator does both, DebugAllocator does neither.
- A trivial allocator wrapped around the Fil-C allocator gives the same protections as the Fil-C allocator does.
In the zig-0.16.0 std.heap , the wrapping Allocators are
ArenaAllocator (non-trivial)
DebugAllocator (trivial)
FixedBufferAllocator (non-trivial - think of the buffer as being a single ‘child allocation’).
StackFallbackAllocator (trivial)
The master branch also has SafeAllocator which stores metadata within each child allocation, but it looks like it does the same job as Fil-C’s allocator, making it redundant.
Outside of that, std.Io often wraps allocators… but I think that’s solely for thread-safety.
As for large zig projects:
If anyone knows any major zig projects with interesting Allocators - trivial or not - I’d like to see them.
To me, this indicates that ArenaAllocator and FixedBufferAllocator are special somehow. I don’t think it’s a coincidence that they both forbid you from increasing the size of any allocation except the most recent one, and both of them allow you to not bother free’ing individual allocations and just do it as one big batch at the end.
If the problem is specific to Arena Allocators, and allocators similar to them, then it follows that a solution could (should?) be specific to them as well.