Pull to refresh

Comments 74

Ключевой вопрос: "Зачем нужен GC, когда код на C++?".

В том же Qt он есть, наверно не просто так его создали...

В Qt нет gc. Есть объекты, владеющие ресурсами. Сборка мусора и владение ресурсом - разные коцепции.

UFO just landed and posted this here

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

Одно из преимуществ языков со сборкой мусора — это сверхбыстрое выделение памяти (говорят, что в C# память выделяется быстрее, чем в C++) плюс фоновое освобождение (не расходуется время на delete в основном потоке). То есть сборка мусора при правильном применении повышает производительность.


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

Скорость выделения памяти от сборки мусора не зависит. Использовать несколько пулов для оптимизации скорости выделения памяти можно и без сборщика мусора.

А вот случаи, когда система уходила в задумчивость из-за того, что программист понадеялся на сборщик мусора, а тот просто не успевал освобождать память, я встречал неоднократно. Например, в системе сбора данных при пиковых нагрузках.

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

Тесты производительности - огонь! =) Советую почитать про выделение пулов под объекты одного класса (не класса в мысле "class", а в смысле схожего цикла жизни), переиспользование памяти и алгоритмы без удаления памяти (выделил один раз и до конца жизни программы).

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

А в С++ в общем случае проблему циклических ссылок никак не решить. Если у вас два произвольных объекта ссылаются друг на друга, вы не можете удалить сначала один из них, а потом другой. Потому что нет гарантии, что второй не обращается в деструкторе к первому.

Ну, даже если отложить попытки решить данную проблему, нужно хотя бы иметь возможность отслеживать существование островов хотя бы в debug-сборке, ведь случайно создать острова очень просто, а память не бесконечная. Иначе выйдет так, что сборщик добавит новый класс проблем, куда посерьёзнее чем те, которые он призван решить.

Upd: Еще есть проблема с многопоточностью - если объект передаётся в другой поток, то освобождение может произойти в неподконтрольном потоке, что еще сильнее все усложняет. Деструкторы и неконтролируемое время уничтожения - само по себе создаёт проблему. Как способ решения - все вызовы деструкторов умных указателей должны происходить в определённый этап цикла потока. Да, очерёдность вызовов деструкторов не гарантируется, но зато гарантируется, что это не произойдёт случайно в неподходящий момент. В своём движке у меня был самописный таск-менеджер как класс-обёртка над потоком, и он работал в паре с объектными пулами. Чтобы избежать синхронизации, пулы были thread_local. И постоянно возникал кейс, когда объекты передавались в другой поток. После использования в другом потоке объекты нужно было уничтожать, но утилизировать в пул чужого потока - не вариант, иначе пул первого потока будет аллоцировать всё новые и новые, а поток-получатель только собирать "перебежчиков". То есть, в качестве механизма "департации" в родной поток объекты помещались в специальный список, отдельный список на каждый поток. И тут на помощь приходит тот самый кастомный таск-менеджер: в конце цикла он проверяет все списки "депортируемых" и пачками за один заход возвращает все объекты в их родной поток через специальный таск. Родной поток же в свою очередь в начале цикла подбирал всех "депортированных" и вызывал деструкторы. Звучит сумбурно, прошу простить. Может моя идея реализации кому-то чем-то поможет.

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

Стоит сравнить накладные расходы на копирование и расходы на ловлю перебежчиков.

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

shared_ptr / weak_ptr.

Как то очень глупо говорить, что в С++ не решить подобное, если все гц в других языках написаны на С++

weak_prt используют, чтобы избежать циклических ссылок. Организуется иерархия владения, и соответственно определенный порядок удаления.
Для gc надо либо как-то ограничивать классы, на которые может ссылаться gc-pointer (например, требовать только тривиальный деструктор), либо делать какие-то хинты, кого удалять первым — фактически аналог weak_prt (а зачем тогда gc?)
shared_ptr / weak_ptr.

