Garbage Collector. Полный курс + перевод из BOTR

  • Tutorial

В данной статье вы встретите сразу два источника информации:


  1. Полный курс по работе Garbage Collector на русском языке: CLRium #6 (текущий семинар здесь)
  2. Перевод статьи из BOTR "Устройство сборщика мусора" от Маони Стевенс.


1. CLRium #5: Полный курс по Garbage Collector



2. Устройство сборщика мусора by Maoni Stephens (@maoni0)


Примечание: чтобы подробнее узнать о сборке мусора в целом см. справочник The Garbage Collection Handbook; специализированная информация о сборщике мусора в CLR приведена в книге Pro .NET Memory Management. Ссылки на оба ресурса даны в конце документа.


Архитектура компонентов


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


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


Устройство распределителя


Распределитель вызывается вспомогательными компонентами среды выполнения с указанием следующей информации:


  • необходимый размер выделяемого участка;
  • контекст выделения памяти для потока исполнения;
  • флаги, которые указывают, например, является ли объект финализируемым.

Сборщик мусора не предусматривает специальных методов обработки для разных типов объектов. Он получает информацию о размере объекта от среды выполнения.


В зависимости от размера, сборщик делит объекты на две категории: маленькие (< 85 000 байт) и большие (>= 85 000 байт). В целом сборка маленьких и больших объектов может происходить одинаково. Однако сборщик разделяет их по размеру, поскольку сжатие больших объектов требует много ресурсов.


Сборщик мусора выделяет память распределителю с учётом контекстов выделения. Размер контекста выделения определяется блоками выделяемой памяти.


  • Контексты выделения – небольшие области определённого сегмента кучи, каждая из которых предназначена для определённого потока исполнения. На машине с одним процессором (имеется в виду 1 логический процессор) используется один контекст выделения памяти для объектов поколения 0.


  • Блок выделяемой памяти – объём памяти, выделяемый распределителем каждый раз, когда ему требуется больше памяти, чтобы расположить объект внутри области. Размер блока обычно составляет 8 Кб, а средний размер управляемых объектов – 35 байт. Поэтому в одном блоке можно расположить множество объектов.



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


Распределитель устроен таким образом, чтобы:


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


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


  • эффективно использовать кэш: распределитель выделяет память блоками, а не для каждого объекта. Он обнуляет так много памяти, чтобы подготовить кэш процессора, поскольку некоторые объекты будут размещены прямо в нём. Блок выделяемой памяти обычно равен 8 Кб.


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


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


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



API


 Object* GCHeap::Alloc(size_t size, флаги DWORD);
 Object* GCHeap::Alloc(alloc_context* acontext, size_t size, флаги DWORD);

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


 Object* GCHeap::AllocLHeap(size_t size, флаги DWORD);

Устройство сборщика


Задачи сборщика мусора


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


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

Логическое описание управляемой кучи


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


В случае маленьких объектов куча делится на три поколения: gen0, gen1 и gen2. Для больших объектов существует только одно поколение – gen3. Gen0 и gen1 называют эфемерными поколениями (объекты живут в них короткое время).


Для кучи небольших объектов номер поколения означает их возраст. Например, gen0 – самое молодое поколение. Это не значит, что все объекты в gen0 моложе объектов в gen1 или gen2. Здесь существуют исключения, которые описаны ниже. Сборка поколения означает сборку объектов в этом поколении, а также во всех его более молодых поколениях.


Теоретически сборка больших и маленьких объектов может происходить одинаково. Однако поскольку сжатие крупных объектов требует много ресурсов, их сборка происходит по-другому. Большие объекты содержатся только в gen2 и собираются только во время сборки мусора в этом поколении из соображений производительности. Как gen2, так и gen3 могут быть большими, а сборка объекта в эфемерных поколениях (gen0 и gen1) не должна быть слишком ресурсозатратной.


Объекты размещаются в самом младшем поколении. Для маленьких объектов это gen0, а для больших – gen3.


Физическое описание управляемой кучи


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


В каждой куче маленьких объектов есть только один эфемерный сегмент, где находятся поколения gen0 и gen1. Этот сегмент может содержать или не содержать объекты поколения gen2. Кроме эфемерных сегментов, могут существовать один или более дополнительных сегментов, которые будут сегментами gen2, так как они содержат объекты поколения 2.


Куча больших объектов состоит из одного или более сегментов.


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


Сегменты кучи выделяются по мере необходимости. Если они не содержат используемых объектов, сегменты удаляются. Однако начальный сегмент в куче всегда существует. За один раз для каждой кучи выделяется один сегмент. В случае маленьких объектов это происходит во время сборки мусора, а для больших – во время выделения памяти для них. Такая схема повышает производительность, поскольку большие объекты собираются только в поколении 2 (что требует много ресурсов).


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


Пороговое значение объёма выделенной памяти


Это логическое понятие, связанное с размером каждого поколения. Если он превышен, в поколении начинается сборка мусора.


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


Выбор поколения для сборки мусора


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


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

Процесс сборки мусора


Этап маркировки


Во время этого этапа CLR должна найти все живые объекты.


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


Чтобы пометить старые объекты, ссылающиеся на новые, сборщик мусора использует специальные биты. Биты устанавливаются механизмом JIT-компилятора во время операций присвоения. Если объект принадлежит к эфемерному поколению, JIT-компилятор установит байт, содержащий бит, указывающий на исходное положение. Собирая мусор в эфемерных поколениях, сборщик может использовать эти биты для всей оставшейся кучи и просмотреть только те объекты, которым эти биты соответствуют.


