Сборка мусора, управление памятью и указатели
Ранее в теме Типы значений и ссылочные типы мы рассматривали отдельные типы данных и как они располагаются в памяти. Так, при использовании переменных типов значений в методе, все значения этих переменных попадают в стек. После завершения работы метода стек очищается.
При использовании же ссылочных типов, например, объектов классов, для них также будет отводиться место в стеке, только там будет храниться не значение, а адрес на участок памяти в хипе или куче, в котором и будут находиться сами значения данного объекта. И если объект класса перестает использоваться, то при очистке стека ссылка на участок памяти также очищается, однако это не приводит к немедленной очистке самого участка памяти в куче. Впоследствии сборщик мусора (garbage collector) увидит, что на данный участок памяти больше нет ссылок, и очистит его.
В методе Test создается объект Person. С помощью оператора new в куче для хранения объекта CLR выделяет участок памяти. А в стек добавляет адрес на этот участок памяти. В неявно определенном методе Main мы вызываем метод Test. И после того, как Test отработает, место в стеке очищается, а сборщик мусора очищает ранее выделенный под хранение объекта Person участок памяти.
Сборщик мусора не запускается сразу после удаления из стека ссылки на объект, размещенный в куче. Он запускается в то время, когда среда CLR обнаружит в этом потребность, например, когда программе требуется дополнительная память.
Как правило, объекты в куче располагаются неупорядочено, между ними могут иметься пустоты. Куча довольно сильно фрагментирована. Поэтому после очистки памяти в результате очередной сборки мусора оставшиеся объекты перемещаются в один непрерывный блок памяти. Вместе с этим происходит обновление ссылок, чтобы они правильно указывали на новые адреса объектов.
Так же надо отметить, что для крупных объектов существует своя куча — Large Object Heap . В эту кучу помещаются объекты, размер которых больше 85 000 байт. Особенность этой кучи состоит в том, что при сборке мусора сжатие памяти не проводится по причине больших издержек, связанных с размером объектов.
Несмотря на то что, на сжатие занятого пространства требуется время, да и приложение не сможет продолжать работу, пока не отработает сборщик мусора, однако благодаря подобному подходу также происходит оптимизация приложения. Теперь чтобы найти свободное место в куче среде CLR не надо искать островки пустого пространства среди занятых блоков. Ей достаточно обратиться к указателю кучи, который указывает на свободный участок памяти, что уменьшает количество обращений к памяти.
Кроме того, чтобы снизить издержки от работы сборщика мусора, все объекты в куче разделяются по поколениям. Всего существует три поколения объектов: 0, 1 и 2-е.
К поколению 0 относятся новые объекты, которые еще ни разу не подвергались сборке мусора. К поколению 1 относятся объекты, которые пережили одну сборку, а к поколению 2 — объекты, прошедшие более одной сборки мусора.
Когда сборщик мусора приступает к работе, он сначала анализирует объекты из поколения 0. Те объекты, которые остаются актуальными после очистки, повышаются до поколения 1.
Если после обработки объектов поколения 0 все еще необходима дополнительная память, то сборщик мусора приступает к объектам из поколения 1. Те объекты, на которые уже нет ссылок, уничтожаются, а те, которые по-прежнему актуальны, повышаются до поколения 2.
Поскольку объекты из поколения 0 являются более молодыми и нередко находятся в адресном пространстве памяти рядом друг с другом, то их удаление проходит с наименьшими издержками.
Класс System.GC
Функционал сборщика мусора в библиотеке классов .NET представляет класс 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 : позволяет сборщику мусора определить, является ли текущий момент оптимальным для сборки мусора
Сборщик мусора
В этой и последующих статьях мы займемся исследованием , одного из основных механизмов, оказывающих существенное влияние на производительность приложений для .NET. Освобождая разработчиков от хлопот, связанных с освобождением памяти, сборщик мусора вводит другие проблемы, которые необходимо учитывать при разработке программ, производительность которых имеет большое значение.
Назначение сборщика мусора
Сборка мусора — это высокоуровневая абстракция, избавляющая разработчиков от необходимости заботиться об освобождении управляемой памяти. В окружениях, снабженных механизмом сборки мусора, выделение памяти производится в момент создания объектов, а освобождение происходит, когда в программе исчезает последняя ссылка на объект. Кроме того, сборщик мусора предоставляет интерфейс финализации для неуправляемых ресурсов, находящихся за пределами управляемой динамической памяти, благодаря чему имеется возможность обеспечить выполнение кода, когда эти ресурсы окажутся не нужны.
При создании сборщика мусора в .NET преследовались две основные цели:
избавиться от ошибок и ловушек, связанных с управлением памятью вручную;
обеспечить производительность операций управления памятью, равную или превышающую производительность ручных механизмов.
В существующих языках программирования и фреймворках используются различные стратегии управления памятью. Мы коротко исследуем две из них: управление на основе списка свободных блоков (реализацию которой можно найти в коллекции стандартных инструментов управления памятью языка C) и сборка мусора на основе подсчета ссылок.
Управление свободным списком
Управление на основе списка свободных блоков — это механизм управления распределением памяти в стандартной библиотеке языка C, который также по умолчанию используется функциями управления памятью в C++, такими как new и delete. Это детерминированный диспетчер памяти, при использовании которого вся ответственность за выделение и освобождение памяти ложится на плечи разработчика. Свободные блоки памяти хранятся в виде связанного списка, откуда изымаются блоки памяти при выделении, и куда они возвращаются, при освобождении.
Он изымает блоки из списка при выделении памяти и возвращает их обратно при освобождении. Приложение обычно получает блоки памяти, хранящие их размеры в служебной области.
Механизм управления памятью на основе списка не свободен от тактических и стратегических решений, влияющих на производительность приложения. Ниже перечислены некоторые из них:
Приложение, использующее механизм управления памятью на основе списка свободных блоков, изначально получает небольшой пул свободных блоков, организованных в виде списка. Список может быть отсортирован по размеру, по времени использования и так далее.
Когда диспетчер получает от приложения запрос на выделение памяти, он выполняет поиск соответствующего блока памяти. Соответствие может определяться по принципу «первый подходящий», «лучше подходящий» или с применением более сложных критериев.
После исчерпания списка диспетчер запрашивает у операционной системы дополнительные свободные блоки и добавляет их в список. Когда приложение возвращает память диспетчеру, он добавляет освободившийся блок в список. На этом этапе может выполняться слияние смежных свободных блоков, дефрагментация и сокращение списка, и так далее.
Ниже перечислены основные проблемы, связанные с управлением памятью на основе списка свободных блоков:
Высокая стоимость операции выделения: поиск блока, соответствующего параметрам запроса, требует времени, даже при использовании критерия «первый подходящий». Кроме того, блоки часто разбиваются на несколько частей. В многопроцессорных системах неизбежно возникает конкуренция за список и необходимость синхронизации операций, если только не используется несколько списков. С другой стороны, наличие нескольких списков ухудшает их фрагментацию.
Высокая стоимость освобождения: возврат блока в список требует времени, и здесь снова возникает проблема синхронизации конкурирующих операций освобождения памяти.
Высокая стоимость управления: чтобы избежать ситуации отсутствия блоков памяти подходящего размера при наличии большого количества маленьких блоков, необходимо выполнять дефрагментацию списка. Но эта работа должна производиться в отдельном потоке выполнения, что опять же требует применения блокировок для доступа к списку и снижает скорость операций выделения и освобождения памяти. Фрагментацию можно уменьшить, выделяя блоки фиксированного размера и поддерживая несколько списков, но при этом увеличивается количество операций по поддержанию динамической памяти в целостном состоянии и добавляет накладные расходы к каждой операции выделения и освобождения памяти.
Сборка мусора на основе подсчета ссылок
Сборщик мусора, опирающийся на подсчет ссылок, связывает каждый объект с целочисленной переменной — счетчиком ссылок. В момент создания объекта счетчик ссылок инициализируется значением 1. Когда приложение создает новую ссылку на объект, его счетчик ссылок увеличивается на 1. Когда приложение удаляет ссылку на существующий объект, его счетчик ссылок уменьшается на 1. Когда счетчик ссылок достигает значения 0, объект можно уничтожить и освободить занимаемую им память.
Одним из примеров управления памятью на основе подсчета ссылок в экосистеме Windows является объектная модель программных компонентов (Component Object Model, COM). Объекты COM снабжаются счетчиками ссылок, определяющими продолжительность их существования. Когда значение счетчика ссылок достигает 0, объект может освободить занимаемую им память. Основное бремя подсчета ссылок ложится на плечи разработчика, в виде явного вызова методов AddRef() и Release(), хотя в большинстве языков имеются обертки, автоматизирующие вызовы этих методов при создании и удалении ссылок.
Ниже перечислены основные проблемы, связанные с управлением памятью на основе подсчета ссылок:
Высокая стоимость управления: всякий раз, когда создается или уничтожается ссылка на объект, необходимо обновлять счетчик ссылок. Это означает, что к стоимости обновления ссылки прибавляются накладные расходы на выполнение таких тривиальных операций, как присваивание ссылки (a = b) или передача ссылки в функцию по значению. В многопроцессорных системах выполнение таких операций требует применения механизмов синхронизации и вызывает «пробуксовку» кеша процессора, при попытке нескольких потоков выполнения одновременно изменить счетчик ссылок.
Использование памяти: счетчик ссылок на объект должен храниться в памяти объекта. Это на несколько байтов увеличивает объем памяти, занимаемой объектом, что делает подсчет ссылок нецелесообразным для легковесных объектов. (Впрочем, это не такая большая проблема для CLR, где к каждому объекту «в нагрузку» добавляется от 8 до 16 байт.)
Правильность: при управлении памятью на основе подсчета ссылок возникает проблема утилизации объектов с циклическими ссылками. Если приложение больше не ссылается на некоторую пару объектов, но каждый из них продолжает хранить ссылку на другой объект (как показано на рисунке ниже), возникает утечка памяти. Эта проблема описывается в документации COM, где явно оговаривается, что такие циклические ссылки должны уничтожаться вручную. Другие платформы, такие как язык программирования Python, поддерживают дополнительные механизмы определения циклических ссылок и их устранения, применение которых влечет за собой увеличение стоимости сборки мусора.
В следующей статье мы рассмотрим механизм сборки мусора на основе трассировки, который используется в .NET.
Принципы работы Garbage collection
В этой статье вспомним, что такое Garbage collection (GC), зачем он нужен вообще и какие проблемы решает. Детально рассмотрим режимы работы GC в .NET, поймем, как работает каждый из них, их особенности и различия. Затронем специфику применения некоторых режимов GC в .NET.
Изучим вопрос мониторинга работы GC, какие доступны для этого инструменты и как ими пользоваться.
Введение
Вообще, откуда взялась эта тема? Она появилась из-за поведения наших сервисов, в том числе и на production. Мы увидели, что некоторые приложения начали отнимать 30% CPU. Не могли понять, почему это происходит — ведь по коду все было хорошо. Провели анализ метрик, о которых поговорим позже, и выяснили, что GC потребляет на сборку мусора порядка 30%. И тут возник вопрос — что же с этим делать. Появилось поле для оптимизации. И мы добились хороших результатов, когда после всевозможных манипуляций снизили потребление CPU до 10%, до 5%. Как этого можно добиться, я расскажу ниже.
Когда я задался вопросом и начал готовить эту статью, мне было интересно, а когда у нас появился первый язык, который уже поддерживал сборку мусора. Я даже немного удивился, потому что это был 1964 год. 50 лет назад люди уже задумывались о том, что разработчиков нужно освобождать от занятий с памятью. Это был язык APL. Из языков, которые поддерживают сборку мусора, можно назвать Erlang (1990 год), Eifel, Smalltalk (1972 год), конечно же, C# и любой современный язык, который выходит сейчас, например Go. Это уже must have.
Интересный факт: по исследованиям, разработчики, которые занимаются написанием кода на языках, не поддерживающих сборку мусора, 40% своего продуктивного времени тратят на операции по работе с управлением памятью, что довольно много и, скорее всего, не всегда будет понятно менеджменту.
Что такое Garbage Collection
GC (Garbage Collection — сборка мусора) — высокоуровневая абстракция, которая избавляет разработчиков от необходимости заботиться об освобождении управляемой памяти.
Давайте вспомним основные тезисы по сборке мусора. В .NET сборка мусора основана на трассировке.
Существует понятие корневых элементов приложения. Корневым элементом (root) называется ячейка в памяти, в которой содержится ссылка на размещаемый в куче объект. Строго говоря, корневыми могут называться такие элементы:
- Ссылки на глобальные объекты (хотя в C# они не разрешены, но позволяет размещать глобальные объекты).
- Ссылки на любые статические объекты или статические поля.
- Ссылки на локальные объекты в пределах кодовой базы приложения.
- Ссылки на передаваемые методу параметры объекта.
- Ссылки на объект, ожидающий финализации.
- Любые регистры центрального процессора, которые ссылаются на объект.
Во время процесса сборки мусора исполняющая среда будет исследовать объекты в куче, чтобы определить, являются ли они по-прежнему достижимыми (т. е. корневыми) для приложения. Для этого среда CLR будет создавать графы объектов, представляющие все достижимые для приложения объекты. Кроме того, следует иметь в виду, что сборщик мусора никогда не будет создавать граф для одного и того же объекта дважды, избавляя от необходимости выполнения подсчета циклических ссылок, который характерен для программирования в среде COM.
Фазы сборки мусора:
- Маркировка (mark phase).
- Чистка (sweep phase).
- Сжатие (compact phase).
Поколения объектов: нулевое, первое, второе поколение.
Нулевое и первое поколения еще называют эфемерными поколениями. Они нужны для ускорения отклика нашего приложения.
Для работы приложения CLR инициализирует 2 сегмента виртуального адресного пространства — Small object heap (объекты до 85 КБ) и Large object heap (объекты свыше 85 КБ, в некоторых случаях массивы и связанные списки (linked list), не достигшие данного размера).
Конфигурирование GC довольно простое, что отображено на следующем рисунке:
Рисунок 1. App.config
Конфигурировать режимы работы GC можно путем добавления в app.config секции, показанной на слайде выше, с помощью параметров gcConcurrent, gcServer.
Режим рабочей станции
Рисунок 2. Процесс сборки мусора в режиме рабочей станции
Если мы откроем любую книгу по .NET, любую статью по .NET, где у нас описано, как работает Garbage Collection, обычно это звучит так: работает приложение, не хватает памяти для того, чтобы выделить следующий объект, и происходит запуск GC. При этом все активные потоки приложения приостанавливаются. Это самый простой процесс сборки мусора — workstation non-concurrent mode.
Недостатком этого подхода является то, что во время сборки мусора приложение не занимается ничем другим, кроме сборки мусора. Можно ли этого избежать и как-то повысить отклик нашего приложения?
Идея, как повысить производительность приложения, довольно проста: если нулевое и первое поколения собираются очень быстро, то почему бы их не очищать отдельно от второго поколения. Возможно ли так сделать, чтобы при сборке второго поколения, наше приложение и дальше продолжало аллоцировать объекты? Да, возможно.
Параллельная сборка мусора
Рисунок 3. Параллельная сборка мусора
Для этого существует режим параллельной сборки мусора (workstation concurrent GC).
Параллельная сборка мусора в .NET
До выхода .NET 4.0 очистка неиспользуемых объектов проводилась с применением техники параллельной сборки мусора. В этой модели, при выполнении сбора мусора эфемерных объектов, сборщик мусора временно приостанавливал все активные потоки внутри текущего процесса, чтобы приложение не могло получить доступ к управляемой куче вплоть до завершения процесса сборки мусора.
По завершении цикла сборки мусора приостановленным потокам разрешалось снова продолжить работу. К счастью, в .NET 3.5 сборщик мусора был хорошо оптимизирован, и потому связанные с ним короткие перерывы в работе с приложением редко становились заметными.
Как и оптимизация, параллельная сборка мусора позволяла проводить очистку объектов, которые не были обнаружены ни в одном из эфемерных поколений, в отдельном потоке. Это сокращало (но не устраняло) необходимость в приостановке активных потоков исполняющей средой .NET. Тем более, параллельная сборка мусора позволяла размещать объекты в куче во время сборки объектов неэфемерных поколений.
Фоновая сборка мусора
Рисунок 4. Фоновая сборка мусора
В .NET 4.0 сборщик мусора по-другому решает вопрос о приостановке потоков и очистке объектов в управляемой куче, используя при этом технику фоновой сборки мусора. Несмотря на ее название, это вовсе не означает, что вся сборка мусора теперь происходит в дополнительных фоновых потоках выполнения. На самом деле, в случае фоновой сборки мусора для объектов, не относящихся к эфемерному поколению, исполняющая среда .NET теперь может проводить сборку мусора объектов эфемерного поколения в отдельном фоновом потоке.
Механизм сборки мусора в .NET 4.0 был улучшен так, чтобы на приостановку потока, связанного с деталями сбора мусора, требовалось меньше времени. Благодаря этим изменениям процесс очистки неиспользуемых объектов поколения 0 и 1 стал оптимальным. Он позволяет получать более высокий уровень производительности приложений.
Давайте представим, что у нас на хосте, где наше приложение запустится, есть один процессор. В таком случае, что бы мы ни делали, мы все равно запустимся в режиме рабочей станции. Вы можете делать с флажками что угодно, но на одном процессоре не хватит одновременных потоков, которые могут запуститься для того, чтобы обслуживать другой режим.
Режим сервера
Особенности работы GC в режиме сервера.
- Сборка выполняется в нескольких выделенных потоках, выполняемых с приоритетом THREAD_PRIORITY_HIGHEST .
- Для каждого процессора предоставляется куча и выделенный поток, выполняющий сборку мусора, и сборка куч выполняется одновременно. Каждая куча содержит кучу небольших объектов и кучу больших объектов, и все кучи доступны из пользовательского кода. Объекты из различных куч могут ссылаться друг на друга.
- Так как несколько потоков сборки мусора работают совместно, для кучи одного и того же размера сборка мусора сервера выполняется быстрее сборки мусора рабочей станции.
- В сборке мусора сервера часто используются сегменты большего размера. Однако обратите внимание, что это только обобщение: размер сегмента зависит от реализации и может изменяться. При настройке приложения не следует делать никаких предположений относительно размера сегментов, выделенных сборщиком мусора.
- Сборка мусора сервера может оказаться ресурсоемкой операцией. Например, если на компьютере с 4 процессорами выполняется 12 процессов, в каждом из которых применяется сборка мусора сервера, будут использоваться 48 выделенных потоков сборки мусора. В случае высокой загрузки памяти, если все процессы запускают сборку мусора, сборщику мусора понадобится выполнить планирование работы 48 потоков.
При запуске сотен экземпляров приложения рассмотрите возможность использования сборки мусора рабочей станции с отключенной параллельной сборкой мусора. Это уменьшит число переключений контекста, что может повысить быстродействие.
Рисунок 6. Визуализация работы Garbage Collection в режиме сервера
На рисунке 6 показана визуализация того, как все это работает в режиме сервера. Как видим, главное отличие заключается в том, что сборка мусора выполняется для каждого доступного процессора.
Рисунок 7. Server Background Mode
Начиная с .NET Framework 4.5, фоновая сборка мусора сервера является режимом по умолчанию для сборки мусора сервера. Этот режим функционирует аналогично фоновой сборке мусора рабочей станции, описанной выше, однако с некоторыми отличиями. Для фоновой сборки мусора рабочей станции используется один выделенный поток фоновой сборки мусора, тогда как для фоновой сборки мусора сервера используется несколько потоков — обычно по одному выделенному потоку для каждого логического процессора.
Инструменты мониторинга
GC class
Что можно сделать с помощь GC class из кода подробно описано в статье, но стоит сразу отметить, что это будет просто логирование нужной нам информации в лог, а затем анализ этой информации с помощью каких-то доступных средств. Не очень хороший способ — это не выход из ситуации.
Performance Monitor
Одним из самых мощных инструментов для обнаружения проблем с производительностью в Windows являются встроенные счетчики производительности, так называемые Performance counters. Оснастка Performance monitor — основной инструмент для управления ими.
Performance Viewer
Performance Viewer основан на трассировке событий Windows. Чуть позже поговорим о том, что это такое, зачем это нужно и что можно вообще мониторить с его помощью.
SOS Debugging Extension
SOS Debugging Extension стоит отметить, но уже мало кто использует этот инструмент.
dotMemory
Платный представитель от JetBrains. Стоит отметить, что его open source конкуренты на текущий момент мало в чем ему уступают.
Concurrency Vizualizer
Concurrency Vizualizer — расширение для Visual Studio. К мониторингу памяти относится очень косвенно. При этом оно очень информативное, так как позволяет увидеть множество параметров по работе приложения в многопоточной среде. С помощью этой утилиты можно проанализировать, когда потоки приостанавливаются, восстанавливают свою работу и т. д.
Performance Monitor
Рисунок 8. Счетчики Performance Monitor
Какие счетчики (counter) предлагает Performance Monitor? Первый счетчик, на который стоит обратить внимание — это процент времени, которое было потрачено самим GC. Этот счетчик делает замеры между двумя сборками мусора, считает циклы процессора, циклы, которые были потрачены в общем и которые были потрачены на сборку мусора. Например, если между двумя сборками прошел 1 миллион циклов процессора и при этом из них 300 тысяч потрачено на сборку мусора, то, соответственно, наше приложение 30% времени тратит просто для того, чтобы собирать мусор.
На какое значение нужно обращать внимание? Это довольно сложный вопрос. К примеру, мы получили цифру 17. Что мне с этой цифрой делать дальше? Из опыта рекомендую обращать внимание на значение 50%. Если 50% — значит половину времени мы тратим впустую. Если это время тратится еще в дата-центрах, то тратятся деньги. И с этим надо что-то делать. Если мы видим цифру в 10 %, то для того, чтобы опустить ее на 5, нужно потратить столько денег, что даже не стоит в это вкладываться.
Следующий параметр, на который стоит обращать внимание — Allocated bytes/second. Он показывает число байтов в секунду, которые мы можем аллоцировать в памяти. Можем посмотреть, какой размер занимает нулевое поколение, первое, второе поколение, сколько занимает Large Object Heap, как перетекают объекты из нулевого поколения в первое, из первого — во второе, количество выживших объектов и т. д.
Finalization Survivors — это счетчик, который показывает количество объектов, которые ушли с очереди финализации и готовы к тому, чтобы началась их чистка.
Пример, как использовать этот инструмент, показан на рисунке 9.
Рисунок 9. Работа с Performance Monitor
Performance Viewer
На мой взгляд, это один из лучших инструментов на текущий момент. Также радует, что производители уже начали задумываться о том, что же делать с Linux, что очень актуально для приложений, написанных под .NET Core. Уже сейчас на их сайте есть небольшой туториал, как снимать метрики с докер хостов. Надеюсь, они будут продолжать развиваться, и мы получим очень хороший инструмент.
Инструмент позволяет мониторить практически все аспекты, которые нужны разработчику для анализа: CPU, стек, есть возможность сделать дамп памяти и проанализировать его, можно посмотреть статистику по GC.
Рисунок 10. Работа с Performance Viewer
Инструмент довольно простой (см. рисунок 10): нажимаем collect и собираем нужные нам метрики. Совет для тех, кто будет использовать — не собирайте метрики долго. Сделал большую ошибку: собрал метрики за минуту и потом ждал пока распарсится минут семь, потом бросил. Должно хватить чтобы понять, что происходит с вашим приложением и что с ним делать. Инструмент может показать все, что связано с GC. На слайде выделены данные, которые касаются GC-статистики. Показывается режим GC, в котором запущено наше приложение, время, которое было потрачено на паузы GC, процессорное время, которое было потрачено, количество сборок мусора в каждом из поколений, минимальные паузы, пики.
События трассировки
Если посмотреть определение в MSDN или в литературе, то трассировка событий — это высокоэффективная масштабируемая система трассировки с минимальными затратами ресурсов, которая реализуется в Windows. Если немного заглянуть под капот, то очень грубо говоря, этот процесс выглядит так: мы запускаем трассировку наших приложений, это все ложится в обычные файлики, эти файлики потом парсятся, и мы исследуем, что происходит с нашим приложением.
Что вообще можно мониторить в .NET в среде CLR? GC, Runtime, Exceptions, Thread pool, Stack и т. д. Детально о всех метриках можно почитать здесь.
Cейчас мы рассмотрим Garbage Collection в событиях (event), и что они нам позволяют мониторить. Они нам позволяют собирать сведения, которые как раз и относятся к сборке мусора: когда она началась, когда закончилась, в каком поколении. Как долго длилась не покажут — нужно вычислять самому, и это нетривиальная задача. Нетривиальная потому, что если мы посмотрим на режим рабочей станции, когда у нас нет никаких конкурентных режимов, то там все просто: потоки остановились, приостановились, возобновились. И эту дельту мы можем словить по разнице. Когда мы вспоминаем высокоприоритетную сборку мусора, то тут уже все далеко не тривиально. Поэтому уже лучше пользоваться теми инструментами, которые у нас есть.
На GitHub есть библиотеки, которые позволяют научиться работать с данными событиями. К примеру, TraceEvent Library позволяет нам написать приложение, которое будет выполнять трассировку другого приложения. И всю эту информацию спокойно собирать, дебажить и что-то с ней делать.
На рисунке 11 показан небольшой пример, как можно запустить трассировку событий используя TraceEvent Library.
Рисунок 11. Пример кода
На рисунке 12 происходит магия в части того, как мы собираем все эти счетчики. А вот что из всего этого вышло уже отображено на рисунке 13.
Рисунок 12. Пример кода
Мы получили следующую информацию: когда у нас начал выполняться GC и сколько времени заняла пауза на GC, какая по счету сборка мусора, с каким поколением работал GC, в каком режиме работает наше приложение.
GC-визуализация
Рисунок 13. Визуализация GC
Есть довольно интересный блог, который ведет Мэт Уоррен. В нем можно найти очень много интересной и полезной информации: как работает Garbage Collection, что же происходит на самом деле «под капотом».
Рисунок 14. Визуализация GC от Мэта Уоррена
На рисунке 14 отображена визуализация работы GC, основанная на трассировке событий, написанная автором блога. Всем, кому интересно понять, как же работает GC, рекомендую разобраться с ним.
Рисунок 15. Таблица данных тестирования в разных режимах
В следующей таблице собраны метрики, полученные в результате тестирования одного и того же приложения в разных режимах. Было запущено приложение, основной задачей которого была генерация memory-трафика. Что мы видим? Серверный режим, действительно, уменьшает паузы работы GC, уменьшает количество запусков итераций сборки мусора, но это все делается за счет более интенсивного использования CPU и за счет более интенсивного потребления памяти. Об этом всегда нужно помнить. Если у нас десктопное приложение, в котором нам нужен максимальный отклик, то этот режим явно не для него.
Выводы
Каждый из нас рано или поздно сталкивается с проблемами неоптимальной работы написанного приложения, причины могут быть разные. При их анализе довольно часто мы не смотрим на то, как в таких случаях работает GC, как его работа влияет на работу приложения, оптимальный ли режим GC выбран именно для текущего приложения. А ведь многие ответы как раз и могут быть получены при таком анализе.
Введение в сборку мусора .NET
Если вы поймете, как работает сборщик мусора в .NET, то поймете и причины ряда проблем, возникающих в приложениях. И хоть .NET обещал конец ручному управлению памятью, вам все еще нужно следить за ее использованием при разработке приложений, чтобы избежать проблем с потреблением памяти и низкой производительностью.
КДПВ
Сборщик мусора в .NET предрекал конец ручного управления памятью и защиту от ее утечек. Идея в том, что при наличии сборщика мусора, работающего в фоновом режиме, разработчикам больше не нужно беспокоиться о необходимости управления жизненным циклом объектов — сборщик сам позаботится о них, когда они станут не нужны.
В реальности все оказалось гораздо сложнее. Сборщик мусора, конечно, помогает избежать наиболее распространенных утечек из тех, что встречаются в неуправляемых программах, утечек, которые возникают из-за того, что разработчик забыл освободить выделенную память, когда работа с ней закончена. Автоматическая сборка мусора также решает проблему преждевременного освобождения памяти, хотя способ решения этой проблемы может и сам привести к утечкам, ведь у сборщика может быть свое, особое мнение на то, является ли объект еще «живым» и в какой момент его необходимо удалить. И чтобы мы могли со всем этим справиться, необходимо понять, как работает сборщик мусора.
Как работает сборщик мусора
Так как же все-таки работает магия сборщика мусора? Основная идея довольно проста — он изучает, как объекты размещены в памяти, определяя те из них, до которых может добраться запущенная программа, следуя некоторой цепочке ссылок.
Когда начинается сборка мусора, сборщик просматривает набор ссылок, называемых корнями. Это участки памяти, которые в силу определенных причин должны быть доступны всегда, и которые содержат ссылки на объекты, созданные программой. Сборщик помечает эти объекты как живые, а затем просматривает все объекты, на которые они ссылаются, помечая живыми и их. Сборщик мусора продолжает в том же духе, пока не пометит живыми все объекты, которые он смог найти таким способом.
Сборщик мусора определяет объект как ссылающийся на другой объект, если он или один из его предков имеет поле, содержащее ссылку на другой объект.
Когда найдены все живые объекты, остальные могут быть уничтожены, а освободившееся место можно использовать для новых объектов. Кроме того, .NET уплотняет память, чтобы в ней не оставалось пробелов, перемещая живые и фактически перезатирая уничтоженные объекты. Это означает, что свободная память всегда находится в конце кучи, что делает выделение новых объектов очень быстрым.
Сами по себе корни не являются объектами, они представляют собой ссылки на объекты. Любой объект, на который ссылается корень, автоматически переживет следующую сборку мусора. В .NET существует четыре основных вида корней:
Локальные переменные ссылочного типа в методе, который выполняется в данный момент.
Объекты, на которые ссылаются эти переменные, всегда должны быть немедленно доступны методу, в котором они объявлены, поэтому их необходимо хранить. Время жизни таких корней может зависеть от того, как была собрана программа. В отладочных сборках локальная переменная живет до тех пор, пока метод находится в стеке. В релизных сборках оптимизирующий JIT-компилятор может посмотреть на структуру программы, чтобы определить последнюю точку, когда переменная используется методом, и удалить ее, когда она больше не нужна. Эта стратегия используется не всегда — ее можно отключить, например, запустив программу в отладчике.
Статические поля также всегда считаются корнями. Объекты, на которые они ссылаются, могут быть доступны в любое время классу, который их объявил, или остальной части программы, если они объявлены, как public . Поэтому .NET всегда будет держать их в памяти. При этом поля, объявленные как ThreadStatic, будут существовать только до тех пор, пока выполняется использующий их поток.
Управляемые объекты, переданные в неуправляемую библиотеку через Interop.
Если управляемый объект передается в неуправляемую библиотеку COM+ через Interop, то он станет корневым объектом с подсчетом ссылок. Это происходит потому, что COM+ не выполняет сборку мусора. Вместо этого он использует систему подсчета ссылок. Как только библиотека COM+ завершает работу с объектом, устанавливая счетчик ссылок в 0, он перестает быть корневым и может быть удален.
Ссылки на объекты с финализатором.
Если у объекта есть финализатор, то он не удаляется сразу, как только сборщик мусора решит, что он больше не нужен. Вместо этого, ссылка на него становится особым видом корня до тех пор, пока .NET не вызовет финализатор. Для удаления таких объектов из памяти обычно требуется более одной сборки мусора, так как когда их признают ненужными в первый раз, они выживут, чтобы позже можно было вызвать финализатор.
Граф объектов
Память в .NET образует сложный запутанный граф перекрестных ссылок. Это может затруднить определение объема памяти, используемой конкретным объектом. Например, память, используемая непосредственно объектом List , довольно мала, поскольку класс List имеет всего несколько полей. Однако одним из этих полей является массив хранимых в списке объектов, который может быть довольно большим, если список имеет много записей. Этот массив принадлежит только одному конкретному списку, поэтому отношения между ними довольно просты. Общий размер списка — это размер маленького начального объекта и большого массива, на который он ссылается. Другое дело, объекты в массиве — вполне возможно, что существует и другой путь через память, по которому они могут быть доступны. В этом случае нет смысла считать их частью размера списка, так как они останутся, даже если список перестанет существовать, но и нет смысла считать их по альтернативному пути — они останутся, если список он будет удален.
Все становится еще более запутанным, когда в игру вступают циклические ссылки.
Граф связей между объектами и корнями (корни обозначены как «GC root»)
При разработке приложений программистам часто удобнее представлять память организованной в дерево, начинающееся с отдельных корней:
Древовидное представление связей относительно корня GC root 2
Это упрощает представление о том, как объекты располагаются в памяти, удобно при написании приложений и при использовании отладчика. Однако из-за этого легко забыть, что объект может быть связан более, чем с одним корнем. Именно из-за этого обычно и происходят утечки памяти в .NET — разработчик забывает или не понимает, что объект привязан более, чем к одному корню. Например, в случае, показанном на схеме выше, установка корня GC root 2 в null на самом деле не позволит сборщику мусора удалить ни одного объекта. Это видно при просмотре полного графа, но не понятно при изучении дерева.
Профилировщик памяти позволяет взглянуть на граф иначе — как на дерево, начинающееся с какого-либо корневого объекта (не следует путать корни сборщика мусора и корневые объекты дерева). Следуя по ссылкам, указывающим на объекты дерева начиная с корневого (т. е. в обратном направлении), мы можем поместить в его листья все корни сборщика мусора. Например, начиная с объекта ClassC , на который ссылается корень GC root 2, мы можем проследить все ссылки и получить следующий граф:
Древовидное представление связей относительно объекта ClassC
Таким образом мы увидим, что объект ClassC имеет двух корней-владельцев, оба из которых должны перестать на него ссылаться, прежде чем сборщик мусора сможет его удалить. Чтобы объект ClassC был удален после того, как корень GC root 2 будет установлен в null , должна быть разорвана любая из промежуточных связей между корнем GC root 3 и объектом.
Такая ситуация запросто может возникнуть в приложениях .NET. Наиболее распространенным является случай, когда на объект данных ссылается элемент пользовательского интерфейса, и этот объект не удаляется после завершения работы с ним. Строго говоря, это не является утечкой — память будет восстановлена, когда элемент пользовательского интерфейса будет обновлен новыми данными, но это может привести к тому, что приложение будет использовать гораздо больше памяти, чем ожидалось. Обработчики событий — еще одна распространенная причина чрезмерного потребления памяти. Легко забыть, что объект будет существовать по крайней мере столько же, сколько и объекты, от которых он получает события, что в случае некоторых глобальных событий (например, определенных в классе Application ) является вечностью.
Реальные приложения, особенно с компонентами пользовательского интерфейса, имеют гораздо более сложные графы, чем в примерах выше. Даже на такую простую вещь, как label в диалоговом окне, можно ссылаться из огромного количества различных мест:
Пример древовидного представления связей относительно объекта в реальном проекте
В таком лабиринте может запросто потеряться какой-нибудь объект.
Ограничения сборщика мусора
Неиспользуемые объекты, на которые все еще есть ссылки
Самым большим ограничением сборщика мусора в .NET является то, что хотя он и преподносится как способный обнаруживать и удалять неиспользуемые объекты, на самом деле он находит только те объекты, на которые отсутствуют ссылки. Это очень важное различие — объект может больше никогда не упоминаться в программе, но, так как от него есть некоторый путь к объекту, который еще используется, он никогда не будет удален из памяти. Это и приводит к утечкам, возникающим, когда объект, который не используется, все равно остается в памяти.
Источник таких утечек бывает довольно трудно обнаружить, хотя симптомы очевидны — рост потребления памяти. Для начала, необходимо определить, какие неиспользуемые объекты остаются в памяти, а затем отследить ссылки, ведущие на них, чтобы выяснить, почему объекты не удаляются. Для решения этой задачи необходим профилировщик памяти. Сравнивая снимки памяти во время утечки, можно найти проблемные неиспользуемые объекты, но отследить ссылки на них в обратном направлении не сможет ни один отладчик.
Сборщик мусора предназначен для работы с избыточными ресурсами, то есть когда момент освобождения конкретного ресурса не имеет особого значения. В современных системах в эту категорию попадает память — не важно, когда она освобождается, главное сделать это вовремя, чтобы предотвратить неудачное выделение памяти под новый объект. Есть и ресурсы, которые не попадают в эту категорию, например, дескрипторы файлов должны быть закрыты как можно быстрее, чтобы не вызвать конфликтов между приложениями. Такие ресурсы не могут полностью управляться сборщиком мусора, поэтому .NET предоставляет метод Dispose() вместе с конструкцией using() для объектов, управляющих этими ресурсами. Дефицитные ресурсы, используемые объектом, быстро освобождаются реализацией метода Dispose() вручную (или используя метод using() , что также можно считать ручным освобождением), тогда как гораздо менее критичная память автоматически освобождается сборщиком мусора позже.
Dispose() не означает ничего особенного для .NET, поэтому утилизированные объекты (т. е. объекты, у которых был вызван метод Dispose() ) все равно должны быть освобождены (т. е. на них не должно быть ссылок). Это делает объекты, которые утилизированы, но не освобождены, хорошими кандидатами для источника утечки памяти.
Фрагментация кучи
Менее известное ограничение — это куча больших объектов (Large Object Heap, LOH), в которой размещаются объекты размером от 85000 байт. Куча больших объектов никогда не уплотняется, соответственно, объекты, которые в ней размещены, никогда не перемещаются, что может привести к преждевременному исчерпанию памяти в программе. Когда одни объекты живут дольше других, в куче образуются так называемые дыры — это называется фрагментацией. Проблема возникает, когда программа запрашивает большой блок памяти, но куча стала настолько фрагментированной, что в ней нет ни одной непрерывной области, достаточно большой, чтобы вместить его. Исключение OutOfMemoryException , вызванное фрагментацией, обычно происходит, когда программа имеет много свободной памяти, но из-за фрагментации не может разместить в ней новый объект.
Другим симптомом фрагментации является то, что .NET-приложению приходится держать память, «занятую» пустыми дырами. Это приводит к тому, что при просмотре в диспетчере задач кажется, что приложение использует гораздо больше памяти, чем ему нужно. Именно это часто происходит, когда профилировщик показывает, что выделенные программой объекты используют лишь небольшой объем памяти, а диспетчер задач показывает, что процесс занимает большой объем.
Производительность сборщика мусора
С точки зрения производительности, наиболее важной особенностью систем с автоматической сборкой мусора является то, что сборщик может начать выполнение в любое время. Это делает такие системы непригодными для использования в случаях, когда время выполнения критически важно, поскольку любая операция может быть приостановлена работой сборщика мусора.
Режимы работы сборщика мусора
Сборщик мусора в .NET имеет два основных режима работы: режим рабочей станции и режим сервера, а также два подрежима: параллельный и непараллельный. Параллельный режим рабочей станции используется в настольных приложениях, а режим сервера — в серверных, например, в ASP.NET.
В параллельном режиме рабочей станции, .NET пытается избежать долгой приостановки приложения за счет того, что параллельно с работой потоков программы, в фоновом режиме работает и поток сборщика мусора, который находит объекты для уничтожения. Это означает, что общий объем работы, которую сборщик может выполнить за определенный промежуток времени, будет меньше, но и приложение не будет на долго останавливаться. Такой подход хорош для интерактивных приложений, где важно создать у пользователя впечатление, что программа реагирует немедленно.
В непараллельном серверном режиме, .NET приостанавливает работу приложения на время работы сборщика мусора. В целом это более эффективно, чем параллельный режим — сборка мусора занимает столько же времени, но при этом ей не приходится бороться с продолжающей работать программой. Однако, при выполнении полной сборки могут возникать заметные паузы.
Режим сборки мусора можно задать в конфигурационном файле приложения, если значение по умолчанию не подходит. Выбор непараллельного режима сборки может быть полезен, когда важнее, чтобы приложение имело высокую пропускную способность, а не выглядело отзывчивым.
Поколения сборщика мусора
В больших приложениях количество объектов, с которыми приходится иметь дело сборщику мусора, может стать очень большим. В таком случае просмотр и перемещение всех объектов может занять очень много времени. Для решения этой проблемы в .NET используются поколения сборщика мусора. Идея поколений заключается в предположении, что объекты, созданные недавно, скорее всего, будут быстро освобождены, поэтому сборщик мусора будет чаще пытается уничтожить именно их. .NET сначала просматривает объекты, которые были созданы с момента последней сборки мусора, и начинает рассматривать более старые объекты, только если у него не получилось освободить достаточно места.
Лучше всего это работает, если .NET может самостоятельно выбирать время сборки мусора. Вызывая GC.Collect() вручную, вы нарушаете эффективность процесса, поскольку это часто приводит к преждевременному устареванию новых объектов, что увеличивает вероятность дорогостоящей полной сборки мусора в ближайшем будущем.
Классы с финализаторами также могут нарушить бесперебойную работу сборщика мусора. Объекты этих классов не могут быть удалены немедленно, вместо этого они попадают в очередь финализаторов и удаляются из памяти только после того, как финализатор будет выполнен. Это означает, что любой объект, на который они ссылаются (и любой объект, на который ссылаются эти объекты, и так далее), должен храниться в памяти как минимум до момента вызова финализатора, и потребуется как минимум две сборки мусора, прежде чем память снова станет доступной. Если граф содержит много объектов с финализаторами, это может означать, что сборщику мусора потребуется много проходов, чтобы полностью освободить и удалить ненужные объекты.
Существует простой способ избежать этой проблемы — реализуйте интерфейс IDisposable на финализируемых классах, перенесите необходимые для финализации объекта действия в метод Dispose() , в конце которого вызовите GC.SuppressFinalize() . Финализатор затем может быть модифицирован так, чтобы в нем вызывался метод Dispose() . GC.SuppressFinalize() сообщает сборщику мусора, что объект больше не нуждается в финализации и может быть немедленно удален, в результате чего память будет освобождена гораздо быстрее.
Заключение
Понимать проблемы с памятью и производительностью в .NET-приложении станет намного проще, если потратить время на изучение работы сборщика мусора. .NET, хоть и облегчает управление памятью, все же не полностью устраняет необходимость отслеживать и управлять ресурсами. Тем не менее, в .NET проще использовать профилировщик памяти для диагностики и устранения проблем. Отслеживание потребления и освобождения памяти на ранних этапах разработки поможет уменьшить количество проблем, но даже в этом случае они все равно могут возникнуть из-за сложности фреймворка или библиотек сторонних разработчиков.
Примечание переводчика
Одной статьи, конечно, недостаточно, чтобы полностью описать работу сборщика мусора в .NET, так что многие аспекты здесь опущены, многие — упрощены. Тем не менее, статья может дать новичкам начальное представление о сборке мусора в .NET, позволяя даже свои первые приложения писать с оглядкой на производительность и избегать многих распространенных ошибок.
Тем же, кто прочитав статью заинтересовался этой темой, я могу посоветовать ставшую уже классикой в мире .NET книгу Джеффри Рихтера «CLR via C#», в которой вы найдете не только гораздо более подробное описание процесса сборки мусора в .NET, но и массу другой полезной информации.