On branch main

modified:   src/ch06.md
new file:   src/ex-ch06-03.zig
new file:   src/ex-ch06-04.zig
This commit is contained in:
zed
2023-11-16 18:38:18 +03:00
parent bedda3b028
commit 9ded5962c4
3 changed files with 525 additions and 55 deletions

View File

@@ -1,5 +1,5 @@
# Динамическая память и аллокаторы
# Динамическая память и распределители памяти
Всё, с чем имели дело до сих пор, требовало знаний размеров всех сущностей
во время компиляции. Массивы всегда имеют размер, известный во время
@@ -16,7 +16,7 @@
модифицированы), переменные в стеке живут, пока работает функция, который
принадлежит этот стековый кадр.
Эта глава содержит 2 темы. Первая это общий обзор нашей третьей области
Эта глава посвящена двум темам. Первая это общий обзор нашей третьей области
памяти, кучи. А вот вторая более интересна - в языке Zig имеется вполне
ясный, но тем не менее уникальный подход к управлению динамической
памятью. Даже если Вы так или иначе умеете работать с кучей
@@ -71,15 +71,15 @@ fn getRandomCount() !u8 {
}
```
Вскоре мы рассмотрим аллокаторы более подробно, а пока нам нужно знать
только то, что `allocator` в этом примере это `std.mem.Allocator`. Мы
используем два его метода, `alloc` и `free`. Из того, что мы вызываем
`allocator.alloc` при помощи `try`, следует, что этот метод может
завершиться с ошибкой. На настоящий момент возможна только одна ошибка,
`OutOfMemory`. Параметры этого метода и возвращаемое значение, в
принципе, говорят нам о том, как он работает: он требует тип (`T`) и
количество элементов, при успешном завершении возвращает полный срез
выделенного в куче массива. Выделение памяти происходит во время
Вскоре мы рассмотрим распределители памяти (аллокаторы) более подробно, а
пока нам нужно знать только то, что `allocator` в этом примере это
`std.mem.Allocator`. Мы используем два его метода, `alloc` и `free`. Из
того, что мы вызываем `allocator.alloc` при помощи `try`, следует, что
этот метод может завершиться с ошибкой. На настоящий момент возможна
только одна ошибка, `OutOfMemory`. Параметры этого метода и возвращаемое
значение, в принципе, говорят нам о том, как он работает: он требует тип
(`T`) и количество элементов, при успешном завершении возвращает полный
срез выделенного в куче массива. Выделение памяти происходит во время
выполнения, иначе никак, поскольку длина становится известной только во
время исполнения.
@@ -369,24 +369,24 @@ fn init(allocator: std.mem.Allocator, id: u64, power: i32) !*User{
## Аллокаторы
Одним из базовых принципов Zig является принцип "никаких скрытых выделений памяти".
В зависимости от Вашего предыдущего опыта программирования,
возможно, для Вас в этом принципе нет ничего особенного.
Тем не менее, это сильно контрастирует с тем, что мы имеем в стандартной
библиотеке языка C, а там для выделения памяти мы имеем `malloc`.
В C для того, чтобы понять, использует ли та или иная функция кучу,
нужно глядеть в исходный код этой функции и искать там `malloc`
и родственные ей, например, `calloc`.
Одним из базовых принципов Zig является принцип "никаких скрытых
выделений памяти". В зависимости от Вашего предыдущего опыта
программирования, возможно, для Вас в этом принципе нет ничего
особенного. Тем не менее, это сильно контрастирует с тем, что мы имеем в
стандартной библиотеке языка C, а там для выделения памяти мы имеем
`malloc`. В C для того, чтобы понять, использует ли та или иная функция
кучу, нужно глядеть в исходный код этой функции и искать там `malloc` и
родственные ей, например, `calloc`.
А вот в Zig нет аллокатора "по умолчанию". Во всех примерах выше
функции, которые размещают сущности в куче, принимают в качестве
параметра `std.mem.Allocator`. По соглашению, обычно это первый параметр.
Вся стандартная библиотека Zig, а также большинство сторонних библиотек
требуют, чтобы вызывающая сторона передала аллокатор в случае,
если этим библиотекам требуется выделять память динамически.
А вот в Zig нет аллокатора "по умолчанию". Во всех примерах выше функции,
которые размещают сущности в куче, принимают в качестве параметра
`std.mem.Allocator`. По соглашению, обычно это первый параметр. Вся
стандартная библиотека Zig, а также большинство сторонних библиотек
требуют, чтобы вызывающая сторона передала аллокатор в случае, если этим
библиотекам требуется выделять память динамически.
Такое явное указание аллокаторов может принимать две формы.
В простых случаях, аллокатор передаётся каждой функции, например:
Такое явное указание аллокаторов может принимать две формы. В простых
случаях, аллокатор передаётся каждой функции, например:
```zig
onst say = std.fmt.allocPrint(allocator, "It's over {d}!!!", .{user.power});
@@ -394,41 +394,365 @@ defer allocator.free(say);
```
Функция `std.fmt.allocPrint` похожа на `std.debug.print`, только вместо
того, чтобы печатать в `stderr`, она выделяет память под строку и "печатает"
в эту строку (как `sprintf` в C).
того, чтобы печатать в `stderr`, она выделяет память под строку и
"печатает" в эту строку (как `sprintf` в C).
Другая форма это когда аллокатор передаётся в "конструктор" (`init`),
там запоминается и далее используется для внутренних нужд объекта.
Мы видели такую форму выше, в реализации структуры `Game`.
Такая форма менее явна, потому что Вы знаете, что объект будет
использовать динамическую память, но не знаете, в каких именно своих
методах он будет это делать. Такой подход более практичен для долго живущих сущностей.
Другая форма это когда аллокатор передаётся в "конструктор" (`init`), там
запоминается и далее используется для внутренних нужд объекта. Мы видели
такую форму выше, в реализации структуры `Game`. Такая форма менее явна,
потому что Вы знаете, что объект будет использовать динамическую память,
но не знаете, в каких именно своих методах он будет это делать. Такой
подход более практичен для долго живущих сущностей.
Преимущество "внедрения" аллокатора состоит не только в том, что
явно обозначено, что функция/объект будут использовать кучу,
но также и в том, что это предоставляет некоторую гибкость.
Дело в том, что `std.mem.Allocator` это *интерфейс* (а не конкретный аллокатор),
который предоставляет методы `alloc/free` и `create/destroy`, наряду с некоторыми другими.
До этого момента мы видели только `std.heap.GeneralPurposeAllocator`,
но в стандартной библиотеке имеются реализации и других аллокаторов.
Преимущество "внедрения" аллокатора состоит не только в том, что явно
обозначено, что функция/объект будут использовать кучу, но также и в том,
что это предоставляет некоторую гибкость. Дело в том, что
`std.mem.Allocator` это *интерфейс* (а не конкретный аллокатор), который
предоставляет методы `alloc/free` и `create/destroy`, наряду с некоторыми
другими. До этого момента мы видели только
`std.heap.GeneralPurposeAllocator`, но в стандартной библиотеке имеются
реализации и других аллокаторов.
Если Вы разрабатываете библиотеку, лучше всего будет, если она будет
принимать аллокатор, это позволит пользователям Вашей библиотеки
сами выбирать, какой конкретно аллокатор будет использоваться.
В противном случае Вам будет нужно выбирать "правильный" аллокатор и,
как мы далее увидим, аллокаторы не являются взаимоисключающими -
программа может одновременно использовать несколько видов аллокаторов,
и на то могут быть свои причины.
принимать аллокатор, это позволит пользователям Вашей библиотеки сами
выбирать, какой конкретно аллокатор будет использоваться. В противном
случае Вам будет нужно выбирать "правильный" аллокатор и, как мы далее
увидим, аллокаторы не являются взаимоисключающими - программа может
одновременно использовать несколько видов аллокаторов, и на то могут быть
свои весьма веские причины.
## Аллокатор общего назначения
## `std.testing.allocator`
Как и подразумевает само название этого аллокатора
(`std.heap.GeneralPurposeAllocator`), это во всех отношениях аллокатор
общего назначения, в частности, он безопасен для использования в
многопоточных программах и может служить главным аллокатором в Ваших
программах. Для большинства программ он будет единственным аллокатором.
Аллокатор создаётся на старте программы и затем передаётся тем функциям,
которым он нужен. Вот небольшой пример:
```zig
const std = @import("std");
const httpz = @import("httpz");
pub fn main() !void {
// create our general purpose allocator
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
// get an std.mem.Allocator from it
const allocator = gpa.allocator();
// pass our allocator to functions and libraries that require it
var server = try httpz.Server().init(allocator, .{.port = 5882});
var router = server.router();
router.get("/api/user/:id", getUser);
// blocks the current thread
try server.listen();
}
```
Здесь мы создаём `GeneralPurposeAllocator`, получаем из него
`std.mem.Allocator` и заьем передаём его функции `init` структуры
`httpz.Server`. В более сложном проекте аллокатор, скорей всего, будет
передаваться во многие другие подсистемы, каждая из которых, в свою
очередь, будет передавать аллокатор дальше по цепочке для своих функций,
объектов и т.п.
Наверное, Вы обратили внимание, что синтаксис создания аллокатора
какой-то странный. Что вообще такое `GeneralPurposeAllocator(.{}){}`? На
самом деле всё это уже нам знакомо, просто тут оно сразу всё в кучу.
`GeneralPurposeAllocator` это, очевидно, функция и, поскольку она
написана в `PascalCase`, она возвращает *тип* (мы будем говорить об
обощённых структурахданных в следующей главе). Зная, что она возвращает
тип, этот пример, где это явно обозначеноЮ будет легче "расшифровать":
```zig
const T = std.heap.GeneralPurposeAllocator(.{});
var gpa = T{};
// is the same as:
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 есть реализация обобщённого динамического массива,
но мы сейчас вручную сделаем реализацию специализированного (только для целых)
динамического массива и продемонстрируем детекцию утечки памяти:
```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) {
// we've run out of space
// create a new slice that's twice as large
var larger = try self.allocator.alloc(i64, len * 2);
// copy the items we previously added to our new space
@memcpy(larger[0..len], self.items);
self.items = larger;
}
self.items[pos] = value;
self.pos = pos + 1;
}
};
```
Нас тут будет интересовать та часть функции `add`, где проверяется, а не
переполнится ли наш массив при добавлении в него очередного элемента,
далее при необходимости длина массива увеличивается в два раза и только
после этого добавляется новый элемент. Мы можем использовать наш
`IntList` вот так:
```zig
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`:
```zig
const testing = std.testing;
test "IntList: add" {
// We're using testing.allocator here!
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 обычно пишутся в том же файле, где реализуется то,
что мы хотим протестировать. Поместите 3 предыдущих отрывка кода в один файл
и запустите тест, используя команду `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.
```
У нас тут аж 2 утечки! К счастью, тестирующий аллокатор говорит нам в точности,
где была выделена память, указатели на которую мы не освободили.
Можете теперь отследить, где и как происходит утечка? Если нет, вспомните,
что каждому выделению памяти (`alloc`) должно соответствовать её освобождение (`free`).
Наш код вызывает `free` только один раз, в функции `deinit`, однако, `alloc`
вызывается более одного раза - первый раз при создании списка, то есть в функции `init`
и затем всякий раз, когда вызывается метод `add` и нам нужно расширить массив.
Поэтому всякий раз, когда мы перевыделяем память под массив, нам нужно
освободить память от предыдущего (более короткого) массива:
```zig
// existing code
var larger = try self.allocator.alloc(i64, len * 2);
@memcpy(larger[0..len], self.items);
// Added code
// free the previous allocation
self.allocator.free(self.items);
```
Добавление строчки `self.allocator.free(self.items);` после копирования массива
в новую область решает нашу проблему. Теперь тест завершается успешно.
## Распределитель на основе регионов
Распределитель памяти общего назначения, то есть `GeneralPurposeAllocator` это
вполне разумный выбор по умолчанию, поскольку он в среднем хорошо работает
для любых возможных сценариев и объёмов выделяемой,высвобождаемой памяти.
Однако, в какой-то конкретной программе Вы можете столкнуться с такой
схемой выделения/освобождения, для которой будет более выгодно использовать
специализированные аллокаторы. В качестве примера можно привести ситуацию,
когда Вам нужны какие-то относительно короткоживущие данные,
которые хотелось бы удалить, так сказать, одним махом. Часто под такие
требования подпадают парсеры. Вот скелет некой функции разбора каких-то данных:
```zig
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`:
```zig
fn parse(allocator: Allocator, input: []const u8) !Something {
// create an ArenaAllocator from the supplied allocator
var arena = std.heap.ArenaAllocator.init(allocator);
// this will free anything created from this arena
defer arena.deinit();
// create an std.mem.Allocator from the arena, this will be
// the allocator we'll use internally
const aa = arena.allocator();
var state = State{
// we're using aa here!
.buf = try aa.alloc(u8, 512),
// we're using aa here!
.nesting = try aa.alloc(NestType, 10),
};
// we're passing aa here, so any we're guaranteed that
// any other allocation will be in our arena
return parseInternal(aa, state, input);
}
```
Этот аллокатор принимает "дочерний" аллокатор, в данном случае это
аллокатор, который передаётся в функцию, которая создаёт другой
аллокатор, `ArenaAllocator`. При использовании этого аллокатора нам не
нужно чистить память индивидуально для каждой сущности, размещённой на
"арене": всё будет освобождено разом при вызове `arena.deinit`. Методы
`free` и `destroy` в этом аллокаторе всё же имеются, но они просто ничего
не делают.
Аллокатор на основе регионов следует использовать с осторожностью.
Поскольку здесь нет никакого способа высвобождать память для каждой
сущности отдельно, нужно иметь уверенность, что `arena.deinit` будет
вызвана прежде, чем мы задействуем слишком много памяти. Интересно, что
это знание (когда нужно почистить) может содержаться как внутри, так и
снаружи функции/объекта? Например, в приведённом выше скелете
представляется разумным задействовать `ArenaAllocator` внутри `Parser`,
поскольку детали, касающиеся времён жизни - это сугубо "личное дело"
самого парсера.
Однако, этого нельзя сказать про наш список `intList`. Он может
быть использован для хранения 10 чисел, а может быть использован
для хранения 10 миллионов чисел. Кроме того, список может быть нужен
на протяжении, скажем, 100 миллисекунд, а может и на протяжении недель.
То есть сам список никак не может знать, как аллокатор выгоднее использовать,
такое знанием в данном случае обладает код, который использует список.
Изначально мы управлялись (в плане аллокатора) с нашим списком вот так:
```zig
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var list = try IntList.init(allocator);
defer list.deinit();
```
Возможно, нам по каким-то причинам захотелось использовать аллокатор на основе регионов:
```zig
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);
// I'm honestly torn on whether or not we should call list.deinit.
// Technically, we don't have to since we call defer arena.deinit() above.
defer list.deinit();
```
При этом нам не нужно вносить изменения в реализацию `intList`, он работает
только с `std.mem.Allocator`, то есть с интерфейсом, общим для всех аллокаторов.
И если бы где-то в `intList` создавался бы ещё один аллокатор с регионами,
то это тоже бы сработало, поскольку никто не запрещает иметь арену внутри арены.
## Arena...
## FixedBufferAllocator

73
src/ex-ch06-03.zig Normal file
View File

@@ -0,0 +1,73 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
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, 2),
};
}
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) {
// we've run out of space
// create a new slice that's twice as large
var larger = try self.allocator.alloc(i64, len * 2);
// copy the items we previously added to our new space
@memcpy(larger[0..len], self.items);
self.items = larger;
}
self.items[pos] = value;
self.pos = pos + 1;
}
};
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]});
}
const testing = std.testing;
test "IntList: add" {
// We're using testing.allocator here!
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]);
}

73
src/ex-ch06-04.zig Normal file
View File

@@ -0,0 +1,73 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
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, 2),
};
}
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) {
// we've run out of space
// create a new slice that's twice as large
var larger = try self.allocator.alloc(i64, len * 2);
// copy the items we previously added to our new space
@memcpy(larger[0..len], self.items);
self.allocator.free(self.items);
self.items = larger;
}
self.items[pos] = value;
self.pos = pos + 1;
}
};
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]});
}
const testing = std.testing;
test "IntList: add" {
// We're using testing.allocator here!
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]);
}