Оптимизация программ под Garbage Collector

habr.png

Не так давно на Хабре появилась прекрасная статья Оптимизация сборки мусора в высоконагруженном .NET сервисе. Эта статья очень интересна тем, что авторы, вооружившись теорией сделали ранее невозможное: оптимизировали свое приложение, используя знания о работе GC. И если ранее мы не имели ни малейшего понятия, как этот самый GC работает, то теперь он нам представлен на блюдечке стараниями Конрада Кокоса в его книге Pro .NET Memory Management. Какие выводы почерпнул для себя я? Давайте составим список проблемных областей и подумаем, как их можно решить.

На недавно прошедшем семинаре CLRium #5: Garbage Collector мы проговорили про GC весь день. Однако, один доклад я решил опубликовать с текстовой расшифровкой. Это доклад про выводы относительно оптимизации приложений.



Проблема

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

При этом одна ссылка со старшего во младшее поколение заставляет накрывать область карточным столом:


  • 4 байта перекрывает 4 Кб или макс. 320 объектов — для x86 архитектуры
  • 8 байт перекрывает 8 Кб или макс. 320 объектов — для x64 архитектуры

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

Поэтому разреженные ссылки в младшее поколение сделают GC более трудоёмким


Решение


  • Располагать объекты со связями в младшее поколение — рядом;
  • Если предполагается трафик объектов нулевого поколения, воспользоваться пуллингом. Т.е. сделать пул объектов (новых не будет: не будет объектов нулевого поколения). И далее, «прогрев» пул двумя последовательными GC чтобы его содержимое гарантированно провалилось во второе поколение, вы избегаете тем самым ссылок на младшее поколение и имеете нули в карточном столе;
  • Избегать ссылок в младшее поколение;


Проблема

Как следует из алгоритмов фазы сжатия объектов в SOH:


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

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


Решение


  • Располагать сильно-связные объекты рядом, в одном поколении
  • Избегать лишних связей в целом (например, вместо дублирования ссылок this→handle стоит воспользоваться уже существующей this→Service→handle)
  • Избегайте кода со скрытой связностью. Например, замыканий


Проблема

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


Решение


  • При помощи PerfMon / Sysinternal Utilities проконтролировать точки выделения новых сегментов и их декоммитинг и освобождение
  • Если речь идет о LOH, в котором идёт плотный трафик буферов, воспользоваться ArrayPool
  • Если речь идет о SOH, убедиться что объекты одного времени жизни выделяются рядом, обеспечивая срабатывание Sweep вместо Collect
  • SOH: использовать пулы объектов


Проблема

Нагруженный участок кода выделяет память:


  • Как результат, GC выбирает окно аллокации не 1Кб, а 8Кб.
  • Если окну не хватает места, это приводит к GC и расширению закоммиченой зоны
  • Плотный поток новых объектов заставит короткоживущие объекты с других потоков быстро уйти в старшее поколение с худшими условиями сборки мусора
  • Что приведет к расширению времени сборки мусора
  • Что приведет к более длительным Stop the World даже в Concurrent режиме


Решение


  • Полный запрет на использование замыканий в критичных участках кода
  • Полный запрет боксинга на критичных участках кода (можно использовать эмуляцию через пуллинг если необходимо)
  • Там где необходимо создать временный объект под хранение данных, использовать структуры. Лучше — ref struct. При количестве полей более 2-х передавать по ref


Проблема

Размещение массивов в LOH приводит либо к его фрагментации либо к утяжелению процедуры GC


Решение


  • Использовать разделение массивов на подмассивы и класса, инкапсулирующего логику работы с такими массивами (т.е. вместо List, где хранится мега-массив, свой MyList с array[][], разделяющий массив на несколько покороче)
    • Массивы уйдут в SOH
    • После пары сборок мусора лягут рядом с вечноживущими объектами и перестанут влиять на сборку мусора
  • Контролировать использования массивов double, длинной более 1000 элементов.


Проблема

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


Решение


  • Использование выделения памяти на стеке, где возможно:
    • Оно не нагружает кучу
    • Не нагружает GC
    • Освобождение памяти — моментальное
  • Использовать Span T x = stackalloc T[]; вместо new T[] где возможно
  • Использовать Span/Memory где это возможно
  • Перевести алгоритмы на ref stack типы (StackList: struct, ValueStringBuilder)


Проблема

Задуманные как короткоживущие, объекты попадают в gen1, а иногда и в gen2.
Это приводит к утяжеленному GC, который работает дольше


Решение


  • Необходимо освобождать ссылку на объект как можно раньше
  • Если длительный алгоритм содержит код, который работает с какими-либо объектами, разнесенный по коду. Но который может быть сгруппирован в одном месте, необходимо его сгруппировать, разрешая тем самым собрать их раньше.
    • Например, на строке 10 достали коллекцию, а на строке 120 — отфильтровали.


Проблема

Часто кажется что если вызвать GC.Collect (), то это исправит ситуацию


Решение


  • Гораздо корректнее выучить алгоритмы работы GC, посмотреть на приложение под ETW и другими средствами диагностики (JetBrains dotMemory, …)
  • Оптимизировать наиболее проблемные участки


Проблема

Pinning создает целый ряд проблем:


  • Усложняет сборку мусора
  • Создает пробелы свободной памяти (ноды free-list items, bricks table, buckets)
  • Может оставить некоторые объекты в более младшем поколении, образуя при этом ссылки с карточного стола


Решение

Если другого выхода нет, используйте fixed () {}. Этот способ фиксации не делает реальной фиксации: она происходит только тогда, когда GC сработал внутри фигурных скобок.


Проблема

Финализация вызывается не детерменированно:


  • Невызванный Dispose () приводит к финализации со всеми исходящими ссылками из объекта
  • Зависимые объекты задерживаются дольше запланированного
  • Стареют, перемещаясь в более старые поколения
  • Если они при этом содержат ссылки на более младшие, порождают ссылки с карточного стола
  • Усложняя сборку старших поколений, фрагментируя их и приводя к Compacting вместо Sweep


Решение

Аккуратно вызывать Dispose ()


Проблема

При большом количестве потоков растет количество allocation context, т.к. они выделяются каждому потоку:


  • Как следствие — быстрее наступает GC.Collect.
  • Вследствие нехватки места в эфимерном сегменте вслед за Sweep наступит Collect


Решение


  • Контролировать количество потоков по количеству ядер


Проблема

При траффике объектов разного размера и времени жизни возникает фрагментация:


  • Повышение Fragmentation ratio
  • Срабатывание Collection с фазой изменения адресов во всех ссылающихся объектах


Решение

Если предполагается траффик объектов:


  • Проконтролировать наличие лишних полей, приблизив размеры
  • Проконтролировать отсутствие манипуляций со строками: там, где возможно, заменить на ReadOnlySpan/ReadOnlyMemory
  • Освобождать ссылку как можно раньше
  • Воспользуйтесь пуллингом
  • Кэши и пулы «прогревайте» двойным GC чтобы уплотнить объекты. Тем самым вы избегаете проблем с карточным столом.

© Habrahabr.ru