Этап планирования


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


Этап перемещения


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


Этап сжатия


Этот этап достаточно прост, поскольку сборщик уже определил новые адреса для перемещения объектов во время этапа планирования. При сжатии объекты будут скопированы по этим адресам.


Этап уборки


Во время этого этапа сборщик ищет неиспользуемое пространство между живыми объектами. Вместо этого пространства он создаёт свободные объекты. Неиспользуемые объекты, находящиеся рядом, становятся одним свободным объектом. Все свободные объекты помещаются в список свободных объектов.


Code flow


Термины:


  • WKS GC: Сборка мусора в режиме рабочей станции
  • SVR GC: Сборка мусора в режиме сервера

Функциональное поведение


WKS GC без параллельной сборки мусора

  1. Пользовательский поток использовал всю выделенную для него память и вызывает сборщик мусора.
  2. Сборщик вызывает SuspendEE, чтобы приостановить все управляемые потоки.
  3. Сборщик выбирает поколение для очистки.
  4. Начинается маркировка объектов.
  5. Сборщик переходит на этап планирования и определяет необходимость сжатия.
  6. При необходимости сборщик перемещает объекты и выполняет сжатие. В другом случае – просто выполняет уборку.
  7. Сборщик вызывает RestartEE, чтобы вновь запустить управляемые потоки.
  8. Пользовательские потоки продолжают работу.

WKS GC с параллельной сборкой мусора

Этот алгоритм описывает фоновую сборку мусора.


  1. Пользовательский поток использовал всю выделенную для него память и вызывает сборщик мусора.
  2. Сборщик вызывает SuspendEE, чтобы приостановить все управляемые потоки.
  3. Сборщик определяет, нужно ли запустить фоновую сборку мусора.
  4. Если да, активируется поток фоновой сборки мусора. Этот поток вызывает RestartEE, чтобы возобновить управляемые потоки.
  5. Выделение памяти для управляемых процессов продолжается одновременно с фоновой сборкой мусора.
  6. Пользовательский поток может использовать всю выделенную для него память и запустит эфемерную сборку мусора (также известную как высокоприоритетная сборка мусора). Она выполняется так же, как в режиме рабочей станции без параллельной сборки мусора..
  7. Поток фоновой сборки мусора снова вызывает SuspendEE, чтобы завершить маркировку, а затем вызывает RestartEE, чтобы запустить параллельную уборку с работающими пользовательскими потоками.
  8. Фоновая сборка мусора завершается.

SVR GC без параллельной сборки мусора

  1. Пользовательский поток использовал всю выделенную для него память и вызывает сборщик мусора.
  2. Потоки сборки мусора в режиме сервера активируются и вызывают SuspendEE, чтобы приостановить выполнение управляемых потоков.
  3. Потоки сборки мусора в режиме сервера выполняют те же операции, что и в режиме рабочей станции без параллельной сборки мусора.
  4. Потоки сборки мусора в режиме сервера вызывают RestartEE, чтобы запустить управляемые потоки.
  5. Пользовательские потоки продолжают работу.

SVR GC с параллельной сборкой мусора

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


Физическая архитектура


Эта секция поможет понять code flow.


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


Функция try_allocate_more_space вызывает GarbageCollectGeneration, когда нужно запустить сборщик мусора.


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


     GarbageCollectGeneration()
     {
         SuspendEE();
         garbage_collect();
         RestartEE();
     }

     garbage_collect()
     {
         generation_to_condemn();
         gc1();
     }

     gc1()
     {
         mark_phase();
         plan_phase();
     }

     plan_phase()
     {
         // фактический этап планирования, чтоб принять 
         // решение о сжатии
         if (compact)
         {
             relocate_phase();
             compact_phase();
         }
         else
             make_free_lists();
     }

Если выполняется параллельная сборка мусора в режиме рабочей станции (по умолчанию), поток кода для фоновой сборки мусора выглядит так:


     GarbageCollectGeneration()
     {
         SuspendEE();
         garbage_collect();
         RestartEE();
     }

     garbage_collect()
     {
         generation_to_condemn();
         // решение провести фоновую сборку
         // активация потока фоновой сборки мусора
         do_background_gc();
     }

     do_background_gc()
     {
         init_background_gc();
         start_c_gc ();

         // подождать пока фоновый процесс сборки мусора не перезапустить работу управляемых потоков.
         wait_to_proceed();
     }

     bgc_thread_function()
     {
         while (1)
         {
             // подождать возникновения события
             // активировать
             gc1();
         }
     }

     gc1()
     {
         background_mark_phase();
         background_sweep();
     }

Ссылки на ресурсы


  • +22
  • 7,1k
  • 2
Семинары Станислава Сидристого
75,21
CLRium #6: Concurrency & Parallelism
Поддержать автора
Поделиться публикацией

Комментарии 2

    +1

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

      0
      Есть ощущение, что присутствуют ошибки либо не очень точное изложение, которое запутывает.
      Для больших объектов существует только одно поколение – gen3.

      И потом дальше по тексту буквально через одно предложение.
      Большие объекты содержатся только в gen2

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

      Самое читаемое