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

КДПВ
КДПВ

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

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

Как работает сборщик мусора

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

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

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

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

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

  1. Локальные переменные ссылочного типа в методе, который выполняется в данный момент.

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

  2. Статические поля.

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

  3. Управляемые объекты, переданные в неуправляемую библиотеку через Interop.

    Если управляемый объект передается в неуправляемую библиотеку COM+ через Interop, то он станет корневым объектом с подсчетом ссылок. Это происходит потому, что COM+ не выполняет сборку мусора. Вместо этого он использует систему подсчета ссылок. Как только библиотека COM+ завершает работу с объектом, устанавливая счетчик ссылок в 0, он перестает быть корневым и может быть удален.

  4. Ссылки на объекты с финализатором.

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

Граф объектов

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

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

Граф связей между объектами и корнями (корни обозначены как "GC root")
Граф связей между объектами и корнями (корни обозначены как "GC root")

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

Древовидное представление связей относительно корня GC root 2
Древовидное представление связей относительно корня GC root 2

Это упрощает представление о том, как объекты располагаются в памяти, удобно при написании приложений и при использовании отладчика. Однако из-за этого легко забыть, что объект может быть связан более, чем с одним корнем. Именно из-за этого обычно и происходят утечки памяти в .NET — разработчик забывает или не понимает, что объект привязан более, чем к одному корню. Например, в случае, показанном на схеме выше, установка корня GC root 2 в null на самом деле не позволит сборщику мусора удалить ни одного объекта. Это видно при просмотре полного графа, но не понятно при изучении дерева.

Профилировщик памяти позволяет взглянуть на граф иначе — как на дерево, начинающееся с какого-либо корневого объекта (не следует путать корни сборщика мусора и корневые объекты дерева). Следуя по ссылкам, указывающим на объекты дерева начиная с корневого (т. е. в обратном направлении), мы можем поместить в его листья все корни сборщика мусора. Например, начиная с объекта ClassC, на который ссылается корень GC root 2, мы можем проследить все ссылки и получить следующий граф:

Древовидное представление связей относительно объекта ClassC
Древовидное представление связей относительно объекта 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, но и массу другой полезной информации.

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


  1. beskaravaev
    22.11.2021 10:56
    +5

    Статья конечно хорошая, но не рассказывает ничего нового, чего не было бы в других подобных. Но для новичков да, пусть лучше будет, а вот для тех кто заинтересовался и хочет глубже, лучше читать Pro .Net Memory Management от Konrad Kokosa и\или посмотреть доклады с CLRium о работе GC (есть в свободном доступе на ТыТруба)


    1. Kolonist Автор
      22.11.2021 11:52

      Согласен, статья исключительно для новичков. Мне она показалась довольно простой для восприятия, потому и решил перевести.


    1. s_lim
      22.11.2021 16:09
      +1

      Есть еще прекрасная книга "Writing High-Performance .NET Code" by Ben Watson. Там довольно подробно расписана утилизация памяти, методы и средства диагностики проблем с памятью (и не только). Вроде как даже перевод на русский имеется.


  1. GoodLuckGuys
    22.11.2021 21:42

    Хорошая статья, только можно добавить почему именно поколений 3 и стадии сборки мусора. Но ещё может невнимательно читал, но не увидел, что в LOH объекты не сдвигаются.


    1. Kolonist Автор
      22.11.2021 21:49
      +1

      Про то, что в LOH объекты не сдвигаются, тут есть. Про поколения и причину их разделения тоже есть, просто нет про то, что их именно 3. Но это перевод, так что даже не знаю, насколько этично будет что-то добавлять от себя.

      А кстати, почему поколений именно 3?


      1. WhiteBlackGoose
        23.11.2021 22:22

        Не отвечу на вопрос, но вообще например в Mono поколений 2. Думаю, что в дотнете просто решили, что два - маловато, а четыре уже не надо.)


        1. Kolonist Автор
          23.11.2021 22:29

          Ну просто предыдущий комментатор советовал написать в статье, почему поколений именно 3, вот я и спросил. Потому что я, например, не знаю. И в Гугле с наскока не нашел.


        1. Kolonist Автор
          24.11.2021 00:05

          Рихтер пишет, что так решили на основании огромного эмпирического опыта. Ну т. е. методом подбора.


      1. ISkomorokh
        01.12.2021 00:37
        +1

        Про то, что в LOH объекты не сдвигаются, тут есть.

        Про LOH информация устарела. GC умеет "дефрагметировать" LOH. https://docs.microsoft.com/en-us/dotnet/api/system.runtime.gcsettings.largeobjectheapcompactionmode?view=net-6.0#System_Runtime_GCSettings_LargeObjectHeapCompactionMode


        1. Kolonist Автор
          01.12.2021 01:41

          Да, в следующей статье об этом написано.


  1. RuslanChessplayer
    23.11.2021 23:57

    Описание параллельного и непараллельного режима мутновато в переводе получилось. В английском варианте все понятно.

    В оригинале сказано что серверный и десктопный это просто альтернативные названия параллельного и непаралельного. А в переводе говорится про "подрежимы". Непорядок.


    1. Kolonist Автор
      24.11.2021 00:03

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