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

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

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



Решать мы будем задачу подсчёта ссылок на указатели, классы и массивы.
Сразу следует обговорить 2 момента: почему от GC в этой статье мы полностью не будем отказываться и почему подсчёт ссылок «полуавтоматический».
Полный отказ от GC подразумевает использование атрибута @nogc для всего кода, но тут есть одно НО. Интерфейс IAllocator, который мы будем использовать, позволяет создавать и уничтожать экземпляры класса правильно одной командой (правильно это с вызовом всех конструкторов и все деструкторов иерархии классов), но функции, которые это делают не помечены как @nogc и чтобы не раздувать статью мы не будем их реализовывать самостоятельно (отчасти в прошлой статье это было рассмотренно).
Подсчёт ссылок будет «полуавтоматическим», так как на данном этапе развития языка нельзя выполнить автоматически какой-то код при передаче или присвоении ссылочних типов (классы и массивы), поэтому этот код мы будет вызывать сами, но постараемся сделать это максимально скрыто.

Начнём с реализации allocator'а.
static this() { RC.affixObj = allocatorObject(RC.affix); } // оборачиваем в объект

struct RC
{
static:
    alias affix = AffixAllocator!(Mallocator,size_t,size_t).instance; // об этом ниже
    IAllocator affixObj;

    // сразу при создании инкрементируем счётчик
    auto make(T,A...)( auto ref A args )
    { return incRef( affixObj.make!T(args) ); }

    // напрямую мы не можем высвободить объект, только через уменьшение счётчика
    private void dispose(T)( T* p ) { affixObj.dispose(p); }

    private void dispose(T)( T p )
        if( is(T == class) || is(T == interface) )
    { affixObj.dispose(p); }

    // affix.prefix( void[] p ), где p -- область выделенной памяти,
    // а не просто указатель на неё, поэтому выглядит так некрасиво
    ref size_t refCount(T)( T p )
        if( is(T == class) || is(T == interface) )
    { return affix.prefix( (cast(void*)p)[0..__traits(classInstanceSize,T)] ); }

    ref size_t refCount(T)( T* p )
    { return affix.prefix( (cast(void*)p)[0..T.sizeof] ); }

    // увеличение счётчика, возвращаем объект для удобства
    auto incRef(T)( auto ref T p )
    {
        if( p is null ) return null;
        refCount(p)++;
        return p;
    }

    // уменьшение счётчика, возвращаем объект для удобства, null если счётчик равен 0
    auto decRef(T)( T p )
    {
        if( p is null ) return null;
        if( refCount(p) > 0 )
        {
            refCount(p)--;
            if( refCount(p) == 0 )
            {
                dispose(p); 
                return null;
            }
        }
        return p;
    }
}

В основе нашего аллокатора лежит Mallocator — аллокатор, работающий через C-шные malloc и free. Мы его обернули в AffixAllocator. Он параметризируется используемым настоящим аллокатором и 2-мя типами данных. При выделении памяти AffixAllocator выделяеть чуть больше: размер Prefix и Suffix типов (соответственно второй и третий параметр шаблонизации). Как можно догадаться префикс находится до выделенной под объект памяти, а суффикс после. В нашем случае Prefix и Suffix это size_t, и как раз префикс у нас и будет олицетворять счётчик ссылок.
Такой подход позволяет без модификации использовать уже существующие классы, так как информация о количестве ссылок хранится вне объекта.

Теперь мы можем создавать и уничтожать объекты классов и указатели с помощью нашего аллокатора.
    auto p = RC.make!int( 10 );
    assert( is( typeof(p) == int* ) );
    assert( *p == 10 );
    assert( RC.refCount(p) == 1 );
    p = RC.decRef(p);
    assert( p is null );

Уже что-то, но пока не интересно: только make за нас инкрементирует счётчик, далее инкрементируем и декриментируем мы его самостоятельно.

Добавим обёртку, которая будет некоторые вещи делать за нас.
struct RCObject(T)
{
    T obj;
    alias obj this; // где будет нужно, компилятор просто подставит obj поле вместо самого объекта RCObject

    this(this) { incRef(); } // конструктор копирования -- увеличиваем счётчик

    this( T o ) { obj = o; incRef(); } // создаётся новая обёртка -- увеличиваем счётчик

    // должна быть возможность поместить объект в обёртку без изменения счётчика ссылок (когда он приходит сразу из RC.make)
    package static auto make( T o )
    {
        RCObject!T ret;
        ret.obj = o;
        return ret;
    }

    // nothrow потому что этого требует деинциализатор из std.experimental.allocator
    ~this() nothrow
    {
        if( obj is null ) return;
        try  decRef();
        catch(Exception e)
        {
            import std.experimental.logger;
            try errorf( "ERROR: ", e );
            catch(Exception e) {}
        }
    }

    void incRef()
    {
        if( obj is null ) return;
        RC.incRef(obj);
    }

    /// удалит obj если кроме этой ссылки больше нет ни одной
    void decRef()
    { 
        if( obj is null ) return;
        assert( refCount > 0, "not null object have 0 refs" );
        obj = RC.decRef(obj);
    }

    size_t refCount() @property const
    {
        if( obj is null ) return 0;
        return RC.refCount(obj);
    }

    // при присвоении для старого объекта уменьшается счётчик ссылок, а после присвоения нового прибавляется
    void opAssign(X=this)( auto ref RCObject!T r )
    {
        decRef();
        obj = r.obj;
        incRef();
    }

    /// тоже самое только работа с голым типом T, без обёртки
    void opAssign(X=this)( auto ref T r )
    {
        decRef();
        obj = r;
        incRef();
    }
}

// для удобства
auto rcMake(T,A...)( A args ){ return RCObject!(T).make( RC.make!T(args) ); }

Теперь у нас есть scope-зависимый подсчёт ссылок и мы можем делать так:
    static class Ch  { }

    {
        RCObject!Ch c;
        {
            auto a = rcMake!Ch();
            assert( a.refCount == 1 );
            auto b = a;
            assert( a.refCount == 2 );
            assert( b.refCount == 2 );
            c = a;
            assert( a.refCount == 3 );
            assert( b.refCount == 3 );
            assert( c.refCount == 3 );
            b = rcMake!Ch();
            assert( a.refCount == 2 );
            assert( b.refCount == 1 );
            assert( c.refCount == 2 );
            b = rcMake!Ch(); // тут старый объект удалится, а новый запишется
            assert( a.refCount == 2 );
            assert( b.refCount == 1 );
            assert( c.refCount == 2 );
        }
        assert( c.refCount == 1 );
    }

Это уже что-то! Но как же быть с массивами? Добавим работу с массивами в наш аллокатор:
...
    T[] makeArray(T,A...)( size_t length )
    { return incRef( affixObj.makeArray!T(length) ); }

    T[] makeArray(T,A...)( size_t length, auto ref T init )
    { return incRef( affixObj.makeArray!T(length,init) ); }

    private void dispose(T)( T[] arr ) { affixObj.dispose(arr); }

    ref size_t refCount(T)( T[] arr ) { return affix.prefix( cast(void[])arr ); }
...

И реализуем обёртку для массивов.
она мало отличается от обёртки для объектов
struct RCArray(T)
{
    // считаем ссылки на память, которую выделили
    private T[] orig;
    // а работать можем со срезом
    T[] work;

    private void init( T[] origin, T[] slice )
    {
        if( slice !is null )
            assert( slice.ptr >= origin.ptr &&
                    slice.ptr < origin.ptr + origin.length,
                    "slice is not in original" );

        orig = origin;
        incRef();

        work = slice is null ? orig : slice;

        static if( isRCType!T ) // если элементы являются обёртками
            foreach( ref w; work ) w.incRef; // добавляем счётчик только рабочему набору
    }

    ///
    alias work this;

    this(this) { incRef(); } конструктор копирования

    this( T[] orig, T[] slice=null ) { init( orig, slice ); }

    /// no increment ref
    package static auto make( T[] o )
    {
        RCArray!T ret;
        ret.orig = o;
        ret.work = o;
        return ret;
    }

    // срез возвращает обёртку
    auto opSlice( size_t i, size_t j )
    { return RCArray!T( orig, work[i..j] ); }

    void opAssign(X=this)( auto ref RCArray!T arr )
    {
        decRef();
        init( arr.orig, arr.work );
    }

    void incRef()
    {
        if( orig is null ) return;
        RC.incRef(orig);
    }

    void decRef()
    {
        if( orig is null ) return;
        assert( refCount > 0, "not null object have 0 refs" );
        orig = RC.decRef(orig);
    }

    size_t refCount() @property const
    {
        if( orig is null ) return 0;
        return RC.refCount(orig);
    }

    ~this()
    {
        if( refCount )
        {
            // логический хак
            if( refCount == 1 )
            {
                static if( isRCType!T )
                    foreach( ref w; orig )
                        if( w.refCount )
                            w.incRef;
            }

            static if( isRCType!T ) // если элементы обёртки
                foreach( ref w; work ) w.decRef;
            decRef;
        }
    }
}

template isRCType(T)
{
    static if( is( T E == RCObject!X, X ) || is( T E == RCArray!X, X ) )
        enum isRCType = true;
    else
        enum isRCType = false;
}


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

Так же в деструкторе есть небольшой логический хак: допустим у нас сохранился только срез массива, а обёртка на оригинальный массив канула в лету. Тогда получается, что счётчик ссылок у orig равняется 1, это значит, что если серз будет тоже удалён, то будет вызван dispose для изначального (orig) массива, это приведёт к тому, что объекты, взятые из оригинального массива, но не попадающие в срез будут подвергнуты операции уменьшения счётчика ссылок и могут быть удалены. Чтобы это не произошло мы добавляем +1 каждому элементу, у которого уже есть больше 1. Потом будет происходить уменьшение у рабочего набора, это позволит оставить на 1 больше у элементов оригинального массива, которые не вошли в рабочий набор и при удалии оригинального массива их счётчик не дойдёт до нуля.

Всё это вместе позволяет делать нам вот такие вещи:
    class A
    {
        int no;
        this( int i ) { no=i; }
    }

    alias RCA = RCObject!A;

    {
        RCA obj;
        {
            RCArray!RCA tmp1;
            {
                RCArray!RCA tmp2;
                {
                    auto arr = rcMakeArray!RCA(6);
                    foreach( int i, ref a; arr )
                        a = rcMake!A(i);
                    assert( arr[0].refCount == 1 );
                    assert( arr[1].refCount == 1 );
                    assert( arr[2].refCount == 1 );
                    assert( arr[3].refCount == 1 );
                    assert( arr[4].refCount == 1 );
                    assert( arr[5].refCount == 1 );

                    tmp1 = arr[1..4];
                    assert( arr[0].refCount == 1 );
                    assert( arr[1].refCount == 2 );
                    assert( arr[2].refCount == 2 );
                    assert( arr[3].refCount == 2 );
                    assert( arr[4].refCount == 1 );
                    assert( arr[5].refCount == 1 );

                    tmp2 = arr[3..5];
                    assert( arr[0].refCount == 1 );
                    assert( arr[1].refCount == 2 );
                    assert( arr[2].refCount == 2 );
                    assert( arr[3].refCount == 3 );
                    assert( arr[4].refCount == 2 );
                    assert( arr[5].refCount == 1 );

                    obj = tmp2[0];
                    assert( arr[0].refCount == 1 );
                    assert( arr[1].refCount == 2 );
                    assert( arr[2].refCount == 2 );
                    assert( arr[3].refCount == 4 );
                    assert( arr[4].refCount == 2 );
                    assert( arr[5].refCount == 1 );
                }
                assert( tmp1[0].refCount == 1 );
                assert( tmp1[1].refCount == 1 );
                assert( tmp1[2].refCount == 3 );

                assert( obj.refCount == 3 );

                assert( tmp2[0].refCount == 3 );
                assert( tmp2[0].obj.no == 3 );
                assert( tmp2[1].refCount == 1 );
                assert( tmp2[1].obj.no == 4 );
            }
            assert( tmp1[0].refCount == 1 );
            assert( tmp1[1].refCount == 1 );
            assert( tmp1[2].refCount == 2 );
            assert( obj.refCount == 2 );
        }
        assert( obj.refCount == 1 );
    }
    // тут будет удалён последний элемент с индексом 3

Хоть код и не помечен как @nogc, он не обращается к встроенному GC. А как мы знаем не трогай и не запахнет не выделяй через GC и он не будет собирать.

На этом всё, надеюсь Вы что-то новое для себя подчерпнули)

Код оформлен как пакет dub.
Сорцы на github.

Это не готовая библиотека, а пока просто набросок, он требует ещё много доработок.
Буду очень рад помощи, если не делом, так советом.
Используете ли Вы D? (для обновления статистики)

Проголосовало 99 человек. Воздержалось 20 человек.

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

