Автор: Робби Лайман

Блокировки стабильности указателей были добавлены в контейнеры Hash Map стандартной библиотеки в 2024 году. Pull request, изначально открытый Leo Emar-Kar в 2025 году, теперь распространяет эту технику обеспечения безопасности памяти на std.ArrayList.

Чтобы воспользоваться этим механизмом в своём коде, нужно вызвать lockPointers() при первом сохранении указателя на элемент или срез элементов, находящихся в памяти ArrayList, и вызвать unlockPointers(), когда эти указатели больше не нужны.

Вот немного искусственный пример. Предположим, что нужно управлять двумя ArrayList

const std = @import("std");

const Context = struct {
    history: std.ArrayList(u8),
    lines: std.ArrayList([]const u8),

    fn parse(ctx: *Context, allocator: std.mem.Allocator, input: []const u8) !void {
        const slice = try ctx.history.addManyAsSlice(allocator, input.len);
        @memcpy(slice, input);
        var it = std.mem.tokenizeScalar(u8, slice, '\n');
        while (it.next()) |line| {
            try ctx.lines.append(allocator, line);
        }
    }
};

Нашли баг? Проблема в том, что элементы Context.lines.items зависят от расположения Context.history.items, но это расположение может измениться, если Context.history потребуется вырасти сверх текущей вместимости. Вот воспроизведение бага:

test "Context.parse" {
    const input = "I'm first!\n";
    const input_two =
        \\But this text
        \\is juuuuuuuuuuuuuuuuuuuuuuuuust long enough that it
        \\causes a problem!
        \\And the problem could be that we segfault!
        \\Which is no fun to run into.
    ;
    var ctx: Context = .{
        .history = .empty,
        .lines = .empty,
    };
    const gpa = std.testing.allocator;
    defer ctx.history.deinit(gpa);
    defer ctx.lines.deinit(gpa);
    try ctx.parse(gpa, input);
    try ctx.parse(gpa, input_two);
    try std.testing.expectEqualStrings("I'm first!", ctx.lines.items[0]);
}

Если запустить этот код через zig test, вывод будет таким (плюс ещё немного):

====== expected this output: =========
I'm first!␃

======== instead found this: =========
UUUUUUUUUU␃

======================================
First difference occurs on line 1:
expected:
I'm first!
^ ('\x49')
found:
UUUUUUUUUU
^ ('\x55')
1/1 blah.test.Context.parse...FAIL (TestExpectedEqual)

Не очень приятно, правда? Такой вывод показывает, что баг есть, но в зависимости от привычки отлаживать проблемы с памятью (и от выбранного аллокатора, от которого зависит, как именно проявится баг) можно надолго застрять, прежде чем найти исправление.

Поскольку указатели сохраняются после первого вызова parse в тесте, что произойдёт, если внести такое изменение?

    try ctx.parse(gpa, input);
+   ctx.history.lockPointers();
+   defer ctx.history.unlockPointers();
    try ctx.parse(gpa, input_two);
    try std.testing.expectEqualStrings("I'm first!", ctx.lines.items[0]);

Появляется panic со стектрейсом, показывающий, где именно было нарушено предположение о стабильности указателей!

thread 3023222 panic: reached unreachable code
/Users/robbie/bin/lib/std/debug.zig:442:14: 0x102d2506f in assert (test)
    if (!ok) unreachable; // assertion failure
             ^
/Users/robbie/bin/lib/std/debug.zig:1880:15: 0x102d31ef7 in assertUnlocked (test)
        assert(l.state == .unlocked);
              ^
/Users/robbie/bin/lib/std/array_list.zig:1348:50: 0x102e3ced7 in ensureTotalCapacityPrecise (test)
            self.pointer_stability.assertUnlocked();
                                                 ^
/Users/robbie/bin/lib/std/array_list.zig:1341:51: 0x102e3cdff in ensureTotalCapacity (test)
            return self.ensureTotalCapacityPrecise(gpa, growCapacity(new_capacity));
                                                  ^
/Users/robbie/bin/lib/std/array_list.zig:1237:41: 0x102e4e5c3 in resize (test)
            try self.ensureTotalCapacity(gpa, new_len);
                                        ^
/Users/robbie/bin/lib/std/array_list.zig:1461:28: 0x102e4e40f in addManyAsSlice (test)
            try self.resize(gpa, try addOrOom(self.items.len, n));
                           ^
/Users/robbie/src/advent-of-code/2024/blah.zig:8:51: 0x102e4dc1f in parse (test)
        const ptr = try ctx.history.addManyAsSlice(allocator, input.len);
                                                  ^
/Users/robbie/src/advent-of-code/2024/blah.zig:35:18: 0x102e4e167 in test.Context.parse (test)
    try ctx.parse(gpa, input_two);

Уже существенная помощь: теперь понятно, что среди вероятных причин падения теста стоит рассматривать проблему безопасности памяти в дополнение к логической ошибке (или вместо неё). Пример, конечно, немного искусственный, но такая структура хранения на базе std.ArrayList регулярно встречается в реальном коде, так что применение этой техники несложно представить и в собственных проектах.

Напоследок стоит отметить тонкий момент: в отличие от HashMap и подобных контейнеров, ArrayList упорядочен, а значит операции над списком могут перемещать элементы даже без перемещения, изменения размера или освобождения памяти списка в целом. Например, указатель (точнее, срез), возвращённый вызовом addManyAsSlice(gpa, n), может не указывать на последние n элементов списка, если позже вызвать orderedRemove() или pop(). По этой причине, хотя orderedRemove() и pop() никогда не аллоцируют память, они будут вызывать ту же ассерцию после вызова lockPointers().