Как работает garbage collector c

Обновлено: 25.06.2024

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

Итак, как у большинства программистов у меня есть свой проект, а именно двумерный игровой движок. На нем и производились все «эксперименты».

Особенности производительности

Сборщик мусора рабочей станции

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

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

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

Сборщик мусора сервера

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

Сборка выполняется в нескольких выделенных потоках, выполняемых с приоритетом THREAD_PRIORITY_HIGHEST .

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

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

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

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

При запуске сотен экземпляров приложения рассмотрите возможность использования сборки мусора рабочей станции с отключенной параллельной сборкой мусора. Это уменьшит число переключений контекста, что может повысить быстродействие.

Этап первый: отладка памяти

Самая часто встречающаяся проблема при ручном управлении памятью — это утечки. Чтобы узнать о них нужно следить за памятью. Именно таков и был мой первоначальных подход. Следим за созданием и удалением объектов, проверяем после завершения программы что осталось не удаленным. Делается очень просто: перегружаем операторы new и delete, чтобы они могли принимать в параметрах имя файла исходного кода и строчку, откуда происходит аллокация, и храним все аллокации в одном месте. Для удобства объявляем макрос, который и передает имя файла и строку в оператор. Соответственно при удалении объекта удаляем запись о соответствующей аллокации.

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

Этап второй: умные указатели

И правда же, самое распространенное решение проблемы управления памятью — умные указатели. О них вас обязательно спросят на собеседовании. Идея проста: вместо обычных указателей мы используем шаблонные классы, которые работают как указатели, но имеют дополнительный функционал для автоматического освобождения объектов. Вместе с объектом храним счетчик ссылок, при присваивании значения указателю увеличиваем счетчик, при уничтожении указателя — уменьшаем. Если счетчик равен нулю — объект никому не нужен и его можно освободить. Однако, есть небольшая проблема — если два объекта ссылаются друг на друга, они никогда не будут освобождены, так как у обоих счетчик ссылок равен единице.

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

Что ж, давайте придумаем ситуацию посложнее.

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

  • не требует много ресурсов
  • гарантирует корректное освобождение объектов при правильной архитектуре
  • просто в освоении
  • усложняет синтаксис
  • в некоторых ситуациях сложно правильно выстроить слабые/сильные указатели
  • зацикленные ссылки приводят к утечкам

Этап третий: велосипедостроительство

Опробовав умные указатели, я остался неудовлетворен. Просто хочется использовать один у тот же тип умного указателя везде и не задумываться о зацикленных ссылках. Пусть он сам о них задумывается. Но как это сделать? Представим ситуацию:

Нужно как-то узнать что два нижних объекта зациклены и их можно удалить, ведь на них никто не ссылается. По рисунку уже несложно догадаться: если ссылки от объекта не ведут к верхнеуровневым объектам, значит он может быть освобожден. Верхнеуровневые объекты — это, грубо говоря, те объекты, с которых начинается инициализация приложения. Для С++ это объекты на стеке и статические.

Этап четвертый: сборщик мусора

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

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

Для каждого созданного объекта создается мета-информация ObjectInfo и хранится в менеджере памяти MemoryManager. Каждый такой объект создается перегруженным оператором new. ObjectInfo хранит в себе информацию о размере объекта, месте, откуда он был создан, список указателей на него и указатель на функцию для вызова деструктора этого объекта.

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

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

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

Этап пятый: импровизация

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

Следующая идея — вовсе не вызывать сборку мусора или делать это крайне редко. Объекты по-прежнему удалять вручную или умными указателями, а сборщик мусора можно использовать в роли отладчика и подстраховки. В таком варианте мы получим производительность эквивалентную ручному управлению памятью и безопасность от утечек при сборке мусора.

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

Помимо этого можно реализовать еще немного «вкусностей». Так как мы используем шаблонные классы-указатели, мы можем проверять их на корректность, то есть при удалении объекта вручную делать все ссылки на него невалидными. И далее в нужных местах их проверять. Еще одно из возможных улучшений — это дефрагментация памяти. На этапе сборки мусора можно перерасположить актуальные объекты в памяти для уменьшения кеш-промахов процессора, что несомненно даст прирост производительности.

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

