Оптимизация Сборки Мусора В Высоконагруженном Сервисе .Net

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

Мы считаем оперативность сервиса (скорость обработки запросов) важным конкурентным преимуществом, поскольку она напрямую влияет на пользовательский опыт. Ключевой показатель для нас — «процент медленных запросов».

Изучая его поведение, мы заметили, что раз в минуту на серверах приложений возникают паузы длительностью около 1000 мс.

В эти промежутки времени сервер не отвечает и появляется очередь из нескольких десятков запросов.

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



Оптимизация сборки мусора в высоконагруженном сервисе .
</p><p>
NET

Современные языки программирования можно разделить на две группы.

Такие языки, как C/C++ или Rust, используют ручное управление памятью, поэтому программисты тратят больше времени на написание кода, управление временем жизни объектов, а затем на отладку.

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

К ним относятся, например, Java, C#, Python, Ruby, Go, PHP, JavaScript и т. д. Программисты экономят время разработки, но это достигается ценой дополнительного времени выполнения, которое программа регулярно тратит на сбор мусора — освобождение памяти.

заняты объектами, на которые в программе не осталось ссылок.

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

Веб-серверы Pyrus работают на платформе .

NET, которая использует автоматическое управление памятью.

Большинство сборок мусора являются блокирующими («остановить мир»), т. е.

все потоки приложений останавливаются во время их работы.

Неблокирующие (фоновые) сборки фактически также останавливают все потоки, но на очень короткий период времени.

Пока потоки заблокированы, сервер не обрабатывает запросы, существующие запросы зависают, а новые ставятся в очередь.

В результате запросы, которые обрабатывались в момент сборки мусора, напрямую замедляются, а запросы сразу после завершения сборки мусора также медленнее из-за накопленных очередей.

Это ухудшает показатель «процент медленных запросов».

Вооружившись недавно вышедшей книгой Конрад Кокоса: Про управление памятью .

NET (о том, как мы за 2 дня привезли его первый экземпляр в Россию, можно написать отдельный пост), полностью посвященный теме управления памятью в .

NET, мы начали исследовать проблему.



Измерение

