Серия статей о GC
  1. Don’t Fear the Reaper
  2. Life in the Fast Lane
  3. Go Your Own Way. Часть первая: Стек
  4. Go Your Own Way. Часть первая: Куча

D, как и многие активно используемые сегодня языки, поставляется со сборщиком мусора (Garbage Collector, GC). Многие виды ПО можно разрабатывать, вообще не задумываясь о GC, в полной мере пользуясь его преимуществами. Однако у GC есть свои изъяны, и в некоторых сценариях сборка мусора нежелательна. Для таких случаев язык позволяет временно отключить сборщик мусора или даже совсем обойтись без него.


Чтобы извлечь максимум пользы от сборщика мусора и свести его недостатки к минимуму, необходимо хорошо понимать, как работает GC в языке D. Хорошей точкой старта будет страничка «Garbage Collection» на dlang.org, которая подводит обоснование под GC в языке D и даёт несколько советов о том, как с ним работать. Это — первая из серии статей, которая призвана более подробно осветить тему.


В этот раз мы коснёмся только самых основ, сосредоточившись на функциях языка, которые могут вызвать выделение памяти через GC. Будущие статьи представят способы отключить GC при необходимости, а также идиомы, помогающие справляться с его недетерминированностью (например, управление ресурсами в деструкторах объектов, находящихся под контролем GC).


Самая первая вещь, которую нужно уяснить: сборщик мусора в D запускается только во время выделения памяти и только в том случае, если нет памяти, которую можно выделить. Он не сидит в фоне, периодически сканируя кучу и собирая мусор. Это необходимо понимать, чтобы писать код, эффективно использующий память под контролем GC. Рассмотрим следующий пример:


void main() {
    int[] ints;
    foreach(i; 0..100) {
        ints ~= i;
    }
}

Эта программа создаёт динамический массив значений типа int, а затем при помощи имеющегося в D оператора присоединения добавляет в него числа от 0 до 99 в цикле foreach. Что неочевидно неопытному глазу, так это то, что оператор присоединения выделяет память для добавляемых значений через сборщик мусора.


Реализация динамического массива в рантайме D вовсе не тупая. В нашем примере не произойдёт сотни выделений памяти, по одному на каждое значение. Когда требуется больше памяти, массив выделяет больше памяти, чем запрашивается. Мы можем определить, сколько на самом деле будет выделений памяти, задействовав свойство capacity. Это свойство возвращает количество элементов, которые можно поместить в массив, прежде чем потребуется выделение памяти.


void main() {
    import std.stdio : writefln;
    int[] ints;
    size_t before, after;
    foreach(i; 0..100) {
        before = ints.capacity;
        ints ~= i;
        after = ints.capacity;
        if(before != after) {
            writefln("Before: %s After: %s",
                before, after);
        }
    }
}

Будучи скомпилированная в DMD 2.073.2, эта программа выдаёт шесть сообщений — шесть выделений памяти через GC. Значат, у GC шесть раз была возможность собрать мусор. В нашем маленьком примере это вряд ли произошло. Но если бы этот цикл был частью большой программы, использующей GC, то сборка мусора вполне могла бы произойти.


Кроме того, любопытно взглянуть на значения before и after. Программа выдаёт последовательность: 0, 3, 7, 15, 31, 63, и 127. После выполнения цикла массив ints содержит 100 значений, и в нём есть место под ещё 27 значений, прежде чем произойдёт следующее выделение памяти, которое увеличит объём массива до 255, экстраполируя предыдущие значения. Это, однако, уже детали реализации рантайма D, и в будущих релизах всё может поменяться. Чтобы узнать больше о том, как GC контролирует массивы и срезы, взгляните на прекрасную статью Стива Швайхоффера (Steve Schveighoffer) на эту тему.


Итак, шесть выделений памяти, шесть возможностей для GC приостановить выполнение кода на неопределённое время даже внутри этого маленького цикла. Насколько это плохо, зависит от того, находится ли цикл в «горячей» части кода и сколько всего памяти выделяется через сборщик мусора. Но даже в худшем случае необязательно отключать GC в этой части программы.


Даже когда речь идёт о языках без встроенного сборщика мусора, таких как C и C++, большинство программистов рано или поздно узнают, что для общей производительности лучше заранее выделить как можно больше ресурсов и свести к минимуму выделение памяти во внутренних циклах. Это одна из многих преждевременных оптимизаций, которые не являются корнем всех зол — то, что мы называем лучшими практиками. Учитывая, что GC в языке D запускается только когда происходит выделение памяти, ту же самую стратегию можно применять как простой способ минимизировать его влияние на производительность. Вот как можно переписать пример:


void main() {
    int[] ints = new int[](100);
    foreach(i; 0..100) {
        ints[i] = i;
    }
}

Мы сократили шесть выделений памяти до одного. Единственное место, где может произойти сборка мусора — перед внутренним циклом. Этот код выделяет место для по крайней мере 100 элементов и инициализирует их нулями перед входом в цикл. После new длина массива будет 100, но в нём почти наверняка будет дополнительная ёмкость.


Есть также другой способ: функция reserve:


void main() {
    int[] ints;
    ints.reserve(100);
    foreach(i; 0..100) {
        ints ~= i;
    }
}

Программа выделит память под по крайней мере 100 значений, но массив всё ещё будет пустым (его свойство length будет возвращать 0), так что ничего не будет инициализировано значениями по умолчанию. Учитывая, что цикл добавляет только 100 значений, гарантируется, что выделения памяти не произойдёт.


Помимо new и reserve, можно также выделять память явным образом, напрямую вызывая GC.malloc.


import core.memory;
void* intsPtr = GC.malloc(int.sizeof * 100);
auto ints = (cast(int*)intsPtr)[0 .. 100];

Литералы массивов обычно выделяют память.


auto ints = [0, 1, 2];

То же самое происходит, когда литерал массива используется в enum.


enum intsLiteral = [0, 1, 2];
auto ints1 = intsLiteral;
auto ints2 = intsLiteral;

Значение типа enum существует только во время компиляции и не имеет адреса в памяти. Его имя — синоним его значения. Где бы вы его не использовали, это будет как если бы вы скопировали и вставили его значение на месте его имени. И ints1, и ints2 вызовут выделение памяти, как если бы мы определили их вот так:


auto ints1 = [0, 1, 2];
auto ints2 = [0, 1, 2];

Литералы массивов не выделяют память, если они используются для заполнения статического массива. Кроме того, строковые литералы (строки в D — это массивы) — исключение из общего правила и также не выделяют память.


int[3] noAlloc1 = [0, 1, 2];
auto noAlloc2 = "No Allocation!";

Оператор конкатенации всегда выделяет память:


auto a1 = [0, 1, 2];
auto a2 = [3, 4, 5];
auto a3 = a1 ~ a2;

У ассоциативных массивов в D своя стратегия выделения памяти, но можно ожидать, что они будут выделять память при добавлении элементов и при их удалении. Также они реализуют два свойства: keys и values, которые выделяют память под массив и заполняют его копиями соответствующих элементов. Когда ассоциативный массив нужно изменять во время того, как вы по нему итерируете, или если нужно отсортировать элементы или ещё каким-то образом обработать их в отрыве от массива, эти свойства — то что доктор прописал. Но в других случаях это лишь лишнее выделение памяти, чрезмерно нагружающее GC.


Когда сборка мусора всё-таки запускается, время, которое она займёт, будет зависеть от объёма сканируемой памяти. Чем меньше, тем лучше. Никогда не будет лишним избегать ненужных выделений памяти, и это ещё один хороший способ минимизировать влияние сборщика мусора на производительность. Как раз для этого у ассоциативных массивов в D есть три свойства: byKey, byValue и byKeyValue. Каждое из них возвращает диапазон, который можно итерировать ленивым образом. Они не выделяют память, поскольку напрямую обращаются к элементам массива, поэтому не следует его изменять во время итерирования. Более подробно о диапазонах можно прочитать в главах Ranges и More Range из книги Али Чехрели (Ali Cehreli) Programming in D.


Замыкания — делегаты или функции, которые должны нести в себе указатель на фрейм стека — также выделяют память. Последняя возможность языка, упомянутая на страничке Garbage Collection — выражение assert. Если проверка проваливается, выражение assert выделяет память, чтобы породить AssertError, которое является частью иерархии исключений языка D, основанной на классах (в будущих статьях мы рассмотрим, как классы взаимодействуют с GC).


И наконец, есть Phobos — стандартная библиотека D. Когда-то давным-давно большая часть Phobos’а была реализована без особой заботы о GC, отчего его трудно было использовать в ситуациях, когда сборка мусора нежелательна. Однако потом было приложено много усилий, чтобы сделать Phobos более сдержанным в обращении с GC. Некоторые функции были переделаны, чтобы они могли работать с ленивыми диапазонами, другие были переписаны, чтобы они принимали буфер, а некоторые были переработаны, чтобы избежать лишних выделений памяти под капотом. В результате стандартная библиотека стала гораздо более пригодной для написания кода без GC (хотя, возможно, ещё остаются места, которые ещё предстоит обновить — пулл-реквесты всегда приветствуются).


Теперь, когда мы разобрались с основами работы с GC, в следующей статье в этой серии мы посмотрим, какие инструменты в языке и компиляторе позволят нам отключать сборщик мусора и гарантировать, что критические места программы не обращаются к GC.


Спасибо Гийому Пьола (Guillaume Piolat) и Стиву Швайхофферу за их помощь в подготовке данной статьи.