Поделиться с друзьями
-->

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


  1. TerraDon
    27.06.2016 08:50
    -3

    И в результате С и С++ живут и здравствуют, и будут дальше оставаться основными системными языками.


    1. deviator
      27.06.2016 08:50
      +2

      в результате чего?


    1. iperov
      27.06.2016 18:50

      Просто надо принять, что С/С++ не для перфекционизма, и кодить на них дальше.


  1. Alex_ME
    27.06.2016 12:42

    А какими преимуществами обладает GC перед подсчетом ссылок, раз он используется довольно часто? D, CLR, JVM — лишь малая часть списка.


    1. Gorthauer87
      27.06.2016 13:06
      +1

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


      1. DarkEld3r
        27.06.2016 13:08

        Плюс GC умеет с циклами работать.


        1. Gorthauer87
          27.06.2016 13:30

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


  1. deksden
    27.06.2016 13:08
    +1

    Неужели так сложно прикрутить аналог ARC из objective-c от Apple? Имхо, единственная из известных мне реально работающих и удобных альтернатив GC. Оверхед добавляется, но он предсказуем — это чистка ссылок по выходу из scope, занимает всегда определенное время, без неожиданных «лагов» в стиле GC.


    1. vintage
      27.06.2016 22:29

      А чем описанное в статье отличается от ARC?


      1. deksden
        28.06.2016 07:09
        +1

        Принцип тот же, безусловно — подсчет ссылок. Суть в первой букве A: автоматическое. Используются возможности LLVM / CLANG для анализа возможных «трудных» и неочевидных случаев для «убийства» объектов — почти уверен, что таких накоплено довольно много. Зачем же изобретать велосипед, когда ARC используется в продакшене и много неочевидных штук уже «вычищено»?


        1. vintage
          28.06.2016 07:26

          Можно пример таких неочевидных случаев убийства? В статье как бы тоже не предлагают вручную крементить счётчики.


          1. deksden
            28.06.2016 07:44
            +2

            Неочевидные случаи — думаю, их хватает. Я ж не разработчик CLANG/LLVM — потому и призываю перенять их опыт.

            С ходу сам могу придумать про банальные циклические ссылки: в objC с ними борются через фичу «слабые и сильные ссылки». «Автоматическое обнуление» (не самом деле — проверка жив ли объект по слабой ссылке) слабых ссылок и возможность вызова селектора над нулевым объектом — приятная особенность ObjC.
            Думаю, для реальной жизни нужна всякая оптимизация — например, путем размещения некоторых объектов на стэке / регистрах, не трогая кучу. Без компилятора это сложно сделать.

            Еще вспомнил — в ObjC есть тонкости в использовании ссылок на блок из самого блока — нельзя забывать помечать такую ссылку слабой.

            В общем, думаю у сообщества ObjC / LLVM накоплен значительный опыт в работе с подсчетом ссылок. Чего бы его не воспринять.


            1. vintage
              28.06.2016 12:59

              1. deksden
                29.06.2016 13:22
                +1

                Прекрасно, если воспринимают!

                У меня в ObjC с ARC никогда не было неочевидных проблем — а те, что были, легко гуглились. Впрочем, утечки памяти в ARC вполне возможны, нужно довольно нормально понимать что именно ты делаешь, но, имхо, ARC на текущий день — лучшая система более-менее автоматического управления памяти (и точно — самая быстрая).

                Поэтому и желаю такому интересному языку как D нормальную систему Ref counting.


            1. guai
              28.06.2016 17:40

              Подсчёт ссылок — это один большой неочевидный случай :)
              Либо надо следить за всеми объектами и разбираться со сложными графами, либо лучше вообще никак. На стэке в D аллоцировать можно, руками можно, GC есть. Подсчёт ссылок вон в статье описан. Только, конечно, область его применимости надо понимать очень хорошо, иначе это стрельба по ногам и утечки памяти.


              1. deksden
                29.06.2016 13:25
                +1

                Да, поддержка ARC на уровне компилятора например, дает возможность выносить циклы release «подальше» от scope в котором переменная больше не используется (например, через автоматическую расстановку autorelease pool).

                Думаю, что на уровне конструкций только самого языка реализовать нормальную рабочую систему не получится. Прийдется «лезть» в компилятор.


          1. deksden
            28.06.2016 09:24
            +1

            Наконец нашел — вот тут чуть подробнее про неочевидный случай с блоками ObjC: https://habrahabr.ru/post/209076/#comment_7203718 (смотрите весь тред с комментами)