К слову, оверхэд при использовании shared_ptr и weak_ptr довольно большой из-за использования атомарных операций. Поэтому код на GC-языке с активным использованием указателей будет работать быстрее.

в гц тоже нужна синхронизация

UFO just landed and posted this here
UFO just landed and posted this here
  1. перегрузка глобального new

  2. не было задачи перехватить всё, только то что нуждается в таком менеджменте

UFO just landed and posted this here

Как вы её сделаете видимой во всех TU?

сделаю её в мейне выше всех инклудов. До модулей в С++20 это будет работать.

UFO just landed and posted this here

запретить перегрузку new, забыть про гц в С++ как ужасную идею

Я считаю, делать гц "универсальным аллокатором" для всех new смысла нет (тогда лучше выбрать другой язык), но имеет смысл создавать определенные высокоуровневые категории объектов через гц (например, игровые сущности). При этом созданные через гц-аллокатор объекты не должны торчать наружу указателями вообще - а иметь свой умный указатель, позволяющий отгородить внешнюю логику от доступа по висячей ссылке. В c++/cli похожая схема, где можно создавать объекты как через new, так и через gcnew.

UFO just landed and posted this here

если бы я делал ГЦ это был бы синглтон с методом
static T& create<T>(Args&&...); или нечто подобное

Оператор new я бы и правда запретил перегружать.

Потому что нет гарантии, что второй не обращается в деструкторе к первому.

Так и пусть обращается.

1) Находим граф связанных указателей, подлежащий удалению.

2) Вызываем дестукторы у всех указателей этого графа.

3) Освобождаем память всех указателей этого графа.

Так это будет лютый UB: сначала вызываем деструктор первого объекта, затем из деструктора второго к нему обращаемся. Сам первый объект в памяти ещё висит, но его поля могут быть в совсем невалидном состоянии. Собственно, поэтому в языках с GC логика работы деструкторов другая, и называются они "финализаторами".

Да, но эту же проблему можно и без GC устроить. Берем два объекта, в деструкторе первого трогаем второй, в деструкторе второго трогаем первый. Какой способ управления памяти тут не примени, UB никуда не пропадет, тут скорее вопрос корректной архитуктуры и дисциплины. Т.е. нужно запретить писать код в деструкторах который потенциально вызывает проблему.

В своем проекте я решал эту проблему кастомным полуумным указателем: уничтожение вызывается только вручную вызовом метода destroy на самом указателе, но все копии этого указателя впоследствии разыменовываются в nullptr (избегаем висячих ссылок). Ну и, собственно, как и вызов delete на nullptr безопасен, так и destroy внутри другого destroy не будет делать ничего. А вообще, желательно избегать использование деструктора и по возможности создавать объекты через фабричный метод и задавать там функцию удаления. Таким образом решается проблема возможного вызова виртуальных методов в конструкторе и деструкторе.

Т.е. нужно запретить писать код в деструкторах который потенциально вызывает проблему.

То есть выкинуть RAII и перейти к явному вызову методов типа Dispose.

Не обязательно. Однако не стоит ожидать что уже существующие классы будут работать из коробки без переписывания/оберток в GC-way.

weak_ptr решает проблему. Но тут программист должен решить, в каком порядке удаляются объекты. А gc как о правильном порядке удаления узнает, если программист об иерархии владения думать не хочет, а хочет чтобы оно само как-то решилось?

Выше не упоминаются конкретные детали реализации, поэтому можно подобрать такие, чтобы обойтись без ub. Речь может идти о placement new с ручным вызовом деструкторов. Для простоты можем говорить о методах create/destroy (возможно, pimpl класса). Граф ссылок можно обойти поиском в глубину, вызывая destroy на каждом шаге назад.

UFO just landed and posted this here

Скорее это фича именно для геймплейной логики в с++, где хочется не думать о жизни объекта.

Думаю, что в с++ можно получить "2 в 1". Полный контроль на низком уровне и использование GC на высоком. Без него очень противно создавать объект не привязанный к миру(аналогия на UObject из UE).