Этап шестой: возврат к началу

В конце концов на решение об отказе от сборщика повлияла специфика моего проекта. Проект планируется с открытым исходным кодом и он нацелен прежде всего на удобство использования. Усложнение и без того сложного синтаксиса С++ специфичными указателями и добавление сборщика мусора несомненно плохо повлияют на проект. Просто представьте знакомство разработчика с новой технологией: ему необходимо изучать новое API да еще и с мудреной моделью управления памятью, тем более, что большинство программистов С++ и так неплохо управляются с памятью вручную.
Окончательно я убедился в возврате к ручной модели когда принял решение использовать скрипты. Именно они нужны для простоты и удобства. Делаешь прототип или простую игру — используй скрипты, экономь время и ресурсы. Необходима гибкость и производительность — добро пожаловать в С++. Тем, кто будет использовать С++ на самом деле вряд ли понадобится сборщик мусора.

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

Сборка мусора рабочей станции и сборка мусора сервера

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

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

На рисунке ниже показаны выделенные потоки, выполняющие сборку мусора на сервере.

Потоки сборки мусора сервера

Сборка мусора, управление памятью и указатели

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

При использовании же ссылочных типов, например, объектов классов, для них также будет отводиться место в стеке, только там будет храниться не значение, а адрес на участок памяти в хипе или куче, в котором и будут находиться сами значения данного объекта. И если объект класса перестает использоваться, то при очистке стека ссылка на участок памяти также очищается, однако это не приводит к немедленной очистке самого участка памяти в куче. Впоследствии сборщик мусора (garbage collector) увидит, что на данный участок памяти больше нет ссылок, и очистит его.

В методе Test создается объект Country. С помощью оператора new в куче для хранения объекта CLR выделяет участок памяти. А в стек добавляет адрес на этот участок памяти. В главном методе Main мы вызываем метод Test. И после того, как Test отработает, место в стеке очищается, а сборщик мусора очищает ранее выделенный под хранение объекта country участок памяти.

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

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

Так же надо отметить, что для крупных объектов существует своя куча - Large Object Heap . В эту кучу помещаются объекты, размер которых больше 85 000 байт. Особенность этой кучи состоит в том, что при сборке мусора сжатие памяти не проводится по причине больших издержек, связанных с размером объектов.

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

Кроме того, чтобы снизить издержки от работы сборщика мусора, все объекты в куче разделяются по поколениям. Всего существует три поколения объектов: 0, 1 и 2-е.

К поколению 0 относятся новые объекты, которые еще ни разу не подвергались сборке мусора. К поколению 1 относятся объекты, которые пережили одну сборку, а к поколению 2 - объекты, прошедшие более одной сборки мусора.

Когда сборщик мусора приступает к работе, он сначала анализирует объекты из поколению 0. Те объекты, которые остаются актуальными после очистки, повышаются до поколения 1.

Если после обработки объектов поколения 0 все еще необходима дополнительная память, то сборщик мусора приступает к объектам из поколения 1. Те объекты, на которые уже нет ссылок, уничтожаются, а те, которые по-прежнему актуальны, повышаются до поколения 2.

Поскольку объекты из поколения 0 являются более молодыми и нередко находятся в адресном пространстве памяти рядом друг с другом, то их удаление проходит с наименьшими издержками.

Класс System.GC

Рассмотрим некоторые методы и свойства класса System.GC:

Метод AddMemoryPressure информирует среду CLR о выделении большого объема неуправляемой памяти, которую надо учесть при планировании сборки мусора. В связке с этим методом используется метод RemoveMemoryPressure , который указывает CLR, что ранее выделенная память освобождена, и ее не надо учитывать при сборке мусора.

Метод Collect приводит в действие механизм сборки мусора. Перегруженные версии метода позволяют указать поколение объектов, вплоть до которого надо произвести сборку мусора

Метод GetGeneration(Object) позволяет определить номер поколения, к которому относится переданый в качестве параметра объект

