Управление и уборка в 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 в РФ?
    Поделиться публикацией

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

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

      0
      не поскажете, как заставить valgrind деманглить имена D?
        +1
        как и gdb — никак)
          0
          В gdb почти полная поддержка D mangling, нужно только использовать свежую версию, с патчами от Iain Buclaw.
            0
            Прошу прощения! Вы правы, 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), в остальном всё, кажется, в порядке. Возможно из-за того, что очень часто пользуюсь шаблонами и не заметил, что в остальных случаях всё работает.
              0
              Ошибки в demangling можно репортить через github.com/ibuclaw/gdb (стоит предварительно проверить на мастер версии)
        0
        Не стоит указывать аттрибуты шаблонным функциям, если они будут вызываться из @nogc кода, при этом внутри них будут вызываться тоже функции, помеченные как @nogc, компилятор инстацирует их соответствующим образом. Это поможет в случае повторного использования кода, когда внутри шаблонной функции будет вызываться не @nogc функция.

        Что-то тут явно противоречие. Почему не стоит указывать атрибуты, если дальше как раз аргументация в пользу того, что стоит? Или я не понимаю, о чем здесь говорится.
          0
          наверное имеется ввиду что не стоит указывать @nogc в шаблоне, т.к. это перекроет инстанцирование шаблона для не- @nogc функций.
            0
            Для шаблонных функций не стоит указывать, чтобы сделать их более гибкими в использовании, необходимые атрибуты могут вычислиться компилятором в зависимости от контекта вызова (инстанцирования) функции. Для обычных функций нужно указывать.
            Пример
            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() );
            }
            

              0
              Теперь все ясно. Стоит переформулировать в тексте, что не нужно ставить @nogc шаблонам — компилятор сам вычислит его необходимость: если все вызываемые из шаблонной функции @nogc и вызывающая @nogc (а может, это требование не нужно?), то и шаблонная будет @nogc. А если хотя бы одно из правил не выполняется, то шаблонная не будет @nogc.
                0
                поправил
            0
            еще вопрос из зала «Он же управляет выделением памяти. Его используют реализации таких встроенных типов как ассоциативные и динамические массивы, строки (что тоже массивы), исключения, делегаты.». как вернуть системе память выделенную под класс или массивы без GC — понятно. Непонятно про «делегаты». Для них выделяется память под контекст? И как эту память можно вернуть системе в случае если GC отключен?
              0
              Делегаты выделяют память, если требуется замыкание — перенос значения со стека, если оно используется возвращаемый из функции делегатом. Без GC замыканий лучше избегать.
                0
                Cпасибо!

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

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

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

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

                Рассматривается вариант настраиваемых глобальных аллокаторов для new / delete, но я практически уверен, что это никогда не будет работать надёжно «из коробки».
              0
              Я мог-бы перевести одну или несколько статей по D, если в этом есть интерес. Если есть ссылки — присылайте, может что-то выйдет.
                0
                Может лучше всё же не переводить, а адаптировать интересную информацию под пользователей хабра?

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое