Programming
D
12 June 2015

Управление и уборка в D

Доброго времени суток, хабр!

Все мы знаем, что в D используется сборщик мусора. Он же управляет выделением памяти. Его используют реализации таких встроенных типов как ассоциативные и динамические массивы, строки (что тоже массивы), исключения, делегаты. Так же его использование втроенно в синтаксис языка (конкатенация, оператор new). GC снимает с программиста ответственность и нагрузку, позволяет писать более компактный, понятный и безопасный код. И это, пожалуй, самые важные плюсы сборщика мусора. Стоит ли от этого отказываться? Расплатой за использование сборщика будут избыточное расходование памяти, что недопустимо при сильно ограниченных ресурсах и паузы всех потоков (stop-the-world) на как таковую сборку. Если эти пункты для Вас критичны добро пожаловать под кат.


Насколько всё плохо?


Первым делом нужно выяснить, а плохо ли вообще?
К слову
Грешить на встроенные средства стоит только после проверки своей архитектуры используемых и алгоритмов.

Можно воспользоваться valgrind, его инструмент memcheck (по умолчанию) покажет сколько раз программа выделяла и освобождала память, а так же её количество (строка total heap usage).
Но valgrind не сможет показать статистику использования GC. К счастью это встроенно в runtime D (dmd only). Сборщик мусора уже скомпилированной программы можно конфигурировать и профилировать следющим образом:
app "--DRT-gcopt=profile:1 minPoolSize:16" program args

Первый аргумент (строка) обрабатывается runtime'ом и не доходит до функции main.
Поддерживаемые параметры:
  • disable:0|1 — отключение сборщика
  • profile:0|1 — профилировка с выводом результата при завершении
  • initReserve:N — резервируемая при старте память (Мб)
  • minPoolSize:N — начальный и минимальный размер пула (Мб)
  • maxPoolSize:N — максимальный размер пула (Мб)
  • incPoolSize:N — шаг увеличения пула (Мб)
  • heapSizeFactor:N — отношение целевого размера кучи к используемой памяти

При включенной профилировке вывод программы после её завершения будет примерно такой:
	Number of collections:  101
	Total GC prep time:  10 milliseconds
	Total mark time:  3 milliseconds
	Total sweep time:  3 milliseconds
	Total page recovery time:  0 milliseconds
	Max Pause Time:  0 milliseconds
	Grand total GC time:  17 milliseconds
GC summary:   67 MB,  101 GC   17 ms, Pauses   13 ms <    0 ms


Жизнь без сборки (почти)


Если после всех тестов и настройки GC результат остаётся неудовлетворительным можно прибегнуть к некоторым ухищрениям.

Не нужен сборщик — не используй


Серьёзно? А так можно?
В критических секциях программы сборщик можно просто отключить:
import core.memory;
...
GC.disable();
...

А когда «будет время на уборку» включить обратно или сразу запустить:
...
GC.enable();
GC.collect(); // enable перед collect делать не обязательно, он сам включится
...

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

Используй правильные типы


Как уже упомяналось в начале статьи массивы, классы, делегаты не самые подходящие кандидаты на использование при стремлении уйти от GC.
Некоторые классы можно заменить структурами. В D структуры выделяются на стеке и уничтожаются при выходе из зоны видимости. Если без классов никуда, то можно использовать его только в области видимости:
import std.typecons;
...
auto cls = scoped!MyClass( param, of, my, _class );
...

Объект cls будет вести себя как экземпляр класса MyClass, но будет уничтожен при выходе из зоны видимости без участия GC. Стоит заменить, что ключевое слово scope для создания объектов классов хотят упразнить в пользу библиотечной реализации, вот обсуждение.

Диапазоны!