Метод GetTotalMemory возвращает объем памяти в байтах, которое занято в управляемой куче

Метод WaitForPendingFinalizers приостанавливает работу текущего потока до освобождения всех объектов, для которых производится сборка мусора

Работать с методами System.GC очень просто:

С помощью перегруженных версий метода GC.Collect можно выполнить более точную настройку сборки мусора. Так, его перегруженная версия принимает в качестве параметра число - номер поколения, вплоть до которого надо выполнить очистку. Например, GC.Collect(0) - удаляются только объекты поколения 0.

Еще одна перегруженная версия принимает еще и второй параметр - перечисление GCCollectionMode . Это перечисление может принимать три значения:

Default : значение по умолчанию для данного перечисления (Forced)

Forced : вызывает немедленное выполнение сборки мусора

Optimized : позволяет сборщику мусора определить, является ли текущий момент оптимальным для сборки мусора

Базовые сведения о времени жизни объекта
Размещайте объект в управляющей куче с помощью ключевого слова new и забывайте об этом.


После создания объект будет автоматически удалён сборщиком мусора, когда в нём отпадёт надобность. Сразу возникает вопрос о том, каким образом сборщик мусора определяет, когда в объект отпадает необходимость?

Давайте просмотрим простой пример:


Обратите внимание, что ссылка на объект Car(myCar) была создана непосредственно внутри метода MakeACar() и не передавалась за пределы определяющей области действия (ни в виде возвращающегося значения, ни в виде параметров ref/out). По этому после вызова метода ссылка на myCar окажется недостижимой, а объект Car — кандидатом на удаление сборщиком мусора. Следует, однако, понимать, что нет никакой гарантии на удаление объекта сразу после выполнение метода MakeACar(). Всё, что в данный момент можно предполагать, так это то, что когда в CLR-среде будет в следующий раз проводиться сборка мусора, то объект myCar будет поставлен на удаление.

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

Роль корневых элементов приложения

Во время процессы сборки мусора исполняющая среда будет исследовать объекты в куче, чтобы определить, являются ли они по прежнему достижимыми (т.е. корневыми) для приложения. Для этого среда CLR будет создавать графы объектов, представляющие все достижимые для приложения объекты. Кроме того, следует иметь ввиду, что сборщик мусора никогда не будет создавать граф для одного и того же объекта дважды, избегая необходимости выполнения подсчёта циклических ссылок, который характерен для программирования в среде COM.

Поколения объектов

При попытке обнаружить недостижимый код объекты CLR-среды не проверяют буквально каждый находящийся в куче объект. Очевидно, что на это уходила бы масса времени, особенно в крупных проектах.
Для оптимизации процесса каждый объект в куче относится к определённому «поколению» Смысл в применении поколений выглядит довольно просто:
Чем дольше объект находится в куче, тем выше вероятность того, что он там будет оставаться.
  • Поколение 0. Идентифицируется новый только что размещённый объект, который ещё никогда не помечался как надлежащий удалению в процессе сборки мусора
  • Поколение 1. Идентифицирует объект, который уже «пережил» один процесс сборки мусора (был помечен, как надлежащий удалению, но не был удалён из-за достаточного свободного места в куче).
  • Поколение 2. Идентифицирует объект, который пережил более одного прогона сбора мусора

Поколения 0 и 1 называются эфемерными.

Сборщик мусора сначала анализирует все объекты, которые относятся к поколению 0. Если после их удаления остаётся достаточное количество памяти, статус всех уцелевших объектов повышается до поколения 1. Если все объекты поколения 0 были проверены, но всё равно требуется дополнительное пространство, то будет запцщени проверка объектов поколения 1. Объекты этого поколения, которым удалось уцелеть, станут объектами поколения 2. если же сборщику мусора всё равно понадобится память, что сборке мусора подвергнуться объекты поколения 2. Так как объектов выше 2 поколения не бывает, то статус объектов не изменится.
Из всего вышесказанного можно сделать вывод, что более новые объекты будут удалятся быстрее, нежели более старые.

Читайте также: