Skip to content

Latest commit

 

History

History
51 lines (26 loc) · 21.3 KB

03-12-MemoryMenegement-GC-Results.md

File metadata and controls

51 lines (26 loc) · 21.3 KB

Выводы

{.big-quote} Адаптация курса в процессе

Давайте поговорим о выводах.

Первое: снижайте кросс-поколенческую связанность.

Проблема: для оптимизации скорости сборки мусора GC, по возможности, младшее поколение. Он старается это делать часто, чтобы уложится в какие-то миллисекунды. Чтобы сделать это, ему необходима информация о ссылках старших, с карточного стола. Если карточный стол пустой, в особенности если bundle table пустой (потому что именно он покрывает мегабайты своими ячейками), если мы везде встретим нули - это просто отличная информация и GC пролетит максимально быстро. Если на bundle table встречает не ноль, то GC идет на карточный стол, начинает анализировать его, опускается еще ниже: анализируются килобайты памяти, 320 объектов в максимуме, для того, чтобы понять, какой из этих 320 объектов ссылается на более младшее поколение. Поэтому разреженные ссылки, в хаотичном порядке - это самый худший результат.

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

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

Тут советы простые. Во-первых, располагать сильно-связанные объекты рядом во втором поколении. Это отсылка к карточному столу. Во-вторых, стоит избегать лишних связей в целом. Иногда бывает желание не обращаться через две ссылки к какому-то полю, разместить эту ссылку рядом. Таким образом добавляется еще третья ссылка на объект. А сильная связанность означает, что при сжатии надо будет намного больше этих ссылок обойти и исправить. Ссылок надо делать меньше. В том числе, избегать синтаксического сахара. Зачастую образуются аллокации, которых не видно. Как их увидеть? Можно установить расширение, которое показывает в коде скрытые аллокации. Это расширение в курсе, какие конструкции языка создают лишний трафик. Если хочется оптимизировать нагруженный код, это сильно помогает.

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

Если говорить о disposable, то вызов метода CheckDisposed() нужно поставить во все публичные места. Помимо public методов, это еще и protected методы, internal методы - все, что не private. Что самое неприятное, публичным методом является лямбда, которую вы куда-то отдали по подписке. В этот момент она стала публичным методом, теперь ее можно откуда-то дернуть. И туда тоже надо ставить CheckDisposed(). Такие вещи могут породить лишнюю связанность. Когда вы что-то забыли и объект ушел на финализацию, он, через внутренние ссылки, тянет за собой все дерево, весь граф нашего приложения. Объекты, которые должны были быть собраны в нулевом поколении, незаметно уходят во второе.

Следующий совет - мониторьте использование сегментов. Если у нас интенсивно работает приложение, может возникнуть ситуация, когда выделение новых объектов приводит к задержкам. Как это делать? С использованием утилит. Например PerfMon, Sysinternal Utilities, dotMemory. Именно сегменты лучше смотреть их более системных утилит, типа Sysinternal. Нужно смотреть точки выделения новых сегментов, совпадают ли они с вашими просадками. Что с этим делать? Если речь идет о LOH, то если в нем идет плотный трафик буферов, то, возможно, стоит переключится на использование ArrayPool. Он для этого и был сделан. Вместо того, чтобы постоянно аллоцировать кучу массивов, которые сами по себе тяжелые элементы, используйте ArrayPool.

Если речь идет о SOH, стоит убедиться, что объекты одного времени жизни выделяются рядом, обеспечивая большую вероятность срабатывания sweep вместо collect (compact?). Если они рядом все уйдут, то пусть рядом и создадутся. Если у нас нагруженный код, внутри которого постоянно идут временные аллокации, то их лучше выделять из пула, чем через операцию new - она нагружает GC.

Еще один совет - не выделяйте память в нагруженных участках кода. Если так делать, то GC выбирает окно аллокации, не 1Кб, а 8. И если окну не хватает места, это приводит к GC и расширению заккоммиченной зоны. Плотный трафик новых объектов заставит короткоживущие объекты с других потоков быстро уйти в старшее поколение с худшими условиями сборки мусора. Если у нас плотный трафик, мы не успеваем старое освобождать, поэтому объекты, рассчитанные на существование в нулевом поколении, уходят в первое, где GC работает медленнее. Когда мы говорим "объекты, рассчитанные на существование в нулевом поколении", сражу же понимаем, что создаем объект, который будет жить не дольше секунды. Мы его создали, забили данными и практически сразу отпустили. Он рассчитан на жизнь в нулевом поколении и чем раньше его отпустить, тем лучше. Как это сделать? Не хранить ссылку. Например, у вас есть длинный метод со своей логикой. И вы метод формируете не по принципу действий друг за другом, а по принципу "похожие операции рядом". Тогда получается, что в начале идет инициализация, а внизу эти объекты используются. Возможно, это использование можно поставить повыше. Метод большой, делает кучу всего. А чем он больше, тем выше вероятность срабатывания в процессе GC. Если вы выше использование этих объектов поднимите, выше вероятность того, что эти объекты уйдут с хипа прямо посреди работы этого метода. Если объекты передержать, а GC сработает, эти объекты уйдут в первое поколение.

Полный запрет на использование замыканий в критичных участках кода. Полный запрет боксинга на критичных участках кода. Там, где необходимо создать временный объект под хранение данных, использовать структуры. Потому что структура ложиться на стеке, ничего не аллоцирует, моментально освобождается. Освобождение структуры требует простого сдвига указателя регистра SP. И не имеет значения, сколько у вас локальных переменных, их освобождение происходит с одинаковой скоростью вне зависимости от их количества. Еще лучше использовать ref struct - это stack only структуры. Для него джиттер может делать оптимизации.

