Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

std: rework ArenaAllocator.State.promote to mutate the original state pointer #13409

Closed
mlugg opened this issue Nov 2, 2022 · 0 comments
Closed
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned. standard library This issue involves writing Zig code for the standard library.
Milestone

Comments

@mlugg
Copy link
Member

mlugg commented Nov 2, 2022

Note: I think I'm okay to make this proposal based on what Vexu said in #13244 (comment), since this is a small std change which doesn't impact compiler implementation and removes a potential footgun.

Motivation

std.heap.ArenaAllocator rightly suggests that when the child allocator is known through some other means, it can be more efficient to store a value of type ArenaAllocator.State, and promote it when it needs to be used. In theory, this is an elegant and simple pattern: however, the way it is currently designed is a bit unintuitive.

Suppose we've created an arena state and stored it, and now want to do some allocation. The correct code for this is as follows:

var arena = state.promote(allo);
try doSomethingWith(arena.allocator());
state = arena.state; // This is important!

The last line here is what I'm concerned with. promote simply creates an actual ArenaAllocator with the state that was passed into it; that means if you still want the state to be valid (as you normally would in this case) you have to copy it back afterwards. This is easy to forget or not realise, and missing this would lead to confusing state corruption on future allocations which could be quite tricky to debug.

This is made worse by the fact that state isn't updated until after doSomethingWith returns. That means if anything called within doSomethingWith itself makes use of state, all bets are off; we'll again get a corrupted state with overlapping allocations. This is an obvious footgun.

Proposal

Replace std.heap.ArenaAllocator.State.promote with the following:

pub fn promote(state: *State, child_allocator: Allocator) Promoted {
    return .{
        .child_allocator = child_allocator,
        .state = state,
    };
}

pub const Promoted = struct {
    child_allocator: Allocator,
    state: *State,

    pub fn allocator(self: Promoted) Allocator {
        return Allocator.init(self, alloc, resize, free);
    }

    pub fn deinit(self: Promoted) void {
        self.arena().deinit();
    }

    fn arena(self: Promoted) ArenaAllocator {
        return .{
            .child_allocator = self.child_allocator,
            .state = self.state.*,
        };
    }

    fn alloc(self: Promoted, n: usize, ptr_align: u29, len_align: u29, ra: usize) ![]u8 {
        var a = self.arena();
        defer self.state.* = a.state;
        return a.alloc(n, ptr_align, len_align, ra);
    }

    fn resize(self: Promoted, buf: []u8, buf_align: u29, new_len: usize, len_align: u29, ra: usize) ?usize {
        var a = self.arena();
        defer self.state.* = a.state;
        return a.resize(buf, buf_align, new_len, len_align, ra);
    }

    fn free(self: Promoted, buf: []u8, buf_align: u29, ra: usize) void {
        var a = self.arena();
        defer self.state.* = a.state;
        a.free(buf, buf_align, ra);
    }
};

Then, the interface can be used as e.g. doSomethingWith(state.promote(allo).allocator()) entirely safely, since every use of the allocator from Promoted.allocator automatically copies to the underlying state.

This makes the interface technically more restrictive, since promote now needs a mutable pointer to the underlying state. However, there are only two cases where you wish to use promote: to use the allocator, or to deinit the arena. In the former case, you almost certainly have a mutable state, since that's necessary for the State to really be useful: if for some reason you have a const state which you're going to use locally and then deinit, it's only one extra line to put it into a var or to just init an actual arena wrapping it. When deiniting, the logic is much the same: you almost certainly have a mutable pointer anyway. (Plus, it could be argued that ArenaAllocator.deinit should take a mutable pointer like ArenaAllocator.allocator - related #9814).

Performance Implications

The only real disadvantage I see to this proposal is that it almost certainly has slightly worse performance characteristics compared to status quo. This solution adds a level of indirection between the allocator and the actual arena state, and copies to the original state on every allocation (status quo has the advantage that the programmer chooses when to copy the state back, and so can do it only when absolutely necessary).

I haven't done any benchmarking on this code. However, its overhead should be pretty negligible, and I believe is an acceptable loss in exchange for this improvement in safety and ease of use.

mlugg added a commit to mlugg/zig that referenced this issue Nov 18, 2022
Previously, std.heap.ArenaAllocator.State.promote required the user to
manually copy the arena state back to the original pointer when done
with it. The original state also couldn't be used elsewhere until it was
copied back. This was a clear footgun, as it's a very easy detail to
miss and forgetting to do it would likely cause strange memory
corruption issues which could be hard to track down. Now, promote
returns a new structure containing a pointer to the original state, and
this pointer is mutated whenever an operation is performed through its
allocator, removing this footgun.

To have a nice API, the returned Promoted struct can be referenced only
through const pointers, since the struct itself never needs to be
mutated. This allows the direct usage 'state.promote().allocator()' to
work safely. However, previously, the std.mem.Allocator API didn't allow
a way to do this without resorting to hacks like @ptrToInt, as the
type-erased pointer was stored internally as a *anyopaque. Therefore,
the definition of Allocator has also been modified to allow storing
const pointers through a union. This required a few changes to std, but
nothing should be hugely breaking.

Resolves: ziglang#13409
mlugg added a commit to mlugg/zig that referenced this issue Nov 18, 2022
Previously, std.heap.ArenaAllocator.State.promote required the user to
manually copy the arena state back to the original pointer when done
with it. The original state also couldn't be used elsewhere until it was
copied back. This was a clear footgun, as it's a very easy detail to
miss and forgetting to do it would likely cause strange memory
corruption issues which could be hard to track down. Now, promote
returns a new structure containing a pointer to the original state, and
this pointer is mutated whenever an operation is performed through its
allocator, removing this footgun.

To have a nice API, the returned Promoted struct can be referenced only
through const pointers, since the struct itself never needs to be
mutated. This allows the direct usage 'state.promote().allocator()' to
work safely. However, previously, the std.mem.Allocator API didn't allow
a way to do this without resorting to hacks like @ptrToInt, as the
type-erased pointer was stored internally as a *anyopaque. Therefore,
the definition of Allocator has also been modified to allow storing
const pointers through a union. This required a few changes to std, but
nothing should be hugely breaking.

Resolves: ziglang#13409
@Vexu Vexu added standard library This issue involves writing Zig code for the standard library. proposal This issue suggests modifications. If it also has the "accepted" label then it is planned. labels Nov 18, 2022
@Vexu Vexu added this to the 0.11.0 milestone Nov 18, 2022
@andrewrk andrewrk modified the milestones: 0.11.0, 0.12.0 Apr 9, 2023
@andrewrk andrewrk modified the milestones: 0.13.0, 0.12.0 Jul 9, 2023
@mlugg mlugg closed this as not planned Won't fix, can't repro, duplicate, stale Jul 19, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned. standard library This issue involves writing Zig code for the standard library.
Projects
None yet
Development

No branches or pull requests

3 participants