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

Все мы знаем, что в 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 в РФ?

Комментарии (19)


  1. ik62
    12.06.2015 08:21

    не поскажете, как заставить valgrind деманглить имена D?


    1. deviator Автор
      12.06.2015 12:47
      +1

      как и gdb — никак)


      1. Dicebot
        15.06.2015 00:24

        В gdb почти полная поддержка D mangling, нужно только использовать свежую версию, с патчами от Iain Buclaw.


        1. deviator Автор
          15.06.2015 03:12

          Прошу прощения! Вы правы, demangling в gdb действительно есть, но он не полный. Для проверки взял старый проект, чуть-чуть поправил, чтобы он упал в нужном месте и вот такой стек получил:

          #0  0x000000000084b20f in draw.TestScene.prepareAbstractModel() (this=0x7ffff45cc300) at src/draw.d:56
          #1  0x000000000084b06c in draw.TestScene.this(des.space.camera.Camera) (this=0x7ffff45cc300, cam=0x7ffff45cc158) at src/draw.d:39
          #2  0x000000000080aa85 in _D3des4util4arch3emm21ExternalMemoryManager57__T6newEMMTC4draw9TestSceneTC6camera18MouseControlCameraZ6newEMMMFC6camera18MouseControlCameraZC4draw9TestScene (
              this=0x7ffff45cc238, _param_0=0x7ffff45cc100) at descore/import/des/util/arch/emm.d:49
          #3  0x000000000084fa8c in window.MainWindow.prepare() (this=0x7ffff45cc200) at src/window.d:26
          #4  0x000000000069ea60 in des.app.base.DesApp.addWindow(des.app.base.DesWindow() delegate) (this=0x7ffff45cd780, winFunc=...) at des/import/des/app/base.d:318
          #5  0x000000000084f808 in D main () at src/main.d:11
          

          Не деманглится имя шаблонной функции (фрейм 2), в остальном всё, кажется, в порядке. Возможно из-за того, что очень часто пользуюсь шаблонами и не заметил, что в остальных случаях всё работает.


          1. Dicebot
            15.06.2015 10:18

            Ошибки в demangling можно репортить через github.com/ibuclaw/gdb (стоит предварительно проверить на мастер версии)


  1. Mingun
    12.06.2015 12:43

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

    Что-то тут явно противоречие. Почему не стоит указывать атрибуты, если дальше как раз аргументация в пользу того, что стоит? Или я не понимаю, о чем здесь говорится.


    1. ik62
      12.06.2015 13:01

      наверное имеется ввиду что не стоит указывать @nogc в шаблоне, т.к. это перекроет инстанцирование шаблона для не- @nogc функций.


    1. deviator Автор
      12.06.2015 13:12

      Для шаблонных функций не стоит указывать, чтобы сделать их более гибкими в использовании, необходимые атрибуты могут вычислиться компилятором в зависимости от контекта вызова (инстанцирования) функции. Для обычных функций нужно указывать.

      Пример
      import std.stdio;
      import std.typetuple;
      import std.range;
      
      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;
          return res / c.length;
      }
      
      struct Bar
      {
          float val;
          size_t cnt;
      
      @nogc: // все функции в этой структуре такие
          this( float n, size_t c ) { val = n; cnt = c; }
          void popFront() { cnt--; }
          float front() { return val; }
          size_t length() @property { return cnt; }
          bool empty() { return cnt == 0; }
      }
      
      float call_nogc() @nogc // указываем конкретно
      {
          // всё в порядке, avg инстанцируется как @nogc,
          // так как все вызовы в Bar @nogc
          return avg( Bar(1.0f,3), Bar(4.0f,6) );
      }
      
      struct Foo
      {
          float val;
          size_t cnt;
      
          // здесь вызовы обычные
          this( float n, size_t c ) { val = n; cnt = c; }
          void popFront() { cnt--; }
          float front() { return val; }
          size_t length() @property { return cnt; }
          bool empty() { return cnt == 0; }
      }
      
      // код с ошибочным вызовом
      //float foo_call_nogc() @nogc // функция помечена как @nogc
      //{
      //    return avg( Foo(1,10), Foo(8,2) );
      //
      //    пытается вызвать non-@nogc функции в Bar
      //    attrib.d(54): Error: @nogc function 'attrib.foo_call_nogc' cannot call non-@nogc function 'attrib.Foo.this'
      //    attrib.d(54): Error: @nogc function 'attrib.foo_call_nogc' cannot call non-@nogc function 'attrib.Foo.this'
      //    avg инстанцируется соответствующим образом, как non-@nogc
      //    attrib.d(54): Error: @nogc function 'attrib.foo_call_nogc' cannot call non-@nogc function 'attrib.avg!(Foo, Foo).avg'
      //}
      
      float foo_call()
      {
          return avg( Foo(1,10), Foo(8,2) ); // тут всё в порядке, мы повторно используем avg
      }
      
      void main()
      {
          writeln( call_nogc() );
          writeln( foo_call() );
      }
      


      1. Mingun
        15.06.2015 20:06

        Теперь все ясно. Стоит переформулировать в тексте, что не нужно ставить @nogc шаблонам — компилятор сам вычислит его необходимость: если все вызываемые из шаблонной функции @nogc и вызывающая @nogc (а может, это требование не нужно?), то и шаблонная будет @nogc. А если хотя бы одно из правил не выполняется, то шаблонная не будет @nogc.


        1. deviator Автор
          20.06.2015 06:19

          поправил


  1. ik62
    12.06.2015 17:45

    еще вопрос из зала «Он же управляет выделением памяти. Его используют реализации таких встроенных типов как ассоциативные и динамические массивы, строки (что тоже массивы), исключения, делегаты.». как вернуть системе память выделенную под класс или массивы без GC — понятно. Непонятно про «делегаты». Для них выделяется память под контекст? И как эту память можно вернуть системе в случае если GC отключен?


    1. Dicebot
      15.06.2015 00:30

      Делегаты выделяют память, если требуется замыкание — перенос значения со стека, если оно используется возвращаемый из функции делегатом. Без GC замыканий лучше избегать.


      1. ik62
        15.06.2015 10:55

        Cпасибо!

        вскрылся еще один неочевидный, для меня по крайней мере, нюанс: destroy() только вызывает деструктор, не освобождая память, занятую обьектом. Это означает, что при отключенном GC, например через "--DRT-gcopt=disabe:1", приходится полностью взять на себя управление памятью.

        Так-же это означает, что если автор какой-либо библиотеки не озаботился управлением памятью в своём коде, то библиотека не сможет работать при отключенном GC. Это не фатально, просто полезно это явно осознавать.



  1. Dicebot
    15.06.2015 11:00

    Да, destroy определён таким образом специально, чтобы можно было явно вызывать деструкторы GC-объектов, не компрометируя при этом безопасность автоматического управления памятью.

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


    1. deviator Автор
      15.06.2015 17:50

      Насчёт выделения памяти библиотеками. Насколько я понял, сейчас подготовлены аллокаторы для предоставления выбора способа выделения памяти? Или логический смысл этой библиотеки другой?


      1. Dicebot
        15.06.2015 18:06

        Это API и реализация базового набора аллокаторов + инструментов для их композиции. Предназначены они не столько для настройки выделения памяти в библиотеках (как, например, STL allocators), сколько для реализации эффективных стратегий работы с памятью в пользовательских приложения.

        Библиотеки (в идеале) должны вместо этого использовать range-based API и ленивые вычисления, чтобы откладывать принятие решений о способе аллокации как можно дольше.

        Рассматривается вариант настраиваемых глобальных аллокаторов для new / delete, но я практически уверен, что это никогда не будет работать надёжно «из коробки».


  1. ik62
    17.06.2015 18:37

    Я мог-бы перевести одну или несколько статей по D, если в этом есть интерес. Если есть ссылки — присылайте, может что-то выйдет.


    1. deviator Автор
      18.06.2015 02:36

      Может лучше всё же не переводить, а адаптировать интересную информацию под пользователей хабра?