При количестве полей более двух - передавать по референсу. Если в этом случае прокидывать через параметры - будет очень жирно, будет идти копирование. А если перетащить по референсу, то будет передан только указатель, что бесплатно.

Избегайте излишних выделений памяти в LOH. Размещение массивов в этой куче приводит либо к его фрагментации, либо к утяжелению процедуры GC.

Решения. Ипсользовать разделение массивов на подмассивы и класс, который их инкапсулирует. На одном из докладов классная техника рассказывалась. Когда большие массивы в этот хип уходят - это плохо. Можно разделить большой массив на ряд маленьких, сделать массив массивов. То есть, массив, ячейки которого указывают на массивы. И все это ляжет в SOH. Работать с этим легко. Массиву нужно выставить правильную длину, например 2048. Когда мы делает доступ по индексу, вместо того, чтобы делить на 2048, мы сдвигаем на 11 бит. Тем самым делаем очень быстрый доступ к элементам внутренних массивов с эмуляцией непрерывного куска памяти. При этом такие массивы уйдут в SOH, и, если использовать ArrayPool, они лягут во второе поколение и перестанут влиять на сборку мусора.

Также стоит контролировать использование массово double, чтобы они были меньше тысячи. Где оправдано и возможно использовать стек потока?Вместо того, чтобы делать оператор new, класс использовать стек потока. Например, есть ряд сверх-короткоживущих объектов - тех, которые живут в рамках вызова метода. Такие вещи создают трафик, особенно в нагруженных местах. Использование выделения на стеке, во-первых, полностью разгрузит кучу. Выделить память на стеке - это либо либо создать локальную переменную, либо использовать ключевое слово stackalloc. В моей книге есть целый раздел, посвященный этому оператору в главе "Стек потока". Это оператор C#, который выделяет память не в хипе, а в локальных переменных. Эта операция практически бесплатная и очень быстрая.

Чтобы изучить, как правильно использовать этот оператор, я решил, что надо обратиться к авторам. Открыл исходники и искал по тексту. 90% использования - это тесты. В других местах - очень редко. В частности, я встретил stack list и value stringbuilder. Чем плох обычный stringbuilder? Нам говорят, что для соединения строк плюс - это плохо. Что делает обычный stringbuilder? Это однозсвязный список, внутри которого есть кусочки массивов, которые формируют строку. Когда мы создаем строку, мы туда аппендим. Он заполняет кусочки по очереди. Получается, что вы избавитесь от проблемы фрагментации строками, но не до конца. В этом случае все равно много всего создается. Как избежать?

Большинство случаев использования simple stringbuilder и плюсов - это трассировка. Logger.trace() или logger.debug(). Вы формируете маленькие строчки из разнородных вещей. А value stringbuilder - это очень хорошая вещь, но есть одна проблема: его модификатор доступа internal. Но его можно обойти. Это stringbuilder на стеке. Вещь, которая строит строчку внутри локальный переменных, не аллоцируя вообще ничего. При создании экземпляр на вход ждет span - указатель на некий range памяти. Можно это написать следующим образом: Span x = stackalloc T[ ]. То есть вы среди локальных переменных выделяете память нужного размера и сохраняете в span. И дальше, на основе этого span создаете value stringbuilder, который внутри этого буфера будет строить строчку, не аллоцируя память вообще. Единсвенный случай, когда он аллоцирует память - если он не вместился. В остальных случаях все будет очень хорошо.Второй кейс - если ваш logger.info на вход принимает не строчку, а span, у вас даже строка не будет аллоцирована.

Стоит использовать span memory, где это возможно, потому что эта вещь, которая нас спасает от лишний аллокаций.Освобождайте объекты как можно раньше. Задуманные как короткоживущие объекты могут попасть в gen_1, а иногда и в gen_2. Это приводит к более тяжелому GC, который работает дольше. Поэтому необходимо освобождать ссылку на объект как можно раньше. Если длительный алгоритм содержит код, который работает с какими-то объектами, разнесенными по коду, необходимо сгруппировать, перенося использование ближе. Это увеличит вероятность того, что они будут собраны GC.

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

Еще один совет - избегайте пиннинга. Пиннинг создает кучу проблем. GC ходит вокруг запинненных объектов как по минному полю.

Избегайте финализации. Финализация вызывается не детерменированно. Она может не вызваться. Не вызванный Dispose() приводит к финализации со всеми исходящими ссылками из объекта, который держит другие объекты. Все это переносится в более старшее поколение, усложняя GC, приводя к полному GC во всех поколениях и заменой sweep на compacting.

Нужно аккуратно вызвать Dispose(). Избегать большого количества потоков. Вообще, количество потоков советуют держать в районе количества ядер. Больше нет смысла, если все они работают без ожидания.

Избегайте трафика объектов разного размера. При трафике объектов разного размера и времени жизни возникает фрагментация, как результат - повышение fragmentation ratio, срабатывание collection с изменением адресов во всех ссылающихся объектах. Поэтому решение - если предполагается трафик объектов, надо контролировать наличие лишних полей, приблизив размеры. Также проконтролировать отсутсвие манипуляций со строками. Там, где возможно, заменить readonly span на readonly memory. Освободить ссылку как можно раньше. Не обязательно обнулять, в методах достаточно поднять использование как можно выше.