корутина ответ на такой запрос(создание объекта не привязанного ни к чему кроме себя)

По личному опыту: когда учился в ВУЗе, прогал на c++, на работе c# (уже больше 8 лет прогаю на c#). И чем больше читаю книг по управлению памятью в c# и о том, как написать производительное приложение, тем больше понимаю, что все эти навороты с автоуправлением памятью усложняют фреймворк до невозможности. На с++ человеку достаточно помнить о том когда нужно очистить память. На шарпе толстенная книга о том, как работает фреймворк с памятью. Любое "авто" приводит к ограничениям, которые нужно помнить, а когда забываешь о них, то теряешь в скорости или стреляешь в ногу. На мой взгляд c++ не тот язык, где это нужно.

На с++ человеку достаточно помнить о том когда нужно очистить память

В современном C++ это уже не совсем так, скорее нужно соблюдать иерархию владения, а все остальное делает RAII и стандартная библиотека.

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

Работа с паматью не бывает простой. Начинаешь писать на Си, надоедают сегфолты - переходишь на С++, там те же сегфолты + стектрейс размером с Войну и Мир. Переходишь на языки с управляемой памятью, там всё круто, но медленно. Начинаешь настраивать сборщик мусора, понимаешь, что это всё капли в море. Начинаешь пытаться обмануть сборщик: out-of-heap, отказ от объектов в пользу примитивных типов, денормализация структур данных. Начинаешь понимать, что пишешь на Java в стиле С++, возвращаешься обратно на С++ с идеей добавить сборку мусора. Получаешь всё те же сегфолты + тормоза от сборки мусора. Плюёшь на всё и решаешь, что отныне не будет циклических зависимостей, используешь shared_ptr/weak_ptr. Понимаешь, что в многопоточном приложении блокировки съедают производительность. Уходишь в Тибет, просветляешься, там постигаешь пуллинг, переиспользование памяти, алгоритмы без использования кучи. Возвращяеся и одухотворённый пишешь программы, которые летают и не падают. Постепенно понимаешь, что даже hello-world в таком стиле это боль и унижение, ведь рядом коллеги пишут простой и понятный код на Java/C#. Завязываешь с программированием и уходишь в строители.

какие ещё блокировки в shared ptr???

Какие ещё алгоритмы без использования кучи? Очевидно что дорогостоящую операцию(выделение памяти), нужно использовать как можно реже, это называется просто нормальный алгоритм, а не алгоритм "без кучи"

Если писать код на современных плюсах, то никаких сегфолтов не будет, и если что все эти оптимизации по переиспользованию памяти и пулам находятся внутри malloc/operator new

какие ещё блокировки в shared ptr???

Блокировки шины на атомарных операциях.

Вы с джавы пришли в С++? Почему у вас бесконечные выделения через new объектов, которые должны быть на стеке?
Почему бы не использовать bitset / vector<bool>, зачем писать свой велосипед?
Почему free list это опять какой то велосипед вместо std::vector?

Почему везде инты? Вы знаете какой размер инта вообще?

почему вы кастуете поинтер сишным кастом к size_t и даже не uintptr_t? "тестирование" выглядит смешно если честно.

Нет ни намёка на синхронизацию, при том что многопоточный тест есть.

в make функции для поинера даже forward нет

Короче тут столько проблем, что их даже не перечислить

1) Про использование new не понял вопроса. В общем случае количество объектов может быть большим, и мы не хотим переполнить стек. 1 лям, даже интов, класть на стек, что-то не хочется.
2) Согласен, можно использовать vector для bool, он тоже их пакует.
3) В статье представлен не финальный вариант. В движке большая часть интов заменены на беззнаковый вариант.

4) Этот gc не будет работать в отдельном потоке, об этом речи и не шло. Многопоточная тут только сборка мусора на вызов ForceGC.

