Оптимизация программ под Garbage Collector
Не так давно на Хабре появилась прекрасная статья Оптимизация сборки мусора в высоконагруженном .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 чтобы уплотнить объекты. Тем самым вы избегаете проблем с карточным столом.