#
Глава VI. Динамическая память и распределители памяти
Всё, с чем имели дело до сих пор, требовало знаний размеров всех сущностей во время компиляции. Массивы всегда имеют размер, известный во время компиляции (фактически, длина массива является частью типа). Все наши строки были строковыми литералами, длина которых тоже понятна по исходному тексту.
Далее, две стратегии управления памятью (глобальные данные и стек), которые мы видели, хоть и являются простыми и эффективными, всё же ограничивают наши возможности. Обе они никак не могут помочь в управлении данными, которые могут менять размер во время исполнения и, кроме того, не обладают необходимой гибкостью в плане времён жизни - глобальные данные живут всё время работы программы (и при этом не могут быть модифицированы), переменные в стеке живут, пока работает функция, который принадлежит этот стековый кадр.
Эта глава посвящена двум темам. Первая это общий обзор нашей третьей
области памяти, кучи. А вот вторая более интересна - в языке Zig имеется
концептуально простой, но тем не менее уникальный подход к управлению
динамической памятью. Даже если вы так или иначе умеете работать с кучей
(например, при помощи malloc
и free
из стандартной библиотеки языка
C), вам всё равно следует проштудировать эту главу, поскольку в Zig есть
свои особенности.
#
Динамическая память (heap, "куча")
Куча это третья область памяти, имеющаяся в нашем распоряжении. В сравнении с первыми двумя это немного "дикий запад": дозволено всё. Именно, мы можем создавать сущности в куче с размером, известным только во время исполнения программы и при этом имеем полный контроль над временем жизни этих сущностей.
Стек вызовов это замечательная штука ввиду простоты и предсказуемости способа, которым он управляет данными (создание/удаление стековых кадров). С другой стороны, эта простота одновременно является недостатком: времена жизни переменных в стеке жёстко привязаны ко времени жизни самого стекового кадра, в котором они "живут". Куча это полная противоположность этому. Для объектов в куче нет никакого встроенного механизма контроля времени жизни, поэтому эти объекты могут существовать столь долго или, наоборот, столь коротко, сколько мы захотим. И эти преимущества, опять же, палка о двух концах: если мы не освободим память, никто её за нас не освободит.
Давайте посмотрим на следующий пример:
const std = @import("std");
pub fn main() !void {
// об аллокаторах мы вот-вот поговорим
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
// ** следующие две строчки очень важны **
var arr = try allocator.alloc(usize, try getRandomCount());
defer allocator.free(arr);
for (0..arr.len) |i| {
arr[i] = i;
}
std.debug.print("{any}\n", .{arr});
}
fn getRandomCount() !u8 {
var seed: u64 = undefined;
try std.os.getrandom(std.mem.asBytes(&seed));
var random = std.rand.DefaultPrng.init(seed);
return random.random().uintAtMost(u8, 5) + 5;
}
Вскоре мы рассмотрим распределители памяти (аллокаторы) более подробно, а
пока нам нужно знать только то, что allocator
в этом примере это
std.mem.Allocator
. Мы используем два его метода, alloc
и free
. Из
того, что мы вызываем allocator.alloc
при помощи try
, следует, что
этот метод может завершиться с ошибкой. На настоящий момент возможна
только одна ошибка, OutOfMemory
. Параметры этого метода и возвращаемое
значение, в принципе, говорят нам о том, как он работает: он требует тип
(T
) и количество элементов, при успешном завершении возвращает полный
срез выделенного в куче массива. Выделение памяти происходит во время
выполнения, иначе никак, поскольку длина становится известной только во
время исполнения.
Общее правило при динамическом выделении памяти - каждый вызов alloc
,
как правило, имеет соответствующий вызов free
. Первый выделяет память,
второй освобождает. Однако, не дайте этому примеру ограничить ваше
воображение. Да, этот паттерн, try alloc
и сразу следом defer free
действительно часто используется, и не зря: такое размещение инструкций
является относительно надёжной защитой от ошибок в виде того, что мы
"забыли" освободить. Но так же часто используется совершенно иная схема:
выделение памяти делается в одном месте, а освобождение совершенно в
другом (например, в другой функции, в другом методе или даже в другой
нити). Как мы уже отметили, нет никаких автоматических механизмов
управления временем жизни объектов в куче, всё в нашей власти.
Представьте себе веб-сервер с постадийной обработкой запроса, причём
каждую стадию отрабатывает выделенная для этого нить - тогда память может
быть выделена на стадии получения HTTP-запроса, а освобождена где-то на
конечной стадии, например, при записи в журнал, в котором фиксируются все
запросы.
#
defer
и errdefer
В рассматриваемом примере мы ввели в обиход незнакомую нам до этого новую
особенность языка Zig, defer
. Этот механизм, согласно его названию,
откладывает исполнение обозначенного кода (который сам по себе может быть
блоком) до того момента, когда закончится выполняться блок (включая явный
return
), в котором находится defer
. Такое отложенное выполнение вовсе
не связано конкретно с аллокаторами и управлением памятью, это более
общий механизм, вы можете использовать его для выполнения абсолютно
произвольного кода.
defer
в Zig подобен таковому в Go
, с одним важным отличием:
в Zig отложенные инструкции будут выполнены по завершении
блока, где они заданы, а в Go они будут выполнены в конце функции.
Родственником defer
является errdefer
. Так же, как и defer
,
он выполняет заданный код по завершении блока, но только в случае,
если произошла ошибка. Это полезно, когда логика инициализации
более сложная, чем в примере выше и нам нужно отменить
уже совершённые процедуры в случае, если на очередном шаге
случилась ошибка.
Следующий пример - своего рода прыжок в сложность. Он демонстрирует
использование errdefer
, а также общую схему, когда init
используется для выделения памяти, а deinit
для её освобождения:
const std = @import("std");
const Allocator = std.mem.Allocator;
pub const Game = struct {
players: []Player,
history: []Move,
allocator: Allocator,
fn init(allocator: Allocator, player_count: usize) !Game {
var players = try allocator.alloc(Player, player_count);
errdefer allocator.free(players);
// храним 10 последних шагов игрока
var history = try allocator.alloc(Move, player_count * 10);
return .{
.players = players,
.history = history,
.allocator = allocator,
};
}
fn deinit(game: Game) void {
const allocator = game.allocator;
allocator.free(game.players);
allocator.free(game.history);
}
};
Как можно надеяться, тут подчёркиваются две вещи. Первая - это полезность
errdefer
. Если всё идёт хорошо, память под поле players
выделяется в
init
и освобождается в deinit
. Но есть особый случай (маловероятный,
но тем не менее) - если выделение памяти под history
завершилось
неудачно, то в этом и только в этом случае нужно освободить память,
выделенную под players
.
Второй аспект этого примера, на который стоит обратить внимание, состоит в
том, что жизненный цикл наших двух срезов, то есть players
и history
,
задаётся логикой прикладного кода. Нет никаких правил насчёт того, когда
должна быть вызвана deinit
и кто это должен сделать. С одной стороны,
это хорошо, поскольку позволяет нам иметь произвольные времена жизни. С
другой стороны, это плохо, потому что мы можем напортачить и никогда не
вызвать deinit
или вызвать её два раза.
Замечание по поводу именований "конструктора" (init
) и "деструктора"
(deinit
). В именно таких названиях нет ничего особенного, это просто
соглашения, принятые в стандартной библиотеке Zig и принятые к
использованию сообществом Zig. В некоторых случаях, включая стандартную
библиотеку, используются другие более подходящие для этих случаев имена,
например, open
и close
.
#
Повторное освобождение и утечки памяти
Только что мы отметили, что не существует никаких правил относительно того, кто и когда должен освободить память. Однако, это верно лишь отчасти, всё таки имеется несколько важных правил, просто ничто, кроме вашей педантичности и аккуратности, не заставит вас их соблюдать.
Первое правило состоит в том, что вы не можете освободить одну и ту же область памяти дважды, смотрим на пример:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var arr = try allocator.alloc(usize, 4);
allocator.free(arr);
allocator.free(arr);
std.debug.print("Это никогда не напечатается\n", .{});
}
Последняя строчка этого кода, как выяснится после попытки его запустить,
оказалась пророческой, действительно, строчка не будет напечатана. Так
получилось потому, что мы два раза делаем allocator.free(arr);
и на
втором разе программа "вылетит" с ошибкой. Это называется
повторное/двойное освобождение (double free) и так делать не надо. Может
показаться, что этого довольно просто избежать, однако, в сложных больших
проектах со сложными правилами по поводу времён жизни бывает трудно всё
это отследить.
Второе правило гласит, что вы не можете освобождать память, на которую у вас нет ссылки. Это вроде как самоочевидно, но бывает не всегда ясно, кто именно ответственен за освобождение. Вот функция, которая создаёт строку в нижнем регистре:
const std = @import("std");
const Allocator = std.mem.Allocator;
fn allocLower(allocator: Allocator, str: []const u8) ![]const u8 {
var dest = try allocator.alloc(u8, str.len);
for (str, 0..) |c, i| {
dest[i] = switch (c) {
'A'...'Z' => c + 32,
else => c,
};
}
return dest;
}
Сама по себе эта функция корректная, но вот такое её использование неправильно:
// здесь лучше использовать std.ascii.eqlIgnoreCase
fn isSpecial(allocator: Allocator, name: [] const u8) !bool {
const lower = try allocLower(allocator, name);
return std.mem.eql(u8, lower, "admin");
}
Это утечка памяти. Память, выделенная внутри allocLower
, никогда не
освобождается. Но суть утечки не в этом, а в том, что как только мы
вернулись из isSpecial
, мы уже никогда не сможем её освободить. В
языках со сборкой мусора память под данные, которые стали не нужны, в
конце концов будет освобождена сборщиком мусора. В коде выше после
возврата из isSpecial
мы потеряли единственную ссылку на переменную
lower
. В итоге память занята, но ни использовать, ни освободить мы её
не можем, потому что у нас нет нужного указателя. Занятая впустую память
будет освобождена операционной системой только тогда, когда наша
программа завершится. Функция isSpecial
, возможно, не так уж и много
памяти теряет, но если она многократно вызывается в программе, которая по
своей сути работает "всегда" (любой сервер), то в итоге у нас на машине
кончится память.
В случае с повторным освобождением программа просто аварийно завершится. А вот утечки памяти могут куда более коварными: мало того, что первопричину бывает довольно трудно установить, ситуацию ещё более могут усугубить небольшие утечки или утечки в не часто вызываемом коде, тогда вы даже какое-то время даже и знать не будете, что имеет место утечка памяти, соответственно и причину искать не будете.
Поскольку утечки это довольно распространённая проблема, Zig предоставляет некоторую помощь, о которой мы узнаем, когда будем изучать аллокаторы.
#
create
и destroy
Метод alloc
структуры std.mem.Allocator
возвращает срез с длиной,
указанной вторым параметром. Если вам нужно одиночное значение,
вместо alloc
и free
используйте create
и destroy
, соответственно.
Пару глав назад, изучая указатели, мы создавали пользователя
и пытались увеличить его силу. Вот работающий пример с динамическим
выделением памяти при помощи метода create
:
const std = @import("std");
pub fn main() !void {
// да-да-да, совсем скоро будет про аллокаторы
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
// создаём экземпляр User в "куче"
var user = try allocator.create(User);
// освобождаем память, выделенную для user в конце блока
defer allocator.destroy(user);
user.id = 1;
user.power = 100;
// добавили это
levelUp(user);
std.debug.print("Пользователь {d} имеет силу {d}\n", .{user.id, user.power});
}
fn levelUp(user: *User) void {
user.power += 1;
}
pub const User = struct {
id: u64,
power: i32,
};
Метод create
принимает только один параметр, тип (T
) создаваемого
объекта. Возвращает он или указатель на выделенное место или ошибку, то
есть !*T
. У вас может возникнуть вопрос - а что, если мы таким образом
создадим пользователя, но при этом не назначим никаких значений полям
id
и power
? Тут всё просто - получится так же, как если бы назначили
"значения" undefined
.
Когда мы изучали висячие указатели, у нас была функция, которая ошибочно возвращала адрес локальной переменной:
pub const User = struct {
fn init(id: u64, power: i32) *User{
var user = User{
.id = id,
.power = power,
};
// это висячий указатель
return &user;
}
};
В этом случае имеет смысл возвращать User
, а не указатель на него.
Но иногда нужно, чтобы функция возвращала именно указатель на то,
что она создала. Так нужно делать, если вы хотите, чтобы время
жизни создаваемой сущности не было привязано к механизму стека вызовов.
Чтобы решить проблему висячего указателя, можно использовать create
:
// поменяли тип возвращаемого значения, поскольку теперь init может завершиться неудачно
// *User -> !*User
fn init(allocator: std.mem.Allocator, id: u64, power: i32) !*User{
var user = try allocator.create(User);
user.* = .{
.id = id,
.power = power,
};
return user;
}
Тут у нас появилась новая синтаксическая конструкция, user.* = .{...}
.
Она немного странная и некоторым она не нравится, но, так или иначе, она
используется. То, что написано справа от знака присваивания, мы уже
видели, это инициализатор структуры с выводимым из левой части типом.
Можно и явно написать, то есть user.* = User{...}
, по сути ничего не
поменяется. А вот то, что слева от знака присваивания, называется
"разыменование указателя" (pointer dereference). Уже знакомый нам
оператор &
(взятие адреса) берёт тип T
и возвращает указатель на
него, то есть *T
. А разыменование указателя, то есть .*
делает
противоположное, то есть берёт *T
и возвращает T
. Помните, что
create
тут возвращает !*User
, стало быть, тип переменной user
есть
указатель на User
, то есть *User
и чтобы заполнить память, где
расположен этот экземпляр, указатель нужно разыменовать.
#
Аллокаторы
Одним из базовых принципов Zig является принцип "никаких скрытых
выделений памяти". В зависимости от вашего предыдущего опыта
программирования, возможно, для вас в этом принципе нет ничего
особенного. Тем не менее, это сильно контрастирует с тем, что мы имеем в
стандартной библиотеке языка C, а там для выделения памяти мы имеем
malloc
. В C для того, чтобы понять, использует ли та или иная функция
кучу, нужно глядеть в исходный код этой функции и искать там malloc
и
родственные ей, например, calloc
.
А вот в Zig нет аллокатора "по умолчанию". Во всех примерах выше функции,
которые размещают сущности в куче, принимают в качестве параметра
std.mem.Allocator
. По соглашению, обычно это первый параметр. Вся
стандартная библиотека Zig, а также большинство сторонних библиотек
требуют, чтобы вызывающая сторона передала аллокатор в случае, если этим
библиотекам требуется выделять память динамически.
Такое явное указание аллокаторов может принимать две формы. В простых случаях, аллокатор передаётся каждой функции, например:
onst say = std.fmt.allocPrint(allocator, "It's over {d}!!!", .{user.power});
defer allocator.free(say);
Функция std.fmt.allocPrint
похожа на std.debug.print
, только вместо
того, чтобы печатать в stderr
, она выделяет память под строку и
"печатает" в эту строку (как sprintf
в C).
Другая форма это когда аллокатор передаётся в "конструктор" (init
), там
запоминается и далее используется для внутренних нужд объекта. Мы видели
такую форму выше, в реализации структуры Game
. Такая форма менее явна,
потому что вы знаете, что объект будет использовать динамическую память,
но не знаете, в каких именно своих методах он будет это делать. Такой
подход более практичен для долго живущих сущностей.
Преимущество "внедрения" аллокатора состоит не только в том, что явно
обозначено, что функция/объект будут использовать кучу, но также и в том,
что это предоставляет некоторую гибкость. Дело в том, что
std.mem.Allocator
это интерфейс (а не конкретный аллокатор), который
предоставляет методы alloc/free
и create/destroy
, наряду с некоторыми
другими. До этого момента мы видели только
std.heap.GeneralPurposeAllocator
, но в стандартной библиотеке имеются
реализации и других аллокаторов.
Если вы разрабатываете библиотеку, лучше всего будет, если она будет принимать аллокатор, это позволит пользователям вашей библиотеки сами выбирать, какой конкретно аллокатор будет использоваться. В противном случае вам будет нужно выбирать "правильный" аллокатор и, как мы далее увидим, аллокаторы не являются взаимоисключающими - программа может одновременно использовать несколько видов аллокаторов, и на то могут быть свои весьма веские причины.
#
Аллокатор общего назначения
Как и подразумевает само название этого аллокатора
(std.heap.GeneralPurposeAllocator
), это во всех отношениях аллокатор
общего назначения, в частности, он безопасен для использования в
многопоточных программах и может служить главным аллокатором в ваших
программах. Для большинства программ он будет единственным аллокатором.
Аллокатор создаётся на старте программы и затем передаётся тем функциям,
которым он нужен. Вот небольшой пример:
const std = @import("std");
const httpz = @import("httpz");
pub fn main() !void {
// создаём аллокатор общего назначения
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
// получаем из него интерфейс std.mem.Allocator
const allocator = gpa.allocator();
// передаём аллокатор функциям и библиотекам, которым он нужен
var server = try httpz.Server().init(allocator, .{.port = 5882});
var router = server.router();
router.get("/api/user/:id", getUser);
// ждём подключения клиента
try server.listen();
}
Здесь мы создаём GeneralPurposeAllocator
, получаем из него
std.mem.Allocator
и затем передаём его функции init
структуры
httpz.Server
. В более сложном проекте аллокатор, скорей всего, будет
передаваться во многие другие подсистемы, каждая из которых, в свою
очередь, будет передавать аллокатор дальше по цепочке для своих функций,
объектов и т.п.
Наверное, вы обратили внимание, что синтаксис создания аллокатора
какой-то странный. Что вообще такое GeneralPurposeAllocator(.{}){}
? На
самом деле всё это уже нам знакомо, просто тут оно сразу всё в кучу.
GeneralPurposeAllocator
это, очевидно, функция и, поскольку она
написана в PascalCase
, она возвращает тип (мы будем говорить об
обобщённых структурах данных в следующей главе). Зная, что она возвращает
тип, этот пример, где это явно обозначено, будет легче "расшифровать":
// вот это вот
const T = std.heap.GeneralPurposeAllocator(.{});
var gpa = T{};
// то же самое, как и это
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
Возможно, вы всё ещё не уверены в смысле .{}
. Это мы тоже встречали
раньше, это инициализатор структуры с неявным типом, тип будет выведен
компилятором. А что это за тип и какие у него поля? Это
std.heap.general_purpose_allocator.Config
, хоть это явно у нас и не
обозначено. Никакие поля тут мы не инициализируем, потому что в Config
указаны значения по умолчанию, их мы и намеревались использовать. Это
общая практика при работе с конфигурациями/настройками. Несколькими
строчками ниже мы встречаем подобное ещё раз, при передаче .{.port = 5882}
функции Server.init
. В данном случае мы используем значения по
умолчанию для всех полей, кроме поля port
.
#
Аллокатор для тестирования
Автор выражает надежду, что вы серьезно призадумались, когда мы обсуждали
утечки памяти и после заявления про то, что Zig тут может помочь,
захотели узнать, чем именно. Помощь нам тут может оказать
std.testing.allocator
, который на данный момент реализован на основе
GeneralPurposeAllocator
. Однако, он тесно интегрирован с системой тестов
Zig, впрочем, это детали реализации. Нам будет важно то, что если мы будем
использовать std.testing.allocator
а наших тестах, он нам отловит
большинство утечек памяти, если таковые будут.
Наверняка вы уже знакомы с динамическими массивами, также часто называемых ArrayList
.
Во многих языках с динамической типизацией вообще все массивы динамические.
Динамические массивы могут менять свою длину в процессе работы программы.
В стандартной библиотеке Zig есть реализация обобщённого динамического массива,
но мы сейчас вручную сделаем реализацию специализированного (только для целых)
динамического массива и продемонстрируем детекцию утечки памяти:
pub const IntList = struct {
pos: usize,
items: []i64,
allocator: Allocator,
fn init(allocator: Allocator) !IntList {
return .{
.pos = 0,
.allocator = allocator,
.items = try allocator.alloc(i64, 4),
};
}
fn deinit(self: IntList) void {
self.allocator.free(self.items);
}
fn add(self: *IntList, value: i64) !void {
const pos = self.pos;
const len = self.items.len;
if (pos == len) {
// у нас кончилось место
// создаём новый срез, в два раза длиннее
var larger = try self.allocator.alloc(i64, len * 2);
// копируем имеющиеся элементы в новое место
@memcpy(larger[0..len], self.items);
self.items = larger;
}
self.items[pos] = value;
self.pos = pos + 1;
}
};
Нас тут будет интересовать та часть функции add
, где проверяется, а не
переполнится ли наш массив при добавлении в него очередного элемента,
далее при необходимости длина массива увеличивается в два раза и только
после этого добавляется новый элемент. Мы можем использовать наш
IntList
вот так:
const std = @import("std");
const Allocator = std.mem.Allocator;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var list = try IntList.init(allocator);
defer list.deinit();
for (0..10) |i| {
try list.add(@intCast(i));
}
std.debug.print("{any}\n", .{list.items[0..list.pos]});
}
Код работает правильно и печатает то, что от него ожидается. Однако,
несмотря на то, что мы вызвали deinit
, тут имеет место утечка. Если вы
не заметили, где она происходит, ничего страшного, ведь сейчас мы напишем
тест и будем в нём использовать std.testing.allocator
:
const testing = std.testing;
test "IntList: add" {
// тут мы используем специальный аллокатор для тестирования
var list = try IntList.init(testing.allocator);
defer list.deinit();
for (0..5) |i| {
try list.add(@intCast(i+10));
}
try testing.expectEqual(@as(usize, 5), list.pos);
try testing.expectEqual(@as(i64, 10), list.items[0]);
try testing.expectEqual(@as(i64, 11), list.items[1]);
try testing.expectEqual(@as(i64, 12), list.items[2]);
try testing.expectEqual(@as(i64, 13), list.items[3]);
try testing.expectEqual(@as(i64, 14), list.items[4]);
}
Тесты в Zig обычно пишутся в том же файле, где реализуется то, что мы
хотим протестировать. Поместите три предыдущих отрывка кода в один файл,
запустите тест, используя команду zig test src/ex-ch06-03.zig
и
наслаждайтесь результатом:
$ /opt/zig-0.11/zig test src/ex-ch06-03.zig
Test [1/1] test.IntList: add... [gpa] (err): memory address 0x7ff7897f4000 leaked:
/coding/zig-lang/learning-zig-rus/src/ex-ch06-03.zig:14:41: 0x2248be in init (test)
.items = try allocator.alloc(i64, 2),
... ^
[gpa] (err): memory address 0x7ff7897fe000 leaked:
/home/zed/2-coding/zig-lang/learning-zig-rus/src/ex-ch06-03.zig:29:50: 0x224cd0 in add (test)
var larger = try self.allocator.alloc(i64, len * 2);
^
...
All 1 tests passed.
2 errors were logged.
1 tests leaked memory.
У нас тут аж две утечки! К счастью, тестирующий аллокатор говорит нам в
точности, где была выделена память, указатели на которую мы не
освободили. Можете теперь отследить, где и как происходит утечка? Если
нет, вспомните, что каждому выделению памяти (alloc
) должно
соответствовать её освобождение (free
). Наш код вызывает free
только
один раз, в функции deinit
, однако, alloc
вызывается более одного
раза - первый раз при создании списка, то есть в функции init
и затем
всякий раз, когда вызывается метод add
и при этом нам нужно расширить
массив. Поэтому всякий раз, когда мы перевыделяем память под массив, нам
нужно освободить память от предыдущего (более короткого) массива:
// существующий код
var larger = try self.allocator.alloc(i64, len * 2);
@memcpy(larger[0..len], self.items);
// добавленный код
// освобождаем память от "старого" массива
self.allocator.free(self.items);
Добавление строчки self.allocator.free(self.items);
после копирования массива
в новую область решает нашу проблему. Теперь тест завершается успешно.
#
Распределитель на основе регионов
Распределитель памяти общего назначения, то есть GeneralPurposeAllocator
это
вполне разумный выбор по умолчанию, поскольку он в среднем хорошо работает
для любых возможных сценариев и объёмов выделяемой/высвобождаемой памяти.
Однако, в какой-то конкретной программе вы можете столкнуться с такой
схемой выделения/освобождения, для которой будет более выгодно использовать
специализированные аллокаторы. В качестве примера можно привести ситуацию,
когда вам нужны какие-то относительно короткоживущие данные,
которые хотелось бы удалить, так сказать, одним махом. Часто под такие
требования подпадают парсеры. Вот скелет некой функции разбора каких-то данных:
fn parse(allocator: Allocator, input: []const u8) !Something {
var state = State{
.buf = try allocator.alloc(u8, 512),
.nesting = try allocator.alloc(NestType, 10),
};
defer allocator.free(state.buf);
defer allocator.free(state.nesting);
return parseInternal(allocator, state, input);
}
Хоть с этим и не особо сложно управляться, однако, функция
parseInternal
, возможно, сама используется много сущностей с коротким
временем жизни, которые, разумеется, тоже надо удалять. И было бы
замечательно, если бы могли освободить память от множества объектов, не
вызывая много раз free
или destroy
, а как бы за один присест. И тут
нам на помощь приходит ArenaAllocator
:
fn parse(allocator: Allocator, input: []const u8) !Something {
// создаём "регионный" аллокатор на основе предоставленного
var arena = std.heap.ArenaAllocator.init(allocator);
// это очистит всю "арену" разом
defer arena.deinit();
// получаем интерфейс std.mem.Allocator от "арены", this will be
// его мы будем использовать для внутренних целей
const aa = arena.allocator();
var state = State{
// используем aa тут!
.buf = try aa.alloc(u8, 512),
// и тут!
.nesting = try aa.alloc(NestType, 10),
};
// передаём aa куда-то, и теперь мы уверены,
// что всё будет находиться на нашей арене
return parseInternal(aa, state, input);
}
Этот аллокатор принимает "дочерний" аллокатор, в данном случае это
аллокатор, который передаётся в функцию, которая создаёт другой
аллокатор, ArenaAllocator
. При использовании этого аллокатора нам не
нужно чистить память индивидуально для каждой сущности, размещённой на
"арене": всё будет освобождено разом при вызове arena.deinit
. Методы
free
и destroy
в этом аллокаторе всё же имеются, но они просто ничего
не делают.
Аллокатор на основе регионов следует использовать с осторожностью.
Поскольку здесь нет никакого способа высвобождать память для каждой
сущности отдельно, нужно иметь уверенность, что arena.deinit
будет
вызвана прежде, чем мы задействуем слишком много памяти. Интересно, что
это знание (когда нужно почистить) может содержаться как внутри, так и
снаружи функции/объекта. Например, в приведённом выше скелете
представляется разумным задействовать ArenaAllocator
внутри Parser
,
поскольку детали, касающиеся времён жизни - это сугубо "личное дело"
самого парсера.
Однако, этого нельзя сказать про наш список IntList
. Он может быть
использован для хранения 10 чисел, а может быть использован для хранения
10 миллионов чисел. Кроме того, список может быть нужен на протяжении,
скажем, 100 миллисекунд, а может и на протяжении недель. То есть сам
список никак не может знать, какой аллокатор выгоднее использовать, такое
знанием в данном случае обладает код, который использует список.
Изначально мы управлялись (в плане аллокатора) с нашим списком вот так:
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var list = try IntList.init(allocator);
defer list.deinit();
Возможно, нам по каким-то причинам захотелось использовать аллокатор на основе регионов:
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const aa = arena.allocator();
var list = try IntList.init(aa);
// если честно, не знаю, вызывать тут list.deinit или не надо.
// технически вроде как и не надо, потому что выше у нас defer arena.deinit().
defer list.deinit();
При этом нам не нужно вносить изменения в реализацию IntList
, он работает
только с std.mem.Allocator
, то есть с интерфейсом, общим для всех аллокаторов.
И если бы где-то в IntList
создавался бы ещё один аллокатор с регионами,
то это тоже бы сработало, поскольку никто не запрещает иметь арену внутри арены.
#
Распределитель с фиксированным буфером
Последний аллокатор, который мы изучим, это
std.heap.FixedBufferAllocator
, который выделяет место под объекты в
буфере (то есть в []u8
), который ему предоставляется при инициализации.
У этого аллокатора есть два главных преимущества. Во-первых, поскольку
память уже выделена заранее, это быстро. Во-вторых, он естественным
образом ограничивает максимальный размер выделяемой памяти, что, с другой
стороны, может считаться недостатком. Другой недостаток состоит в том,
что free
и destroy
у этого аллокатора работают только с последним
размещённым объектом (практически, как в стеке). Попытка удаления не
последнего объекта абсолютна безопасна, поскольку такое удаление просто
ничего не делает.
const std = @import("std");
pub fn main() !void {
var buf: [150]u8 = undefined;
var fa = std.heap.FixedBufferAllocator.init(&buf);
defer fa.reset();
const allocator = fa.allocator();
const json = try std.json.stringifyAlloc(allocator, .{
.this_is = "an anonymous struct",
.above = true,
.last_param = "are options",
}, .{.whitespace = .indent_2});
std.debug.print("{s}\n", .{json});
}
Этот пример напечатает
{
"this_is": "an anonymous struct",
"above": true,
"last_param": "are options"
}
Уменьшите размер буфера (сделайте [120]u8
вместо [150]u8
) и вы получите ошибку:
error: OutOfMemory
...
Общеупотребительной является практика, когда буфер приводится в исходное
состояние (при помощи метода reset
) и затем снова используется. Это
также относится и к ArenaAllocator
, хотя и в меньшей степени.
#
Заключение
Отсутствие в Zig аллокатора по умолчанию делает его прозрачным и гибким в
отношении управления памятью. Интерфейс std.mem.Allocator
позволяет
строить специализированные аллокаторы на основе более общих.
В целом, возможности (и связанная с ними ответственность), которые даёт нам размещение данных в куче, будем надеяться, нам теперь понятны. Так же понятно, что способность иметь данные произвольного размера и с произвольным временем жизни является для большинства программ насущной необходимостью.
Однако, ввиду сложности, которая привносится работой с динамической памятью,
не стоит забывать об альтернативах. Например, выше мы использовали
функцию std.fmt.allocPrint
(которой нужен аллокатор), но в стандартной
имеется также функция std.fmt.bufPrint
, которой вместо аллокатора
передаётся буфер:
const std = @import("std");
pub fn main() !void {
const name = "Пётр";
var buf: [100]u8 = undefined;
const greeting = try std.fmt.bufPrint(&buf, "Привет, {s}!", .{name});
std.debug.print("{s}\n", .{greeting});
}
Такого рода интерфейс переносит заботы по управлению памятью на вызывающую сторону.
Если бы у нас было более длинное имя или менее объёмный буфер, bufPrint
могла бы вернуть ошибку, вроде NoSpaceLeftInThBuffer
. Однако, полно ситуаций,
когда приложение знает свои ограничения, например, максимальную длину имени.
В таких случаях использование bufPrint
вместо allocPrint
будет
проще, безопаснее и быстрее.
Ещё одна альтернатива динамическому выделению памяти это потоковая
передача данных при помощи std.io.Writer
. Как и Allocator
, Writer
это интерфейс, реализованный многими типами, в частности, файлами. Выше
мы использовали stringifyAlloc
для сериализации JSON в динамически
выделенную строку. А можно было сразу выводить на экран, используя
stringify
, передав этой функции нужную реализацию интерфейса Writer
:
const std = @import("std");
pub fn main() !void {
const out = std.io.getStdOut();
try std.json.stringify(.{
.this_is = "an anonymous struct",
.above = true,
.last_param = "are options",
}, .{.whitespace = .indent_2}, out.writer());
}
Цель использования обозначенных (или подобных) альтернатив вовсе не в том, чтобы полностью исключить использование кучи, это просто не сработает, поскольку все такие альтернативы имеют смысл в каких-то особых случаях. Но, тем не менее, теперь в вашем распоряжении есть много возможностей - начиная от стековых кадров и вплоть до аллокатора общего назначения, со всеми промежуточными механизмами вроде статических буферов, потоковой передачи данных и специализированных аллокаторов.