forward_list вы тоже на стек боитесь положить?

Размер forward_list 16 на 86-64. forward_list используется в мапе по структуре из пункта 2. Стек тоже не выдержит лям forward_list.)

у вас там ровно один std::forward_list<FAddrHandler>* ItemMap = nullptr;

Если не один, делаете контейнер явно.

В современном С++ семантика указателя не владеющее вью на 0 или 1 объект, всё. Более одного объекта - контейнер, не владеющее - вью(span или subrange), точно 1 - ссылка. Один или 0, но владеющее - optional

это сишный массив листов, возможно плохо акцентировал внимание.

что мешает вам сделать шаблон, где будет всё размешено на стеке, раз уж там всего лишь в конструкторе создаётся один раз массив(выделяется память), а потом уже снаружи этот тип выделить на куче ( в unique_ptr например), если вы так боитесь за стек?(хотя вы знаете на компиляции сколько там будет элементов)

Ну, это не интересно. Ничего, что на уровне ниже еще есть C++ менеджер кучи? А там все ох как не просто. Раз уж взялись делать сборку мусора то менеджер кучи надо иметь свой. Так что только VirtualAlloc, только хардкор. По поводу адресов.

Для начала - все придумано до нас - см. как устроена структура адреса в винде (не знаю как там в Linux). Это не просто число, это иерархический идентификатор из частей - номер блока, номер страницы, смещение.

Потом - а что если по одному адресу сначала что-то освободится, а потом выделится другое? std::weak_ptr к такому устойчив.

Еще - а чем deleter'ы от std::shared_ptr не подходят? Зачем своя структура?

Основная фича GC - это нормальное съедание оторвавшихся подграфов объектов. А в С++ это не выйдет никак из-за кольцевых ссылок. Выехать можно только повторив всю "магию" из .NET-ного GC. Вот это к слову интересная идея - затащить часть .NET Core как .lib. Но не уверен, что оно так просто отчуждается.

Я так и не понял, зачем нужен свой GC, если можно просто сохранить объекты как shared и освобождать все со значением 1 в нужное время

P.S. Имею в виду один класс с вектором shared указателей на объекты

Потому что с GC мы получаем возможность спрашивать валидность объекта не только сильной, но и слабой ссылки после удаления объекта. Тут не будет проблемы, если мы создадим случайно несколько разных "умных" указателей на один объект.

Бросилось в глаза:

  • повторение inline в теле класса излишне, потому что метод, определенный в теле класса, по умолчанию inline;

  • лучше в GGarbageCollector* Get() возвращать (возможно const) ссылку, чтобы убрать ненужное разыменовывание указателя. Чтобы избежать неявных глобальных изменений данных, следует использовать глобальные const переменные. Синглтон - глобальный объект, поэтому лучше возвращать const ссылку и объявлять public методы const;

  • если служебный метод класса (напр. конструктор или деструктор) имеет поведение по умолчанию (пустое тело в коде), для наглядности лучше сделать его = default;

  • повторять несколько раз спецификаторы доступа к членам класса необязательно. Общий подход к размещению членов класса; https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rl-order

  • аргумент IsNew конструктора TGCPointer(T* InObject, bool IsNew) не имеет прямого соотвествия полю класса, а участвует в скрытой логике реализации, вытаскивая её на поверхность. Наверное, следует пересмотреть дизайн класса;

  • get-методы, например TGCPointer::GetChecked, по названию, не должны менять состояния класса. GetChecked, вероятно, не get-метод, так как в теле выполняется несколько не связанных с получением данных действий. Следует переименовать, лучше разбить (или удалить, если не окажется полезным);

  • TGCPointer::operator=(T* Ptr) для произвольного адреса выглядит небезопасным;

  • в MakeGCPointer вместо аргументов выписаны типы аргументов, должно быть вроде new T{forward(args)...};

  • о замене size_t на uintptr_t и использовании reinterpret_cast вместо C-стиля; https://stackoverflow.com/questions/153065/converting-a-pointer-into-an-integer

  • delete допускает nullptr аргумент, проверка не нужна;

  • BitsPackSize и BitsPackMask следует связать через промежуточные вычисления, чтобы не вносить правки по отдельности руками;

  • функции, возвращающие FAddrHandler*, могут возвращать ссылку (const), чтобы не добавлять лишнего разыменовывания;

  • для получения данных, на которые указывает iterator в STL следует использовать разыменовывание; https://en.cppreference.com/w/cpp/named_req/Iterator

  • подход к реализации структуры данных с forward_list выглядит переусложненным. Можно было заменить внутренности реализации FAnyPtrMap на unordered_map;

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

1) скорее да, но это часть код стайла движка. Там вообще используется свой FORCEINLINE по аналогии с UE4
2) согласен
3) возразить не могу, но это тоже часть стайла
4) стайл
5) часть оптимизации, если есть предложения, будет интересно почитать
6) возможно не нужен, но не очень страшно
7) все нормально, как раз фича этого GC, что мы устойчивы к возникновению в коде обычных с++ указателей
8) +
9) +
10) соглашение в проекте
11) +
12) +, даже ускорит
13) +
14) нужно проверить с тестами производительности, не уверен, что будет быстрее
15) супер расширяемый код тоже не есть хорошо. В данном месте ровно столько, сколько нужно.

Вообще не понимаю, какие тут плюсы перед shared_ptr.
GC вводят для решения двух проблем
1. Циклические ссылки
2. Проблема shared_ptr в многопоточной среде, когда для инкремента указателя надо поставить lock.
Здесь при создании/удалении умных указателей такой же
++Count;
++TotalObjectsCount;

Просто автор не позаботился о корректности в многопоточной среде и не поставил блокировки.

То есть, обе проблемы не решены.

Есть ещё проблема с распихиванием указателей по бакетам:

	inline unsigned int GetLocalAddr(size_t Addr) const noexcept
	{
		return Addr & BitsPackMask; // Addr % BitsPackSize
	}

const size_t LAddr = (const size_t)Ptr;
auto& LItems = ItemMap[GetLocalAddr(LAddr)];

Все Ptr выравнены по границе 8 байт, а значит, в массиве ItemMap только каждый 8-й forward_list будет реально задействован, остальные будут созданы впустую.

Верно, текущая версия не предусматривает использование GC в отдельном потоке. Сейчас он работает в основном по таймеру.
Про выравнивание указателей - спасибо, еще место для оптимизации памяти.

Про плюсы перед shared_ptr я в статье затрагивал, но GC, это другой подход к контролю памяти в принципе. Поэтому их некорректно сравнивать.

текущая версия не предусматривает использование GC в отдельном потоке. Сейчас он работает в основном по таймеру
Тут вопрос не сколько в GC, сколько вообще в возможности работать в несколько потоков. Что будет, если запустить 8 потоков и в каждом аллоцировать? Ответ: forward_list сломаются, т.к. они на такое не расчитаны. А многопоточность в играх сейчас уже must-have.

Про плюсы перед shared_ptr я в статье затрагивал
По перфомансу? Вы неверно используете его, вместо
auto A = std::shared_ptr<TestStruct>((new TestStruct()));
надо использовать make_shared. Это удвоит производительность и вероятно сократит разрыв между ним и вашим GC.

какой лок? Там локфри

Лок не в смысле отдельного mutex-а, а хардварный лок на линию кеша в атомарном инкременте/декременте счётчика ссылок. По производительности это так же больно, как mutex/критическая секция, потому что под капотом всё тот же LOCK XADD или LOCK CMPXCHG
UFO just landed and posted this here
Да, я про оптимистичный сценарий.

1) на кой ляд C++ сборщик мусора?? Автоматическая память - ваш бро.

2) boehm gc

Универсальный сборщик мусора для С++: «}»

Sign up to leave a comment.

Articles