Отдельный виток и, как я понял, текущая тенденция развития стандартной библиотеки это переход на коцепцию диапазонов (range). Так сейчас работают практически все функции из std.algorithm. Диапазоны могут быть разные: входные, выходные, бесконечные, с длиной и тд
Смысл их в том, что это объекты (структуры), содержащие определённые методы, такие как front, popFront и тд. Более подробно о том, какие структуры могут выступать в роли диапазонов в стандартной библиотеке. Их преимущества это отложенность вычислений и никаких выделений памяти. Простой пример:
import std.stdio;
import std.typetuple;
import std.range;
import std.array;

template isIRWL(R) { enum isIRWL = isInputRange!R && hasLength!R; }

template FloatHandler(R) { enum FloatHandler = is( ElementType!R == float ); }

float avg(R1,R2)( R1 a, R2 b )
    if( allSatisfy!(isIRWL,R1,R2) && allSatisfy!(FloatHandler,R1,R2) )
{
    auto c = chain( a, b ); // соединяем в один диапазон
    float res = 0.0f;
    foreach( val; c ) res += val; // foreach одобряет)
    return res / c.length; // не все InputRange имеют длину
}

void main()
{
    float[] a = [1,2,3];
    float[] b = [4,5,6,7];
    writeln( avg( a, b ) ); // 4
    float[] d = chain( a, b ).array; // легко сделать массив
    writeln( d ); // [1,2,3,4,5,6,7]
}

Функция chain возвращает объект типа Result (локальный для функции), который в себе содержить 2 ссылки на диапазоны, которые были указаны на входе. При переборе этого объекта с помощью foreach вызываются методы front и popFront, а этот объект вызывает соответствующие методы сначала у первого диапазона, затем у второго, когда первый станет пустым.
Хорошая презентация на тему диапазонов была на DConf2015, автор Jonathan M Davis.

Если очень хочется классов


Да таких, что постоянно создаются и удаляются. В этом случае можно немного переоформить класс и использовать концепцию FreeList
class Foo
{
    static Foo freelist; // голова списка
    Foo next; // используется для реализации списка
    static Foo allocate()
    {
        Foo f;
        if( freelist ) // если у нас есть свободный объект
        {
            f = freelist; // берём его
            freelist = f.next;
        }
        else f = new Foo(); // иначе создаём новый
        return f;
    }
 
    static void deallocate(Foo f) // ненужный объект добавляем в список свободных
    {
        f.next = freelist;
        freelist = f;
    }
    ... тут основные методы класса ...
}

...
    Foo f = Foo.allocate();
    ...
    Foo.deallocate(f);

В этом случае мы минимизируем выделение памяти для новых объектов, если такие уже создавались и больше не нужны. Полностью от сборщика это нас не огораживает, но если память не выделять, то и сборщик не будет запускать сборку.
К слову
Выделять всю необходимую память лучше заранее, конечно, если такое возможно.


Жизнь без сборки (ну если только чуть-чуть)


Я не нашёл способа полноценно писать на D не используя сборщик, но это, отчасти, по моему мнению, и хорошо. Ручное управление памятью чревато ошибками, небезопасно, громоздко и тд (старый и злой С++). Но если сильно нужно, то можно.

Для ручного управления памятью используются функции из libc malloc и free. Для работы с массивами, это элементарно:
import core.stdc.stdlib;
...
auto arr = (cast(float*)malloc(float.sizeof*count))[0..count];
...
free( arr.ptr );
...


Чтобы оградить себя от нежелательного использования GC можно использовать аттрибут @nogc. Компилятор будет выдавать ошибку при обнаружении использования сборщика внутри блоков с таким аттрибутом.
void foo() {}
void func(int[] arr) @nogc
{
    auto a = new MyClass; // ошибка
    arr ~= 42; // ошибка
    foo(); // ошибка: вызов функции, не помеченной как @nogc
}