Для профилирования веб-сервера Pyrus мы использовали утилиту PerfView ( https://github.com/Microsoft/perfview ), предназначенный для профилирования приложений .

NET. Утилита основана на механизме Event Tracing for Windows (ETW) и оказывает минимальное влияние на производительность профилируемого приложения, что позволяет использовать ее на рабочем сервере.

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

Ничего не собираем — приложение работает в обычном режиме.

Кроме того, PerfView не требует перекомпиляции или перезапуска приложения.

Запустим трассировку PerfView с параметром /GCCollectOnly (время трассировки 1,5 часа).

В этом режиме он собирает только события сборки мусора и оказывает минимальное влияние на производительность.

Давайте посмотрим на отчет трассировки Memory Group/GCStats, а в нем сводку событий сборщика мусора:

Оптимизация сборки мусора в высоконагруженном сервисе .
</p><p>
NET

Здесь мы видим несколько интересных показателей:

  • Среднее время паузы сборки во 2-м поколении составляет 700 миллисекунд, а максимальная пауза — около секунды.

    На этом рисунке показано время, на которое остановлены все потоки в приложении .

    NET; в частности, эта пауза будет добавлена ко всем обработанным запросам.

  • Количество сборок 2-го поколения сопоставимо с количеством сборок 1-го поколения и ненамного меньше количества сборок 0-го поколения.

  • В столбце Induced показано 53 сборки 2-го поколения.

    Индуцированная коллекция является результатом явного вызова GC.Collect().

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

Поясним наблюдение по поводу количества сборок мусора.

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

Именно для этого режима предназначен сборщик мусора .

NET, и в этом режиме должно быть гораздо меньше сборок второго поколения, чем 0-го поколения.

То есть для оптимальной работы сборщика мусора мы должны подогнать работу нашего приложения под гипотезу поколений.

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

Это правило также применимо к другим платформам, использующим автоматическое управление памятью поколений, например Java. Интересующие нас данные можно извлечь из другой таблицы отчета GCStats:

Оптимизация сборки мусора в высоконагруженном сервисе .
</p><p>
NET

Здесь перечислены случаи, когда приложение пытается создать большой объект (в .

NET Framework объекты размером > 85000 байт создаются в LOH — Large Object Heap) и ему приходится ждать завершения сборки 2-го поколения, что и происходит. параллельно в фоновом режиме.

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

Раньше мы использовали версию .

NET Framework 4.6.1, а в версии 4.7.1 Microsoft улучшила сборщик мусора, теперь он позволяет выделять память в куче больших объектов во время фоновой сборки поколения 2: https://docs.microsoft.com/ru-ru/dotnet/framework/whats-new/#common-language-runtime-clr Поэтому мы обновились до последней на тот момент версии 4.7.2.

Сборки 2-го поколения

Почему у нас так много сборок старшего поколения? Первое предположение заключается в том, что у нас есть утечка памяти.

Чтобы проверить эту гипотезу, давайте посмотрим на размер второго поколения (мы настроили Zabbix для мониторинга соответствующих счетчиков производительности).

Из графиков размера 2-го поколения для 2-х серверов Pyrus видно, что его размер сначала растет (в основном за счет заполнения кешей), но затем стабилизируется (большие провалы на графике — это регулярный перезапуск веб-сервиса для обновления версии ):

Оптимизация сборки мусора в высоконагруженном сервисе .
</p><p>
NET

Это означает, что заметных утечек памяти нет, а значит, большое количество сборок 2-го поколения происходит по другой причине.

Следующая гипотеза — большой трафик памяти, т. е.

многие объекты попадают во 2-е поколение, и многие объекты там умирают. Для поиска таких объектов в PerfView предусмотрен режим /GCOnly. Из отчетов трассировки обратите внимание на «Стеки смерти объектов Gen 2 (грубая выборка)», которые содержат выборку объектов, умирающих во 2-м поколении, а также стеки вызовов мест, где эти объекты были созданы.

Здесь мы видим следующие результаты:

Оптимизация сборки мусора в высоконагруженном сервисе .
</p><p>
NET

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

Среди них:

  • System.Byte[] Если мы заглянем внутрь, то увидим, что больше половины — это буферы для сериализации в JSON:


Оптимизация сборки мусора в высоконагруженном сервисе .
</p><p>
NET

  • Slot[System.Int32][] (это часть реализации HashSet), System.Int32[] и т.д. Это наш код, который вычисляет клиентские кэши — те каталоги, формы, списки, друзей и т.д., которые данный пользователь видит и которые кэшируются в его браузере или мобильном приложении:


Оптимизация сборки мусора в высоконагруженном сервисе .
</p><p>
NET



Оптимизация сборки мусора в высоконагруженном сервисе .
</p><p>
NET

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

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

А при размере > 85000 байт память для них выделяется в Large Object Heap, которая собирается только со 2-го поколения.

Чтобы проверить, откройте раздел «GC Heap Alloc Ignore Free (Coarse Sampling) stacks» в результатах perfview/GCOnly. Там мы видим строку LargeObject, в которой PerfView группирует создание больших объектов, а внутри мы увидим все те же массивы, которые мы видели в предыдущем анализе.

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



Оптимизация сборки мусора в высоконагруженном сервисе .
</p><p>
NET



Оптимизация сборки мусора в высоконагруженном сервисе .
</p><p>
NET



Изменения в системе Pyrus

По результатам измерений мы определили основные направления дальнейшей работы: борьба с большими объектами при расчете клиентских кешей и сериализации в JSON. Есть несколько вариантов решения этой проблемы:
  • Самое простое — не создавать большие объекты.

    Например, если большой буфер B используется при последовательных преобразованиях данных A-> B-> C, то иногда эти преобразования можно объединить, чтобы получить A-> C, и избежать создания объекта B. Этот вариант не всегда применим.

    , но он самый простой и эффективный.

  • Пул объектов.

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

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

    Хорошим примером является ArrayPool в .

    NET Core, который также доступен в .

    NET Framework как часть пакета System.Buffers Nuget.

  • Используйте маленькие предметы вместо больших.

Давайте отдельно рассмотрим оба случая больших объектов — вычисление кэша клиента и сериализацию в JSON.

Вычисление клиентских кэшей

Веб-клиент Pyrus и мобильные приложения кэшируют данные, доступные пользователю (проекты, формы, пользователи и т. д.).

Кэширование используется для ускорения работы, а также необходимо для работы в автономном режиме.

Кэши вычисляются на сервере и передаются клиенту.

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

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

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

Вот почему мы увидели выделение памяти для больших временных массивов в куче больших объектов.

Разберем предложенные варианты избавления от создания крупных объектов:

  • Полная утилизация крупных объектов.

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

  • Использование пула объектов.

    У этого подхода есть трудности:

    • Разнообразие используемых коллекций и типы элементов в них: используются HashSet, List и Array (последние 2 можно комбинировать).

      Коллекции хранят Int32, Int64, а также всевозможные классы данных.

      Для каждого используемого типа понадобится свой пул, в котором также будут храниться коллекции разного размера.

    • Трудный срок жизни коллекций.

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

      Это можно сделать, если объект используется в одном методе.

      Но в нашем случае ситуация сложнее, так как многие крупные объекты перемещаются между методами, помещаются в структуры данных, передаются в другие структуры и т. д.

    • Выполнение.

      Есть ArrayPool от Microsoft, но нам также нужны List и HashSet. Подходящей библиотеки мы не нашли, поэтому нам пришлось реализовывать классы самостоятельно.

  • Использование мелких предметов.

    Большой массив можно разделить на несколько маленьких частей, которые не будут загружать кучу больших объектов, а будут созданы в 0-м поколении, а затем идти по стандартному пути к 1-му и 2-му.

    Надеемся, что они не доживут до 2-го поколения, а будут собраны сборщиком мусора в 0-м или хотя бы в 1-м поколении.

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

    Сложности:

    • Выполнение.

      Подходящих библиотек мы не нашли, поэтому классы пришлось писать самим.

      Нехватка библиотек понятна, поскольку сценарий «коллекции, не загружающие Large Object Heap» — это очень узкая область применения.

Мы решили пойти по третьему пути и изобрести собственное колесо, написать List и HashSet, которые не загружают кучу больших объектов.



Кусочный список

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

А используемая нами библиотека Newtonsoft.Json может автоматически сериализовать ее, поскольку она реализует IEnumerable. :

  
  
   

public sealed class ChunkedList<T> : IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable, IList, ICollection, IReadOnlyList<T>, IReadOnlyCollection<T> {

Стандартный список имеет следующие поля: массив для элементов и количество заполненных элементов.

Чанкедлист имеет массив массивов элементов, количество полностью заполненных массивов, количество элементов в последнем массиве.

Каждый из массивов элементов размером менее 85000 байт:

Оптимизация сборки мусора в высоконагруженном сервисе .
</p><p>
NET



private T[][] chunks; private int currentChunk; private int currentChunkSize;

Поскольку ChunkedList довольно сложен, мы написали для него подробные тесты.

Любую операцию необходимо тестировать как минимум в 2-х режимах: «малом», когда весь список умещается в один кусок размером до 85 000 байт, и «большом», когда он состоит более чем из одного куска.

Причем для методов, меняющих размер (например, Add), сценариев еще больше: «маленький» -> «маленький», «маленький» -> «большой», «большой» -> «большой», «большой».

» -> «маленький».

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

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

Их реализация и тестирование будут довольно дорогими.

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

То есть все тесты устроены так: создаём List и Чанкедлист , проделайте над ними те же операции и сравните результаты.

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

в ChunkedList .

Давайте протестируем, например, добавление элементов в список:

[Benchmark] public ChunkedList<int> ChunkedList() {

Теги: #программирование #Высокая производительность #C++ #.

NET #производительность #сборка мусора #ASP.NET #ASP #ASP #сборка мусора #сборщик мусора #gc #gc #управление памятью #perfview #c#.

net #pyrus #etw

Вместе с данным постом часто просматривают: