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

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


  1. GC запускается только тогда, когда вы запрашиваете выделение памяти. Вопреки расхожему заблуждению, GC языка D не может просто взять и поставить на паузу ваш клон Майнкрафта посреди игрового цикла. Он запускается только когда вы запрашиваете память через него и только тогда, когда это необходимо.


  2. Простые стратегии выделения памяти в стиле C и C++ позволяют уменьшить нагрузку на GC. Не выделяйте память внутри циклов — вместо этого как можно больше ресурсов выделяйте заранее или используйте стек. Сведите к минимуму общее число выделений памяти через GC. Эти стратегии работают благодаря пункту № 1. Разработчик может диктовать, когда допустимо запустить сборку мусора, грамотно используя выделение памяти из кучи, управляемой GC.



Стратегии из пункта № 2 подходят для кода, который программист пишет сам, но они не особенно помогут, когда речь идёт о сторонних библиотеках. В этих случаях при помощи механизмов языка D и его рантайма можно гарантировать, что в критических местах кода никакого выделения памяти не произойдёт. Также есть параметры командной строки, которые помогают убедиться, что GC не встанет на вашем пути.


Давайте представим, что вы пишете программу на D и по тем или иным причинам решили полностью исключить сборку мусора. У вас есть два очевидных решения.


Таблетка от жадности


Первое решение — вызвать GC.disable при запуске программы. Выделение памяти через GC всё ещё будет работать, но сборка мусора остановится. Вся сборка мусора, включая ту, что могла произойти в других потоках.


void main() {
    import core.memory;
    import std.stdio;
    GC.disable;
    writeln("Goodbye, GC!");
}

Вывод:


Goodbye, GC!

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


Сборка мусора всё может произойти, если это необходимо для дальнейшей корректной работы программы, например в случае нехватки свободной памяти.

Насколько это плохо, зависит от конкретного случая. Если для вас такое ограничение приемлемо, то есть ещё кое-какие инструменты, которые помогут держать всё под контролем. Вы можете по необходимости вызывать GC.enable и GC.collect. Такая стратегия позволяет контролировать циклы освобождения ресурсов лучше, чем простые техники из C и C++.


Антимусоросборочная стена


Когда запуск сборщика мусора абсолютно недопустим, вы можете обратиться к атрибуту @nogc. Повесь его на main, и минует тебя сборка мусора.


@nogc
void main() { ... }

Это окончательное решение вопроса GC. Атрибут @nogc, применённый к main, гарантирует, что сборщик мусора не запустится никогда и нигде на всём протяжении стека вызовов. Больше никаких подводных камней «если это необходимо для дальнейшей корректной работы программы».


Не первый взгляд такое решение кажется гораздо лучшим, чем GC.disable. Давайте попробуем.


@nogc
void main() {
    import std.stdio;
    writeln("GC be gone!");
}

На этот раз мы не продвинемся дальше компиляции:


Error: @nogc function 'D main' cannot call non-@nogc function 'std.stdio.writeln!string.writeln'
(Ошибка: @nogc-функция 'D main' не может вызвать не-@nogc–функцию 'std.stdio.writeln!string.writeln')

Сила атрибута @nogc в том, что компилятор не позволяет его обойти. Он работает очень прямолинейно. Если функция обозначена как @nogc, то любая функция, которую вы вызываете внутри неё, также должна быть обозначена как @nogc. Очевидно, что writeln это требование не выполняет.


И это ещё не всё:


@nogc 
void main() {
    auto ints = new int[](100);
}

Компилятор не спустит вам с рук и этого:


Error: cannot use 'new' in @nogc function 'D main'
(Ошибка: нельзя использовать 'new' в @nogc-функции 'D main')

Также внутри @nogc-функции нельзя использовать любые возможности языка, которые выделяют память через GC (их мы рассмотрели в предыдущей статье серии). Мир без сборщика мусора. Большое преимущество такого подхода в том, что он гарантирует, что даже сторонний код не может использовать эти возможности и выделять память через GC за вашей спиной. Недостаток же в том, что сторонние библиотеки, разработанные без @nogc, становятся для вас недоступны.


При таком подходе вам придётся прибегнуть к различным обходным путям, чтобы обойтись без несовместимых с @nogc возможностей языка и библиотечных функций, включая некоторые функции из стандартной библиотеки. Некоторые из них тривиальны, другие сложнее, а что-то и вовсе нельзя обойти (мы подробно всё это рассмотрим в будущих статьях). Неочевидный пример одной из таких вещей — исключения. Идиоматический способ породить исключение такой:


throw new Exception("Blah");

Из-за того, что здесь есть new, в @nogc-функции так написать нельзя. Чтобы обойти это ограничение, требуется заранее выделить место под все исключения, которые могут быть выброшены, а для этого требуется потом как-то освобождать эту память, из чего проистекают идеи об использовании для механизма исключений подсчёта ссылок или о выделении памяти на стеке… Короче говоря, это большой клубок проблем. Сейчас появилось предложение по улучшению D Уолтера Брайта, которое призвано распутать этот клубок и сделать так, чтобы throw new Exception работало без GC, когда это необходимо.


К сожалению, проблема использования исключений в @nogc-коде не решена до сих пор. (прим. пер.)

Справиться с ограничениями @nogc main — вполне выполнимая задача, она просто потребует немного мотивации и дисциплины.


Ещё одна вещь, которую стоит отметить: даже @nogc main не исключает GC из программы полностью. D поддерживает статические конструкторы и деструкторы. Первые срабатывают перед входом в main, а последние — после выхода из неё. Если они есть в коде и не обозначены как @nogc, то, технически, выделение памяти через GC и сборка мусора могут происходить даже в @nogc-программе. Тем не менее, атрибут @nogc, применённый к main, означает, что на протяжение работы main сборка мусора запускаться не будет, так что по сути это то же самое, что не иметь никакого GC.


Добиваемся хорошего результата


Здесь я выскажу мнение. Существует широких спектр программ, которые можно написать на D, не отключая GC на время и не отказываясь от него полностью. Очень много можно добиться, минимизируя выделение памяти через GC и исключая его из горячих точек кода — и именно так и следует делать. Я не устаю это повторять, потому что часто неправильно понимают, как происходит сборка мусора в языке D: она может запуститься только когда программист выделяет память через GC и только когда это нужно. Используйте это знание с пользой, выделяя мало, редко и за пределами внутренних циклов.


В тех программах, где действительно нужен полный контроль, возможно, нет необходимости полностью отказываться от GC. Рассудительное использование @nogc и/или API core.memory.GC зачастую позволяет избежать любых проблем с производительностью. Не вешайте атрибут @nogc на main, повесьте его на функции, где точно нужно запретить выделение памяти через GC. Не вызывайте GC.disable в начале программы. Вызывайте её перед критическим местом, а после него вызывайте GC.enable. Сделайте так, чтобы GC собирал мусор в стратегических точках (например, между уровнями игры), при помощи GC.collect.


Как и всегда в оптимизации производительности при разработке ПО, чем полнее вы понимаете, что происходит под капотом, тем лучше. Необдуманное использование API core.memory.GC может заставить GC выполнять лишнюю работу или не оказать никакого эффекта. Для лучшего понимания внутренних процессов вы можете использовать тулчейн D.


В скомпилированную программу (не компилятор!) можно передать параметр рантайма D --DRT-gcopt=profile:1, который поможет вам в тонкой настройке. Вы получите полезную информацию от профилировщика GC, такую как суммарное количество сборок мусора и суммарное время, затраченное на них.


В качестве примера: gcstat.d добавляет двадцать значений в динамический массив целых чисел.


void main() {
    import std.stdio;
    int[] ints;
    foreach(i; 0 .. 20) {
        ints ~= i;
    }
    writeln(ints);
}

Компиляция и запуск с параметром профилировщика GC:


dmd gcstat.d
gcstat --DRT-gcopt=profile:1
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
        Number of collections:  1
        Total GC prep time:  0 milliseconds
        Total mark time:  0 milliseconds
        Total sweep time:  0 milliseconds
        Total page recovery time:  0 milliseconds
        Max Pause Time:  0 milliseconds
        Grand total GC time:  0 milliseconds
GC summary:    1 MB,    1 GC    0 ms, Pauses    0 ms <    0 ms

Отчёт сообщает об одной сборке мусора, которая, по всей вероятности, произошла во время выхода из программы. Рантайм D завершает работу GC на выходе, что (в текущей реализации) обычно вызовет сборку мусора. Это делается главным образом для того, чтобы запустить деструкторы собранных объектов, хотя D и не требует, чтобы деструкторы объектов под контролем GC когда-либо вызывались (это тема для одной из следующих статей).


DMD поддерживает параметр командной строки -vgc, который отобразит каждое выделение памяти через GC в вашей программе — включая те, что спрятаны за возможностями языка, такими как оператор присоединения ~=.


В качестве примера: взгляните на inner.d.


void printInts(int[] delegate() dg)
{
    import std.stdio;
    foreach(i; dg()) writeln(i);
} 

void main() {
    int[] ints;
    auto makeInts() {
        foreach(i; 0 .. 20) {
            ints ~= i;
        }
        return ints;
    }

    printInts(&makeInts);
}

Здесь makeInts — внутренняя функция. Указатель на нестатическую внутреннюю функцию является не указателем на функцию, а делегатом, то есть парным указателем на функцию/контекст (если внутренняя функция обозначена как static, то вместо типа delegate вы получаете тип function). В этом конкретном случае делегат обращается к переменной в родительской области видимости.


Вот вывод компилятора с опцией -vgc:


dmd -vgc inner.d
inner.d(11): vgc: operator ~= may cause GC allocation
inner.d(7): vgc: using closure causes GC allocation

(inner.d(11): vgc: оператор ~= может вызвать выделение памяти через GC)
(inner.d(7): vgc: использование замыкания может вызвать выделение памяти через GC)

Здесь мы видим, что нужно выделить память, чтобы делегат мог нести в себе состояние ints, что делает его замыканием (которое не является отдельным типом — тип по-прежнему delegate). Переместите объявление ints внутрь области видимости makeInts и скомпилируйте снова. Вы увидите, что выделение памяти из-за замыкания пропало. Ещё лучше изменить объявление printInts таким образом:


void printInts(scope int[] delegate() dg)

Добавление scope к параметру функции гарантирует, что никакие ссылки в этом параметре не могут выйти за пределы функции. Иначе говоря, становится невозможным присвоить делегат dg глобальной переменной или вернуть его из функции. В результате отпадает необходимость создавать замыкание, так что никакого выделения памяти не будет. См. в документации об указателях на функции, делегатах и замыканиях, а также о классах хранения параметров функций.


Резюме


Учитывая, что GC языка D сильно отличается от того, что есть в языках вроде Java и C#, его производительность будет иметь другую характеристику. Кроме того, программы на D как правило производят гораздо меньше мусора, чем программы на языках вроде Java, где почти все типы имеет ссылочную семантику. Полезно это понимать, приступая к своему первому проекту на D. Стратегии, которые применяют опытные программисты на Java, чтобы уменьшить влияние GC на производительность, здесь вряд ли применимы.


Хотя определённо существует программы, где паузы на сборку мусора абсолютно неприемлемы, их, пожалуй, меньшинство. В большинстве проектов на D можно и нужно начинать с простых приёмов из пункта № 2 в начале статьи, а затем адаптировать код к использованию @nogc и core.memory.GC там, где требуется производительность. Параметры командной строки, представленные в этой статье, помогут найти места, где это может быть необходимо.


Чем больше времени проходит, тем проще становится управлять сборщиком мусора в программах на D. Идёт организованная работа над тем, чтобы сделать Phobos — стандартную библиотеку D — как можно более совместимой с @nogc. Улучшения языка, такие как предложение Уолтера о выделении памяти под исключения, должны значительно ускорить этот процесс.


В будущих статьях мы рассмотрим, как выделять память, не прибегая к GC, и использовать её параллельно с памятью из GC, чем заменить недоступные в @nogc-коде возможности языка и многое другое.


Спасибо Владимиру Пантелееву, Гильяму Пьола (Guillaume Piolat) и Стивену Швайхофферу (Steven Schveighoffer) за ценные отзывы о черновике этой статьи.