Для сохранения гибкости использования не стоит указывать аттрибуты шаблонным функциям. Если шаблонная функция будет вызываться из @nogc кода, компилятор постарается сделать её также @nogc. Для этого должно сохраняться условие, что внутри этой шаблонной функции используются только @nogc функции. Это поведение компилятора оказывается полезным в случае повторного использования шаблонного кода, когда шаблонная функция будет нужна при использовании сборщика (будет вызываться из обычного кода и будет использовать обычный код внутри себя). Это относится и к другим аттрибутам (nothrow, pure, etc).

При компиляции можно вывести все места в программе, где используется сборщик:
dmd -vgc source.d ...

Компилятор только укажет на места использования, но не будет генерировать ошибку.

Нужно помнить, что при создании потоков через стандартную библиотеку также используется сборщик. Для создания потоков без сборщика необходимо использовать C-шные функции, как и в случае с malloc и free.

И на последок: создание классов без сборщика


Небольшой пример с комментариями

import std.stdio;
import core.exception;
import core.stdc.stdlib : malloc, free;
import core.stdc.string : memcpy;
import core.memory : GC;

import std.traits;

class A
{
    int x;
    this( int X ) { x = X; }
    int foo() { return 2 * x; }
}

class B : A
{
    int z = 2;
    this( int x ) { super(x); }
    override int foo() { return 3 * x * z; }
}

// std.conv.emplace не сможет быть @nogc, поэтому переписан
T classEmplace(T,Args...)( void[] chunk, auto ref Args args )
    if( is(T == class) )
{
    enum size = __traits(classInstanceSize, T); // узнаём размер экземпляра класса

    // проверяем память, куда будем записывать
    if( chunk.length < size ) return null;
    if( chunk.length % classInstanceAlignment!T != 0 ) return null; 

    // объект TypeInfo хранит инициализирующее состояние класса в свойстве init, копируем его в память
    // кажется там только виртуальная таблица функций и статические поля класса, могу ошибаться
    memcpy( chunk.ptr, typeid(T).init.ptr, size );

    auto res = cast(T)chunk.ptr;

    // вызываем конструктор
    static if( is(typeof(res.__ctor(args))) )
        res.__ctor(args);
    else
        static assert(args.length == 0 && !is(typeof(&T.__ctor)),
                "Don't know how to initialize an object of type "
                ~ T.stringof ~ " with arguments " ~ Args.stringof);

    return res;

}

auto heapAlloc(T,Args...)( Args args )
{
    enum size = __traits(classInstanceSize, T);
    auto mem = malloc(size)[0..size];
    if( !mem ) onOutOfMemoryError();
    //GC.addRange( mem.ptr, size ); // об этом ниже
    return classEmplace!(T)( mem, args );
}

auto heapFree(T)( T obj )
{
    destroy(obj);
    //GC.removeRange( cast(void*)obj ); // и об этом тоже
    free( cast(void*)obj );
}

void main()
{
    auto test = heapAlloc!B( 12 );
    writeln( "test.foo(): ", test.foo() ); // 72
    heapFree(test);
}

Насчёт закоментированных строк GC.addRange() и GC.removeRange(). Если Вы твёрдо определились, что использовать сборщик не будете, то можно оставить их закоментированными. В случае, если внутри класса будут храниться массивы, делегаты, другие классы и тп которые должны убираться с помощью GC, то нужно добавить в GC диапазон памяти, который он будет сканировать в целях поиска мусора.

Если конструктор @nogc, то можно использовать heapAlloc в @nogc функции, с heapFree всё сложнее: destroy помимо вызовов деструкторов (что можно достаточно просто реализовать mixin'ом) ещё производит некоторые действия, связанные с монитором класса (конечно, если захотеть, то можно и их заменить на @nogc вариант).

Заключение



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

В этом плане мне показались интересными доклады Walter Bright и Andrei Alexandrescu с той же DConf2015.

PS. Почему на хабре ещё нет подсветки синтаксиса D?
PPS. Кто-нибудь в курсе намечаются ли конференции по D в РФ?

+13
7.5k 32
Comments 19
Top of the day