Память, в С++ с ней было всегда сложно работать (горькое наследство C)… Тут нам на помощь приходит C++11 со своими std::shared_ptr.


Как вы догадались, если бы у этих примитивов не было бы проблем, то не было бы этой статьи :)

Давайте рассмотри следующий пример классической утечки памяти на std::shared_ptr:

#include <iostream>
#include <memory>

class Child;

class Parent {
 public:
  Parent() {
    std::cout << "Parent()" << std::endl;
  }

  ~Parent() {
    std::cout << "~Parent()" << std::endl;
  }

  void createChild() {
    child_ptr_ = std::make_shared<Child>();
  }

  std::shared_ptr<Child> getChild() {
    return child_ptr_;
  }

 private:
  std::shared_ptr<Child> child_ptr_;
};

class Child {
 public:
  Child() {
    std::cout << "Child()" << std::endl;
  }

  ~Child() {
    std::cout << "~Child()" << std::endl;
  }

  void setParent(std::shared_ptr<Parent> parentPtr) {
    parent_ptr_ = parentPtr;
  }

 private:
  std::shared_ptr<Parent> parent_ptr_;
};

int main() {
  auto parent = std::make_shared<Parent>();
  parent->createChild();
  parent->getChild()->setParent(parent);
  return 0;
}

Очевидно, что мы не увидим вызов деструкторов объектов. Как с этим бороться? std::weak_ptr приходит нам на помощь:

...

class Child {

  ...

  void setParent(std::shared_ptr<Parent> parentPtr) {
    parent_ptr_ = parentPtr;
  }

 private:
  std::weak_ptr<Parent> parent_ptr_;
};

...

Да, это помогает решить проблему. Но вот если у вас более сложная иерархия объектов и очень сложно понять, кого следует сделать std::weak_ptr, а кого std::shared_ptr? Или вы не хотите вообще замарачиваться с слабыми связями?

Garbage Collector — наше все !!

Нет, конечно же нет. В С++ нету нативной поддержки Garbage Collector-а, а даже если ее добавят мы получаем накладные рассходы на работу Garbage Collector-а, плюс ломается RAII.

Что же нам делать?

Deterministic Garbage Pointer Collector — это поинтер, который трекает все ссылки от root-объектов и как только никто из root-объектов не ссылается на наш объект, он сразу же удаляется.

Принцип его работы подобен std::shared_ptr (он трекает scope), но также объекты, которые ссылаются на него.

Давайте рассмотрим принцип его работы на предудущем примере:

#include <iostream>
#include "gc_ptr.hpp"

class Child;

class Parent {
 public:
  Parent() {
    std::cout << "Parent()" << std::endl;
  }

  ~Parent() {
    std::cout << "~Parent()" << std::endl;
  }

  void createChild() {
    child_ptr_.create_object();
  }

  memory::gc_ptr<Child> getChild() {
    return child_ptr_;
  }

  void connectToRoot(void * rootPtr) {
    child_ptr_.connectToRoot(rootPtr);
  }

  void disconnectFromRoot(void * rootPtr) {
    child_ptr_.disconnectFromRoot(rootPtr);
  }

 private:
  memory::gc_ptr<Child> child_ptr_;
};

class Child {
 public:
  Child() {
    std::cout << "Child()" << std::endl;
  }

  ~Child() {
    std::cout << "~Child()" << std::endl;
  }

  void setParent(memory::gc_ptr<Parent> parentPtr) {
    parent_ptr_ = parentPtr;
  }

  void connectToRoot(void * rootPtr) {
    parent_ptr_.connectToRoot(rootPtr);
  }

  void disconnectFromRoot(void * rootPtr) {
    parent_ptr_.disconnectFromRoot(rootPtr);
  }

 private:
  memory::gc_ptr<Parent> parent_ptr_;
};

int main() {
  memory::gc_ptr<Parent> parent;
  parent.create_object();
  parent->createChild();
  parent->getChild()->setParent(parent);
  return 0;
}


Как видим, кода стало немного больше, но это та цена, которую необходимо заплатить за полностью автоматическое удаление объектов. Видно, что добавились дополнительные методы connectToRoot и disconnectFromRoot. Конечно же, писать их руками все время будет довольно сложно, поэтому я намереваюсь сделать небольшой генератор этих методов в классах, которые используют gc_ptr (как видим эти мы придерживаемся принципа Zero-Overhead, мы не платим за то что не используем, а если используем-то расходы не больше, чем если бы мы написали это руками).

Библиотека gc_ptr.hpp потоко-безопасная, она не создает никаких дополнительных потоков для сборки мусора, все выполняется в конструкторе, деструкторе и операторах присваивания, так что если мы затираем наш объект и на него больше не ссылается ни один root-объект, то ты возвращаем память, выделенную под наш объект.

Спасибо за внимание!

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


  1. bfDeveloper
    05.08.2019 18:57
    +1

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


    1. redradist Автор
      05.08.2019 21:10

      >> Выглядит как панацея и серебряная пуля вместе взятые.
      Нет, это не панацея, есть свои недостатки производительности
      >> Я правильно понимаю, что вы при каждой потере ссылки проходите до рута чтобы понять, не была ли она последней?
      Не совсем, я бегу не всегда от рута, а от места где поменялась ссылка, так я прохожусь по части графа, а не по всему графу от рута… Добавлю в статью картинку попозже…
      Да это медленно, но в отличии от обычного Garbage Collector-а этот pointer детерминистически pointer ведет себя детерминированно

      Замеров по памяти и скорости работы и памяти еще не делал, намереваюсь сделать…


      1. Siemargl
        06.08.2019 10:43

        Некоторые примеры показывают, что паузы с GC достигают 300мс на 1.5Гб памяти на мелких объектах.

        Стоит бы сравниваться с Боехм GC


  1. raiSadam
    05.08.2019 19:35

    <зануда>Вообще-то поддержка сборщик мусора в c++ есть _https://en.cppreference.com/w/cpp/memory garbage collector support</зануда> А если серьёзно, то действительно интересуют цифры производительности, ну и какие-то пограничные случаи рассмотреть бы


    1. redradist Автор
      05.08.2019 21:37

      Ну declare_reachable и undeclare_reachable не трекают связи между объектами, я же их трекаю через специальные методы connectToRoot и disconnectFromRoot, которые я налижу на генератор и добавлю к библиотеке, пока что их необходимо прописывать руками (
      Единственное что позволили бы мне эти методы сделать reachable unreachable sematic более явной

      Из граничных условий я виже пока, что не отработает в lambda-выражениях при сохранении lambda-функций в std::function так как надо получить достук к установки рута для std::function и контекстно захваченых объектов


  1. a-tk
    05.08.2019 19:48

    Вывод: операторы присваивания и выход объекта из области видимости будут очень дорогими и недетерминированными по времени.


    1. redradist Автор
      05.08.2019 21:42

      Вполне детерминироваными, так как если заранее просчитать все связи можно сказать когда объекты удалятся
      Для обычного Garbage Collector-а время его запуска непредсказуемо и время выполнения непредсказуемы


      1. a-tk
        06.08.2019 08:11

        Но сколько объектов затронет такая сборка, сказать заранее невозможно. Может на нём висит граф из тысяч объектов, которые тоже зачистить надо?


  1. sashagil
    05.08.2019 20:23

    Если вы собираетесь писать небольшой генератор, почему бы не нацелить генератор на анализ графа сильных ссылок (для предотвращения циклов) ещё в фазе кодогенерации? Ещё один момент: мне попадались статьи Херба Саттера на данную тему, вам интересно было бы прочитать? Он нацеливался на добавление нового смартпойнтера в стандартную библиотеку (если я не путаю), но не довёл до этого.


    1. redradist Автор
      05.08.2019 21:58

      Да, я видел его выступления на тему статического анализа утечек памяти в C++
      Безумно интересно, но мне кажется одно другому не мешает…
      У нас в C++ будет:
      1) Raw Pointers — наивысшая производительность, но возможна утечка памяти при неаккуратном обращении
      2) Smart Pointer — совсем малость хуже производительность, лучше с управлением памятью, но опять же при неаккуратном обращении возможны утечки памяти… Чтобы их исключить необходимо хорошо организовывать свои структуры данных и flow программы
      3) Garbage Collector Pointer — наихудшая производительность из всех возможных, но гарантированное разрушение объектов во вполне определенных местах в программе. Не нужно разбираться со структурой программы добавляй и он просто работает
      4) Static Analyze Memory Leaks — никаких накладных расходов, но не все случаи охватываются

      И пусть люди выбирают в зависимости от задачи что им больше подходит


      1. sashagil
        06.08.2019 00:19
        +1

        Выступление Херба про конкретный проект https://github.com/hsutter/gcpp называлось Lifetime Safety By Default — Making Code Leak-Free by Construction, он его делал примерно 3 года назад на CppCon, слайды здесь, видео здесь — это не про статический анализ, поэтому решил привести ссылки. Пользуясь случаем, про raw pointers (*) и references (&). Некоторое время назад я программировал, избегая их использования, возвращая результатами функций смартпойнтеры и передавая их аргументами, например, как в getChild в вашем коде. Однако, сейчас я пересмотрел этот подход в сторону рекомендаций Core Guidelines, конкретно F.7: For general use, take T* or T& arguments rather than smart pointers, F.60: Prefer T* over T& when “no argument” is a valid option и R.30: Take smart pointers as parameters only to explicitly express lifetime semantics. Херб подробно разбирает этот вопрос в GotW #91 Solution: Smart Pointer Parameters. Конечно, открытие «сырых» ссылок / указателей упрощает злоупотребление, идея в том, что злоупотребления пресекаются статическим анализатором (которому в таком контексте нужно не точно анализировать все возможные варианты lifetime management, а предупреждать о нарушениях конкретной модели lifetime management, предлагаемой в Core Guidelines). Насколько хороши в этом плане существующие анализаторы вроде clang-tidy, сейчас сказать не могу (надо когда-нибудь разобраться, конечно).


  1. oleg-m1973
    05.08.2019 20:37
    +3

    Ужасно. Если не можете разобраться с shared_ptr/weak_ptr, программируйте на си-шарпе, зачем вам c++?


    1. redradist Автор
      05.08.2019 22:03
      -2

      Я прекрасно разбираюсь shared_ptr/weak_ptr, cс чего вы взяли что я не разбираюсь?
      Ничего ужасного, это один из вариантов как можно писать код и не заморачиваться со структурой программы, естественно этот вариант не подходит для высокопроизводительных программ, но если производительность не интересует, а интересует быстро реализовать задачу то вполне подходит и такой pointer )


      1. oleg-m1973
        05.08.2019 22:20
        +1

        Очевидно, что далеко не прекрасно. И, как соотносятся фразы «Но вот если у вас более сложная иерархия объектов и очень сложно понять» и «интересует быстро реализовать задачу то вполне подходит и такой pointer )»? По-моему, они явно не об одном и том же.
        О многопоточности я вообще молчу.


        1. redradist Автор
          05.08.2019 22:27

          Я не могу понять почему такая острая критика, за мелкие неточные выссказывания в статье?
          Как по этим фразам можно считать разбирается человек или нет?
          Фразы которые вы привели лишь о случаях в которых можно было бы использовать данный тип объекта


  1. Playa
    05.08.2019 21:07
    +2

    Задуматься на минуту о времени жизни объекта? Нет, хочу писать велосипеды и стрелять в ноги


    1. ultrinfaern
      05.08.2019 21:36
      -3

      Я бы сказал, что всякие unique_ptr, shared_ptr, weak_ptr — это явно костыли эмулирования GC.
      Вообще, все зависит от сложности доменной модели — где пойдут и костыли, а где нужно задуматься о велосипеде полнго GC.


    1. redradist Автор
      05.08.2019 22:12
      -1

      Блин, зачем же ) После выстрела в ногу на велосипеде же кататься не получится ))


  1. jknight
    06.08.2019 00:14

    Прошу прощения за граммар-нацизм, но, блин, что мешает перед постингом текста более-менее крупного размера скопировать текст в Ворд, который бы вывел на чистую воду добрый десяток всяких опечаток, ошибок, и отсутствующих запятых? Хабр всегда был ресурсом интеллектуальных и образованных людей, и видеть тут ошибки уровня «рассходы» и «используем-то» глазам кроваво…


  1. maaGames
    06.08.2019 09:51

    Т.е. весь сыр-бор из-за того, что в дочернем объекте зачем-то хранится умный указатель на родителя, вместо просто указателя? По определению, дочерний объект не может существовать без родительского, потмоу что при удалении родительского сперва удаляются его дочерние (удаляется контейнер, содержащий умные указатели на дочерние объекты). Если дочернему объекту разрешено менять родителя, то всё-равно простой/умный указатель будут или обнуляться или модифицироваться.
    В общем, сами отстрелили себе ногу, использовав умный указатель не там, где это было нуно.