- Don’t Fear the Reaper
- Life in the Fast Lane
- Go Your Own Way. Часть первая. Стек
- Go Your Own Way. Часть вторая. Куча
Мы продолжаем цикл статей о сборщике мусора в языке D. Этот вторая часть статьи, посвящённой выделению памяти за пределами GC. В первой части говорилось о выделении памяти на стеке. Теперь мы рассмотрим выделение памяти из кучи.
Хотя это только четвёртая публикация в этой серии, это уже третья, в которой я рассказываю о способах избежать использования GC. Не обманитесь: я не пытаюсь отпугнуть программистов от сборщика мусора в языке D. Как раз наоборот. Понимание того, когда и как обходиться без GC, необходимо для эффективного его использования.
Ещё раз проговорю, что для эффективной сборки мусора нужно снижать нагрузку на GC. Как уже говорилось в первой и последующих статьях серии, это не значит, что от него нужно полностью отказываться. Это значит, что нужно быть рассудительным в том, сколько и как часто выделять память через GC. Чем меньше выделений памяти, тем меньше остаётся мест, где может начаться сборка мусора. Чем меньше памяти находится в куче сборщика мусора, тем меньше памяти ему нужно сканировать.
Невозможно точно и всеобъемлюще определить, в каких приложениях влияние GC будет ощутимым, а в каких нет — это очень сильно зависит от конкретной программы. Но можно смело сказать, что в большинстве приложений нет необходимости отключать GC временно или полностью, но когда это всё-таки нужно, то важно знать, как обходиться без него. Очевидное решение — выделять память на стеке, но D также позволяет выделять память в обычной куче, минуя GC.
Вездесущий Си
Хорошо это или плохо, C окружает нас повсюду. На сегодняшний день любая программа, на каком бы языке она бы ни была написана, на каком-то уровне наверняка обращается к API языка C. Несмотря на то, что спецификация C не определяет стандартного ABI, его платформо-зависимые причуды достаточно широко известны, чтобы большинство языков умело с ним взаимодействовать. Язык D — не исключение. На самом деле, все программы на D по умолчанию имеют доступ к стандартной библиотеке C.
Пакет core.stdc — набор модулей D, транслированных из заголовков стандартной библиотеки C. Когда линкуется исполняемый файл на D, вместе с ним линкуется и стандартная библиотека C. Чтобы получить к ней доступ, нужно только импортировать соответствующие модули.
import core.stdc.stdio : puts;
void main()
{
puts("Hello C standard library.");
}
Те, кто только начал знакомство с D, могут думать, что обращение к коду на C требует аннотации extern(C)
, или, после недавней статьи Уолтера Брайта D as a Better C [перевод], что код нужно компилировать с флагом -betterC
. Ни то, ни другое не верно. Обычные функции в D могут вызывать функции из C без всяких дополнительных условий, кроме как наличия extern(C)
в объявлении вызываемой функции. В примере выше объявление puts
находится в модуле core.stdc.stdio
— и это всё, что нам нужно, чтобы её вызвать.
malloc
и его друзья
Раз в D у нас есть стандартная библиотека C, значит, нам доступны функции malloc
, calloc
, realloc
и free
. Чтобы получить их в своё распоряжение, достаточно импортировать core.stdc.stdlib
. А благодаря магии срезов языка D использовать их для работы с памятью без GC проще простого.
import core.stdc.stdlib;
void main()
{
enum totalInts = 10;
// Выделить место под 10 значений типа int.
int* intPtr = cast(int*)malloc(int.sizeof * totalInts);
// assert(0) (и assert(false)) всегда остаются в исполняемом файле, даже
// если проверки assert выключены, что делет их удобными для обработки
// сбоев в malloc.
if(!intPtr) assert(0, "Out of memory!");
// Освобождает память на выходе из функции. В этом примере это
// необязательно, но полезно в других функциях, которые временно
// выделают память.
scope(exit) free(intPtr);
// Снять срез с указателя, чтобы получить более удобную
// пару указатель+длина.
int[] intArray = intPtr[0 .. totalInts];
}
Таким образом мы обходим не только GC, но и обычную для D инициализацию значениями по умолчанию. В массиве значений типа T
, выделенном через GC, все элементы были бы инициализированы значением T.init
— для int
это 0
. Если вы хотите имитировать стандартное поведение D, потребуются дополнительные усилия. В данном примере мы могли бы просто заменить malloc
на calloc
, но это будет корректно только для целых чисел. Например, float.init
— это float.nan
, а не 0.0f
. Позже мы к этому ещё вернёмся.
Конечно, чтобы сделать наш код более идиоматичным, мы должны обернуть malloc
и free
в специальные функции и работать уже только со срезами. Минимальный пример:
import core.stdc.stdlib;
// Выделяет бестиповый блок памяти, с которым можно работать через срез.
void[] allocate(size_t size)
{
// Результат malloc(0) зависит от имплементации (может вернуть null или какой-то адрес), но это явно не то, что мы хотим делать.
assert(size != 0);
void* ptr = malloc(size);
if(!ptr) assert(0, "Out of memory!");
// Возвращает срез с указателя, чтобы адрес был сцеплен с размером
// блока памяти.
return ptr[0 .. size];
}
T[] allocArray(T)(size_t count)
{
// Убедимся, что мы учитываем размер элементов массива!
return cast(T[])allocate(T.sizeof * count);
}
// Две версии deallocate для удобства
void deallocate(void* ptr)
{
// free handles null pointers fine.
free(ptr);
}
void deallocate(void[] mem)
{
deallocate(mem.ptr);
}
void main() {
import std.stdio : writeln;
int[] ints = allocArray!int(10);
scope(exit) deallocate(ints);
foreach(i; 0 .. 10) {
ints[i] = i;
}
foreach(i; ints[]) {
writeln(i);
}
}
Функция allocate
возвращает void[]
вместо void*
, потому что срез несёт в себе количество выделенных байт в своём свойстве length
. В нашем случае, поскольку мы выделяем память под массив, мы могли бы из allocate
возвращать указатель, а в allocArray
уже снимать с него срез, но тогда каждому, кто вызывал бы allocate
напрямую, пришлось бы учитывать размер блока памяти. То, что в C длина массива отделена от него самого, — источник большого количества ошибок, и чем раньше мы их объединим, тем лучше. Дополните наш пример обёртками для calloc
и realloc
, и вы получите заготовку для менеджера памяти, основанного на куче языка C.
К слову, предыдущие три примера (да, даже шаблон allocArray
) работают и -betterC
, и без него. Но в дальнейшем мы будем придерживаться обычного кода на D.
Чтобы не текло, как из-под крана
Когда вы работаете со срезами памяти, расположенной за пределами GC, будьте осторожны с добавлением новых элементов, конкатенацией и изменением размера. По умолчанию, операторы дополнения ~=
и конкатенации ~
, применённые к динамическим массивам и срезам, выделяют память через GC. Конкатенация всегда выделяет новый блок памяти для объединённого массива (или строки). Оператор дополнения обычно выделяет память только если это требуется. Как показывает следующий пример, это требуется всегда, когда дан срез памяти за пределами GC.
import core.stdc.stdlib : malloc;
import std.stdio : writeln;
void main()
{
int[] ints = (cast(int*)malloc(int.sizeof * 10))[0 .. 10];
writeln("Capacity: ", ints.capacity);
// Сохранить указатель на массив для сравнения
int* ptr = ints.ptr;
ints ~= 22;
writeln(ptr == ints.ptr);
}
Должно вывести следующее:
Capacity: 0
false
Ёмкость 0
указывает, что добавление следующего элемента вызовет выделение ресурсов. Массивы, выделенные через GC, обычно имеют свободное место сверх запрошенного, так что добавление элементов может произойти без выделения новой памяти. Это свойство отвечает скорее за память, на которую указывает массив, нежели за сам массив. GC ведёт внутренний учёт того, сколько элементов может храниться в срезе до того, как потребуется выделение новой памяти. В нашем примере, поскольку место под ints
было выделено не через GC, никакого учёта не происходит, поэтому добавление следующего элемента обязательно вызовет выделение памяти (см. статью Стивена Швайхоффера D slices за дополнительной информацией).
Это нормально, когда именно этого вы и хотите, но если вы этого не ожидаете, то легко столкнётесь с утечкой памяти из-за того, что выделяете память через malloc
и никогда её не освобождаете.
Взгляните на эти две функции:
void leaker(ref int[] arr)
{
...
arr ~= 10;
...
}
void cleaner(int[] arr)
{
...
arr ~= 10;
...
}
Несмотря на то, что массив — тип с семантикой ссылок, то есть изменение существующих элементов массива внутри функции изменит их и в оригинальном массиве, в функции они передаются по значению. Всё, что влияет на структуру массива (например, изменение свойств length
и ptr
) повлияет только на локальную переменную внутри функции. Оригинальный массив не изменится — если его не передали по ссылке.
Если передать в leaker
массив, выделенный в куче языка C, то добавление нового элемента приведёт к выделению нового массива через GC. Хуже того: если после этого освободить память, передав в free
его свойство ptr
(которое теперь уже указывает на адрес в куче, управляемой GC, а не в куче языка C), то мы попадём на территорию неопределённого поведения. Зато с функцией cleaner
всё нормально. Любой массив, переданный в неё, останется неизменным. Внутри неё произойдёт выделение памяти через GC, но свойство ptr
исходного массива всё ещё будет указывать на первоначальный блок памяти.
Пока вы не перезаписываете исходный массив и не выпускаете его из области видимости, проблем не будет. Функции вроде cleaner
могут что угодно делать со своим локальным срезом, и снаружи всё будет в порядке. Если вы хотите избежать выделений памяти, то вы можете повесить на функции, к которым у вас есть доступ, атрибут @nogc
. Если это невозможно или нежелательно, то либо сохраняйте отдельно указатель, возвращаемый malloc
, чтобы потом передать его в free
, либо напишите собственные функции для дополнения и конкатенации, либо пересмотрите свою стратегию выделения памяти.
Обратите внимание на тип Array
из модуля std.container.array
: он не зависит от GC, и может быть полезно использовать его, чем управлять памятью вручную.
Другие API
Стандартная библиотека C — не единственный игрок на поле выделения памяти в куче. Существует несколько альтернативных реализаций malloc
, и любую из них можно использовать. Потребуется вручную скомпилировать исходники и слинковать с получившимися объектами, но это не неподъёмная задача. Также можно воспользоваться системными API: например, в Win32 доступна функция HeapAlloc (просто импортируйте core.sys.windows.windows
). Если есть указатель на блок памяти, то вы всегда можете снять с него срез и использовать в программе на D так же, как если бы вы получили его через GC.
Агрегатные типы
Если бы нас волновало только выделение массивов, то мы могли бы сразу перейти к следующему разделу. Однако нам нужно разобраться также со структурами и классами. В этой статье мы сфокусируемся только на структурах. Следующие несколько статей в этой серии будут посвящены исключительно классам.
Выделить память под один экземпляр структуры или целый массив зачастую не сложнее, чем с типом int
.
struct Point { int x, y; }
Point* onePoint = cast(Point*)malloc(Point.sizeof);
Point* tenPoints = cast(Point*)malloc(Point.sizeof * 10);
Идиллия разрушается, когда имеется конструктор. Функция malloc
и её друзья не умеют создавать объекты языка D. К счастью, Phobos предоставляет шаблонную функцию, которая умеет это делать.
Функция std.conv.emplace
принимает либо указатель на типизированную память, либо void[]
, а также опциональные аргументы, и возвращает указатель на полностью готовый экземпляр этого типа. Следующий пример показывает, как использовать emplace
и с malloc
, и с нашей функцией allocate
из предыдущих примеров:
struct Vertex4f
{
float x, y, z, w;
this(float x, float y, float z, float w = 1.0f)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
}
void main()
{
import core.stdc.stdlib : malloc;
import std.conv : emplace;
import std.stdio : writeln;
Vertex4f* temp1 = cast(Vertex4f*)malloc(Vertex4f.sizeof);
Vertex4f* vert1 = emplace(temp1, 4.0f, 3.0f, 2.0f);
writeln(*vert1);
void[] temp2 = allocate(Vertex4f.sizeof);
Vertex4f* vert2 = emplace!Vertex4f(temp2, 10.0f, 9.0f, 8.0f);
writeln(*vert2);
}
Функция emplace
также инициализирует все переменные значениями по умолчанию. Помните, что структуры в D не обязательно имеют конструктор. Вот что будет, если мы уберём конструктор из реализации Vertex4f
:
struct Vertex4f
{
// x, y и z инициализируются значением float.nan
float x, y, z;
// w инициализируется значением 1.0f
float w = 1.0f;
}
void main()
{
import core.stdc.stdlib : malloc;
import std.conv : emplace;
import std.stdio : writeln;
Vertex4f vert1, vert2 = Vertex4f(4.0f, 3.0f, 2.0f);
writeln(vert1);
writeln(vert2);
auto vert3 = emplace!Vertex4f(allocate(Vertex4f.sizeof));
auto vert4 = emplace!Vertex4f(allocate(Vertex4f.sizeof), 4.0f, 3.0f, 2.0f);
writeln(*vert3);
writeln(*vert4);
}
Программа выведет следующее:
Vertex4f(nan, nan, nan, 1)
Vertex4f(4, 3, 2, 1)
Vertex4f(nan, nan, nan, 1)
Vertex4f(4, 3, 2, 1)
Итак, emplace
позволяет инициализировать созданные в куче структуры таким же образом, что и созданные на стеке — с конструктором или без него. Она также работает со встроенными типами вроде int
и float
. Также у этой функции есть версия, предназначенная для классов, но к этому мы вернёмся в следующей статье. Только всегда помните, что emplace
создаёт один экземпляр, а не массив экземпляров.
std.experimental.allocator
Весь предыдущий текст описывает основы создания собственного менеджера памяти. Во многих случаях лучше воздержаться от того, чтобы лепить что-то самому, и вместо этого воспользоваться пакетом std.experimental.allocator
из стандартной библиотеки D. Это высокоуровневое API, которое использует низкоуровневые техники вроде тех, что описаны выше, а также парадигму проектирования через интроспекцию (Design by Introspection), чтобы облегчить создание аллокаторов различных типов, которые умеют выделять память под экземпляры типов и целые массивы, производить инициализацию и вызов конструкторов. Аллокаторы вроде Mallocator
и GCAllocator
можно либо использовать напрямую, либо комбинировать с другими строительными блоками, когда нужно что-то специфическое. Реальный пример их использования — библиотека emsi-containers.
Держим GC в курсе
Поскольку обычно не рекомендуется отключать GC полностью, большинство программ на D, которые выделяют память за пределами GC, сочетают её с памятью, выделенной через GC. Чтобы сборщик мусора мог корректно работать, он должен знать обо всех внешних ссылках на память из GC. Например, основанный на malloc
связный список может содержать ссылки на экземпляры классов, созданные через new
.
GC можно известить об этом при помощи GC.addRange
.
import core.memory;
enum size = int.sizeof * 10;
void* p1 = malloc(size);
GC.addRange(p1, size);
void[] p2 = allocate!int(10);
GC.addRange(p2.ptr, p2.length);
Когда блок памяти больше не нужен, можно вызвать соответствующую функцию GC.removeRange
, чтобы предотвратить его сканирование. Этим вы не освобождаете этот блок памяти. Это нужно сделать вручную через free
или интерфейс аллокатора, который вы для него использовали. Обязательно прочитайте документацию, прежде чем использовать эти функции.
Поскольку мы выделяем память за пределами GC во многом для того, чтобы уменьшить количество сканируемой памяти во время сборки мусора, то может показаться, что всё это обесценивает наши старания. Но так думать неправильно. Если за пределами сборщика мусора хранятся ссылки на память из него, то жизненно важно, чтобы он об этом знал. Иначе GC может освободить память, на которую всё ещё есть ссылки. Функция addRange
предназначена специально для таких ситуаций. Если есть уверенность, что блок внешней памяти не содержит ссылок на объекты из GC, то addRange
вызывать не нужно.
Предупреждение
Будьте внимательны при использовании addRange
. Поскольку эта функция реализована в стиле C и принимает указатель на блок памяти вместе с количеством байт в нём, то здесь можно ошибиться.
struct Item { SomeClass foo; }
auto items = (cast(Item*)malloc(Item.sizeof * 10))[0 .. 10];
GC.addRange(items.ptr, items.length);
GC будет сканировать блок памяти в 10 байт. Свойство length
возвращает количество элементов в срезе массива. Это не то же самое, что и общий размер этих элементов в байтах — если только это не срез типа void[]
(или срез элементов размером в один байт, таких как byte
и ubyte
). Правильно будет так:
GC.addRange(items.ptr, items.length * Item.sizeof);
Пока в API рантайма не появится альтернатива, лучше написать для этой функции обёртку, принимающую параметр типа void[]
.
void addRange(void[] mem)
{
import core.memory;
GC.addRange(mem.ptr, mem.length);
}
Тогда вызов addRange(items)
будет делать всё правильно. Неявное преобразование среза в тип void[]
означает, что mem.length
будет выдавать тот же результат, что items.length * Item.sizeof
.
Цикл статей о GC продолжается
Эта статья осветила самые основы того, как использовать кучу, не прибегая к GC. Помимо классов, в нашем рассказе остался ещё один зияющий пробел: что делать с деструкторами. Я сохраню эту тему для следующей статьи, где она будет очень к месту. Вот что запланировано для следующей из цикла статей о GC. Оставайтесь на связи!
Спасибо Уолтеру Брайту (Walter Bright), Гийому Пьола (Guillaume Piolat), Адаму Руппу (Adam D. Ruppe) и Стивену Швайхофферу (Steven Schveighoffer) за неоценимую помощь в подготовке этой статьи.
К сожалению, следующих статей мы до сих пор не дождались. На момент написания этой серии в языке ожидались некоторые изменения, касающиеся деструкторов, поэтому автор решил повременить со следующей статьёй. С появлением в APIcore.memory.GC
функцииinFinalizer
вопрос можно считать более или менее решённым, и Майкл обещает взяться за продолжение, как только появится время.