Разновидности сборщиков мусора

53

Среда выполнения .NET поддерживает несколько разновидностей сборщиков мусора, даже при том, что внешне она выглядит как огромный монолит кода с ограниченными возможностями настройки. Эти разновидности предназначены для использования в разных ситуациях: в клиентских приложениях, в высокопроизводительных серверных приложениях, и так далее. Чтобы разобраться в отличительных чертах этих разновидностей, необходимо посмотреть, как сборщик мусора взаимодействует с прикладными потоками выполнения (часто называемыми mutator threads).

Приостановка потоков для сборки мусора

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

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

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

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

Приостановка потоков на время работы сборщика мусора

Приостановка прикладных потоков выполнения на время работы сборщика мусора выполняется в безопасных точках (safe points). Не каждая инструкция может быть прервана, чтобы дать сборщику мусора возможность выполнить свою работу. JIT-компилятор генерирует информацию о точках в выполняемом коде, где можно безопасно приостановить поток и приступить к сборке мусора, и среда выполнения CLR старается приостанавливать потоки именно в таких точках - она не будет вызывать SuspendThread(), не убедившись, что это безопасно для потока.

В CLR 2.0 были возможны ситуации, когда управляемый поток, выполняющий массивные вычисления в глубоком цикле, мог не достигать безопасной точки длительное время, вызывая задержки в работе сборщика мусора до 1500 миллисекунд (что, в свою очередь, вызывало задержки в работе потоков, приостановленных сборщиком мусора). Эта проблема была исправлена в версии CLR 4.0. Имейте в виду, что неуправляемые потоки выполнения не могут быть приостановлены сборщиком мусора, пока не вернутся в управляемый код - об этом позаботится механизм P/Invoke.

Приостановка потоков в фазе маркировки

В фазе маркировки сборщик мусора выполняет в основном операции чтения. Тем не менее, на этом этапе могут возникать ошибки первого и второго рода.

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

Объект, добавленный в уже промаркированную часть графа

На рисунке показан объект, добавленный в уже промаркированную часть графа (заштрихованными квадратиками обозначены промаркированные объекты). Из-за этого объект ошибочно может быть принят за неиспользуемый. Решить эту проблему можно, перехватывая операции создания новых ссылок (и объектов) и маркируя их. Это требует дополнительных затрат на синхронизацию и увеличивает стоимость выделения памяти, но позволяет другим потокам выполняться параллельно со сборкой мусора.

Объект, промаркированный сборщиком мусора, может оказаться неиспользуемым, если последняя ссылка на него была удалена в ходе выполнения фазы маркировки:

Объект, удаленный из графа уже после того, как оно было помечен

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

Приостановка потоков в фазе чистки

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

Решения этих проблем найдены (например, эти проблемы исправлены в сборщике Azul Pauseless для JVM), но они не реализованы в сборщике мусора для CLR. Гораздо проще объявить, что фаза чистки не поддерживает параллельное выполнение прикладных потоков.

Чтобы выяснить, принесет ли выгоду вашему приложению параллельное выполнение потоков и сборщика мусора, определите сначала, сколько времени обычно уходит на сборку мусора. Если приложение тратит половину всего времени на освобождение памяти, тогда перед вами открывается широкое поле для оптимизации. Если, напротив, сборка мусора выполняется раз в несколько минут, возможно вам стоит подумать о приложении своих усилий в другом направлении. Узнать, сколько времени тратится на сборку мусора, можно с помощью счетчика производительности «% Time in GC» находящегося в категории «.NET CLR Memory» профилировщика CLR Profiler.

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

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

Первая разновидность сборщика мусора, с которой мы познакомимся, называется сборщиком мусора для рабочей станции (workstation GC). Эта разновидность делится на два подвида: параллельный сборщик мусора для рабочей станции (concurrent workstation GC) и непараллельный сборщик мусора для рабочей станции (non-concurrent workstation GC).

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

Параллельный сборщик мусора для рабочей станции

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

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

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

Работа параллельного сборщика мусора

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

Таким образом, приложения с графическим интерфейсом, использующие параллельный сборщик мусора, должны приложить все усилия, чтобы исключить возможность запуска из потока управления пользовательским интерфейсом. Для этого достаточно обеспечить выделение памяти только в фоновых потоках выполнения и воздерживаться от явного вызова GC.Collect() в потоке пользовательского интерфейса.

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

Непараллельный сборщик мусора для рабочей станции

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

Работа непараллельного сборщика мусора

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

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

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

Приложения, использующие сборщик мусора для серверов, обладают следующими характеристиками:

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

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

Начиная с версии NT 6.1 (Windows 7 и Windows Server 2008 R2), операционная система Windows поддерживает более 64 логических процессоров, используя группы процессоров. Начиная с версии CLR 4.5, сборщик мусора так же поддерживает более 64 логических процессоров. Для этого требуется добавить элемент <GCCpuGroup enabled="true" /> в конфигурационный файл приложения.

Серверные приложения, вероятнее всего, получат определенные выгоды от использования версии сборщика мусора для сервера. Однако, как было сказано выше, по умолчанию используется параллельный сборщик мусора для рабочей станции. Это верно для всех приложений, выполняющихся в среде CLR с настройками по умолчанию, - консольных приложений, Windows-приложений и Windows-служб. В других средах CLR имеется возможность выбрать другою версию сборщика мусора. Одной из таких сред выполнения является ASP.NET: она выполняет приложения, выбирая для них версию сборщика мусора для сервера, потому сервер IIS обычно устанавливается на компьютеры с несколькими процессорами (впрочем, этот выбор можно изменить настройками в файле Web.config).

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

Выбор разновидности сборщика мусора

Управлять выбором разновидности сборщика мусора можно с помощью интерфейсов размещения CLR (Hosting interfaces). Однако имеется также возможность определять версию сборщика мусора в конфигурационном файле приложения (App.config). Ниже приводится разметка XML из конфигурационного файла приложения, с помощью которой можно реализовать выбор между версиями и подверсиями сборщика мусора:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <runtime>
    <gcServer enabled="true" />
    <gcConcurrent enabled="false" />
  </runtime>
</configuration>

Элемент gcServer управляет выбором разновидности сборщика мусора - для сервера или для рабочей станции. Элемент gcConcurrent управляет выбором подвида сборщика мусора для рабочей станции.

В версии .NET 3.5 (включая .NET 2.0 SP1 и .NET 3.0 SP1) появился прикладной программный интерфейс, позволяющий производить выбор во время выполнения. Он реализован в виде класса GCSettings из пространства имен System.Runtime, с двумя свойствами: IsServerGC и LatencyMode.

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

Свойство LatencyMode, напротив, может принимать значения типа GCLatencyMode: Batch, Interactive, LowLatency и SustainedLowLatency. Значение Batch соответствует непараллельному сборщику мусора; значение Interactive - параллельному. Свойство LatencyMode допускается применять для переключения между параллельным и непараллельным сборщиком мусора во время выполнения.

Наибольший интерес представляют значения LowLatency и SustainedLowLatency. Они сообщают сборщику мусора, что ваш код в настоящий момент выполняет операции, чувствительные ко времени выполнения, и сборка мусора в данный момент нежелательна. Значение LowLatency появилось в .NET 3.5, оно поддерживается только параллельным сборщиком мусора для рабочей станции и предназначается для непродолжительных операций. Значение SustainedLowLatency было добавлено в CLR 4.5, поддерживается обеими разновидностями сборщика мусора, для сервера и для рабочей станции, и предназначается для выполнения продолжительных операций, в ходе которых приложение не должно приостанавливаться для полной сборки мусора.

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

Установка режима сборки мусора с низкой задержкой (low latency) предписывает сборщику воздерживаться от выполнения полной сборки, если в этом нет абсолютной необходимости, то есть, когда операционная система не испытывает недостатка физической памяти (интенсивный обмен с файлом подкачки гораздо хуже, чем выполнение полной сборки мусора). Режим низкой задержки не отключает сборщик мусора; частичная сборка (о которой будет рассказываться, когда мы перейдем к поколениям объектов) все еще выполняется, но времени на сборку мусора тратится значительно меньше.

Безопасное использование режима низкой задержки

Единственный безопасный способ использовать сборщик мусора в режиме с низкой задержкой - внутри области ограниченного выполнения (Constrained Execution Region, CER). Область CER - это ограниченный блок кода, где CLR не сможет возбудить неожиданное исключение (например, аварийное прерывание потока выполнения), которое не позволит блоку кода выполниться полностью. Код, заключенный в область CER, должен вызывать только код, обеспечивающий гарантии надежности.

Использование CER является единственным способом, гарантирующим установку режима задержки в прежнее состояние. Следующий фрагмент демонстрирует, как этого добиться (для его компиляции необходимо импортировать пространства имен System.Runtime.CompilerServices и System.Runtime):

GCLatencyMode oldMode = GCSettings.LatencyMode;
RuntimeHelpers.PrepareConstrainedRegions();

try
{
    GCSettings.LatencyMode = GCLatencyMode.LowLatency;
    // Произвести операции, чувствительные к продолжительности выполнения
}
finally
{
    GCSettings.LatencyMode = oldMode;
}

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

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

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

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

Пройди тесты
Лучший чат для C# программистов