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

    Ежедневно в сервисе Pyrus работают десятки тысяч сотрудников из нескольких тысяч организаций по всему миру. Отзывчивость сервиса (скорость обработки запросов) мы считаем важным конкурентным преимуществом, так как она напрямую влияет на впечатление пользователей. Ключевой метрикой для нас является «процент медленных запросов». Изучая ее поведение, мы заметили, что раз в минуту на серверах приложений возникают паузы длиной около 1000 мс. В эти промежутки сервер не отвечает и возникает очередь из нескольких десятков запросов. О поиске причин и устранении узких мест, вызванных сборкой мусора в приложении, пойдет речь в этой статье.



    Современные языки программирования можно разделить на две группы. В языках типа C/C++ или Rust используется ручное управление памятью, поэтому программисты тратят больше времени на написание кода, управление временем жизни объектов, а затем на отладку. При этом баги из-за неправильного использования памяти — одни из самых сложных в отладке, поэтому большинство современной разработки ведется на языках с автоматическим управлением памятью. К ним относятся, например, Java, C#, Python, Ruby, Go, PHP, JavaScript, и.т.д. Программисты экономят время разработки, но за это приходится платить дополнительным временем выполнения, которое программа регулярно тратит на сборку мусора — освобождение памяти, занятой объектами, на которые в программе не осталось ссылок. В небольших программах это время ничтожно, однако по мере роста числа объектов и интенсивности их создания сборка мусора начинает давать заметный вклад в общее время выполнения программы.

    Веб-серверы Pyrus работают на платформе .NET, где используется автоматическое управление памятью. Большинство сборок мусора — блокирующие ('stop the world'), т.е. на время своей работы останавливают все потоки (threads) приложения. Неблокирующие (фоновые) сборки на самом деле тоже останавливают все потоки, но на очень короткий период времени. Во время блокировки потоков сервер не обрабатывает запросы, имеющиеся запросы подвисают, новые складываются в очередь. В результате напрямую замедляются запросы, которые обрабатывались в момент сборки мусора, также медленнее выполняются запросы сразу по окончании сборки мусора из-за накопившихся очередей. Это ухудшает метрику «процент медленных запросов».

    Вооружившись недавно вышедшей книгой Konrad Kokosa: Pro .NET Memory Management (о том, как мы за 2 дня привезли в Россию ее первый экземпляр, можно написать отдельный пост), целиком посвященной теме управления памятью в .NET, мы начали исследование проблемы.

    Измерение


    Для профилирования веб-сервера Pyrus мы воспользовались утилитой PerfView (https://github.com/Microsoft/perfview), заточенной под профилирование .NET приложений. Утилита основана на механизме Event Tracing for Windows (ETW) и имеет минимальное влияние на производительность профилируемого приложения, что позволяет использовать ее на боевом сервере. Кроме того, влияние на производительность зависит от того, какие виды событий и какую информацию мы собираем. Не собираем ничего — приложение работает как обычно. Также PerfView не требует ни перекомпиляции, ни перезапуска приложения.

    Запустим трассировку PerfView с параметром /GCCollectOnly (время трассировки 1.5 часа). В этом режиме он собирает только события сборок мусора и оказывает минимальное влияние на производительность. Посмотрим на отчет трассировки Memory Group / GCStats, а в нем на сводку событий сборщика мусора:



    Тут мы видим сразу несколько интересных показателей:
    • Среднее время паузы сборки во 2-м поколении — 700 миллисекунд, а максимальная пауза около секунды. Данная цифра показывает время, на которое останавливаются все потоки в .NET приложении, в частности во все обрабатываемые запросы добавится эта пауза.
    • Количество сборок 2-го поколения сравнимо с 1-м поколением и ненамного меньше количества сборок 0-го поколения.
    • В столбце Induced указано 53 сборки во 2-м поколении. Induced-сборка — это результат явного вызова GC.Collect(). В нашем коде мы не нашли ни одного вызова этого метода, значит, виновата какая-то из используемых нашим приложением библиотек.

    Поясним наблюдение про количество сборок мусора. Идея разделить объекты по времени их жизни основана на гипотезе о поколениях (generational hypothesis): значительная часть создаваемых объектов умирает быстро, а большинство остальных живут долго (другими словами, мало объектов, имеющих «среднее» время жизни). Именно под этот режим и заточен сборщик мусора .NET, и в этом режиме сборок второго поколения должно быть гораздо меньше, чем 0-го поколения. То есть, для оптимальной работы сборщика мусора мы должны подгонять работу нашего приложения под гипотезу о поколениях. Сформулируем правило так: объекты должны либо умирать быстро, не доживая до старшего поколения, либо доживать до него и жить там вечно. Это правило применяется и для других платформ, где используется автоматическое управление памятью с разделением по поколениям, например, таким как Java.

    Интересные для нас данные можно извлечь из другой таблицы в отчете GCStats:



    Здесь перечислены случаи, когда приложение пытается создать большой объект (в .NET Framework объекты размером > 85000 байт создаются в LOH — Large Object Heap), и ему приходится ждать окончания сборки 2-го поколения, которая происходит параллельно в фоне. Эти паузы аллокатора не настолько критичны, как паузы сборщика мусора, так как они влияют только на один поток. До этого мы использовали версию .NET Framework 4.6.1, а в версии 4.7.1 Microsoft доработали сборщик мусора, теперь он позволяет выделять память в Large Object Heap во время фоновой сборки 2-го поколения: https://docs.microsoft.com/ru-ru/dotnet/framework/whats-new/#common-language-runtime-clr
    Поэтому мы обновились до последней на тот момент версии 4.7.2.

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


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



    Это означает, что нет заметных утечек памяти, то есть, большое количество сборок 2-го поколения возникает по другой причине. Следующая гипотеза — большой трафик памяти, т.е., во 2-е поколение попадает много объектов, и много объектов там умирает. Для нахождения таких объектов в PerfView имеется режим /GCOnly. Из отчетов трассировки обратим внимание на 'Gen 2 Object Deaths (Coarse Sampling) Stacks', который содержит выборку объектов, умирающих во 2-м поколении, вместе со стеками вызовов мест, где эти объекты были созданы. Тут мы видим следующие результаты:



    Раскрыв строку, внутри мы видим стек вызовов тех мест в коде, которые создают объекты, доживающие до 2-го поколения. Среди них:
    • System.Byte[] Если заглянуть внутрь, то мы увидими, что больше половины — это буферы для сериализации в JSON:



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





    Интересно, что буферы для JSON и для вычисления клиентских кэшей — это всё временные объекты, которые живут в течение одного запроса. Почему же они доживают до 2-го поколения? Обратим внимание, что все эти объекты — массивы достаточно большого размера. А при размере > 85000 байт память под них выделяется в Large Object Heap, которая собирается только вместе со 2-ым поколением.

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





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


    По результатам измерений мы выделили основные направления дальнейшей работы: борьба с большими объектами при вычислении клиентских кэшей и сериализации в JSON. Есть несколько вариантов решения этой проблемы:
    • Самое простое — не создавать больших объектов. Например, если большой буфер B используется в последовательных преобразованиях данных A->B->C, то иногда эти преобразования можно объединить, превратив в A->C, и избавившись от создания объекта B. Этот вариант не всегда применим, но при этом он самый простой и эффективный.
    • Пул объектов. Вместо того, чтобы постоянно создавать новые объекты и выкидывать, нагружая сборщик мусора, мы можем хранить коллекцию свободных объектов. В простейшем случае, когда нам нужен новый объект, то мы берем его из пула, или создаем новый, если пул пустой. Когда объект нам больше не нужен, мы возвращаем его в пул. Хороший пример — ArrayPool в .NET Core, который также доступен в .NET Framework в составе Nuget-пакета System.Buffers.
    • Использовать вместо больших объектов маленькие.

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

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


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

    Таким образом, на сервере регулярно происходит много вычислений клиентских кэшей, при этом создается много временных короткоживущих объектов. Если пользователь состоит в большой организации, то он может получить доступ к многим объектам, соответственно клиентские кэши для него будут большими. Именно поэтому мы видели выделение памяти под большие временные массивы в Large Object Heap.

    Проанализируем предложенные варианты избавления от создания больших объектов:
    • Полное избавление от больших объектов. Этот подход неприменим, так как в алгоритмах подготовки данных используются среди прочего сортировка и объединение множеств, а для них требуются временные буферы.
    • Использование пула объектов. У этого подхода есть сложности:
      • Разнообразие используемых коллекций и типов элементов в них: используются HashSet, List и Array (2 последних можно объединить). В коллекциях хранятся Int32, Int64, а также всевозможные классы данных. Для каждого используемого типа понадобится свой пул, который к тому же будет хранить коллекции разных размеров.
      • Сложное время жизни коллекций. Чтобы получить преимущества от пула, объекты в него придется возвращать после использования. Это можно сделать, если объект используется в одном методе. Но в нашем случае ситуация сложнее, так как многие большие объекты путешествуют между методами, кладутся в структуры данных, перекладываются в другие структуры, и т.д.
      • Реализация. Есть ArrayPool от Microsoft, но нам нужны еще List и HashSet. Мы не нашли какой-нибудь подходящей библиотеки, поэтому классы пришлось бы реализовывать самим.
    • Использование маленьких объектов. Большой массив можно разбить на несколько маленьких кусочков, которые не буду нагружать Large Object Heap, а будут создаваться в 0-м поколении, а дальше идти стандартным путем в 1-е и 2-е. Мы надеемся, что они не доживут до 2-го, а будут собраны сборщиком мусора в 0-м, или в крайнем случае в 1-м поколении. Плюс этого подхода в том, что изменения имеющегося кода минимальные. Сложности:
      • Реализация. Мы не нашли подходящих библиотек, поэтому классы пришлось бы писать самим. Отсутствие библиотек объяснимо, так как сценарий «коллекции, не нагружающие Large Object Heap» — это очень узкая область применения.

    Мы решили пойти по 3-му пути и изобрести свой велосипед написать List и HashSet, не нагружающие Large Object Heap.

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


    Наш ChunkedList<T> реализует стандартные интерфейсы, включая IList<T>, благодаря этому требуются минимальные изменения имеющегося кода. Да и используемая нами библиотека Newtonsoft.Json автоматически умеет его сериализовывать, так как он реализует IEnumerable <T>:

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

    У стандартного списка List<T> имеются следующие поля: массив для элементов и количество заполненных элементов. В ChunkedList<T> имеется массив массивов элементов, количество полностью заполненных массивов, количество элементов в последнем массиве. Каждый из массивов элементов при этом которых меньше 85000 байт:


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

    Так как ChunkedList<T> устроен довольно сложно, то на него мы написали подробные тесты. Любую операцию нужно тестировать как минимум в 2-х режимах: в «маленьком», когда весь список умещается в один кусок размером до 85000 байт, и «большом», когда он состоит более, чем из одного куска. При этом для методов, изменяющих размер (например, Add), сценариев еще больше: «маленький» -> «маленький», «маленький» -> «большой», «большой» -> «большой», «большой» -> «маленький». Тут возникает довольно много запутанных граничных случаев, с которыми хорошо справляются юнит-тесты.

    Ситуация упрощается тем, что часть методов из интерфейса IList не используются, и их можно не реализовывать (такие, как Insert, Remove). Их реализация и тестирование были бы довольно накладными. Кроме того, написание юнит-тестов упрощается тем, что нам не нужно придумывать новый функционал, ChunkedList<T> должен вести себя так же, как и List<T>. То есть все тесты устроены так: создаем List<T> и ChunkedList<T>, проводим над ними одинаковые операции и сравниваем результаты.

    Мы провели замер производительности с помощью библиотеки BenchmarkDotNet, чтобы убедиться, что мы не сильно замедлим наш код при переходе с List<T> на ChunkedList<T>. Протестируем, например, добавление элементов в список:

    [Benchmark]
    public ChunkedList<int> ChunkedList()
    {
    	var list = new ChunkedList<int>();
    	for (int i = 0; i < N; i++)
    		list.Add(i);
    	return list;
    }

    И такой же тест с использованием List<T> для сравнения. Результаты при добавлении 500 элементов (все помещается в один массив):
    Method Mean Error StdDev Gen 0/1k Op Gen 1/1k Op Gen 2/1k Op Allocated Memory/Op
    StandardList 1.415 us 0.0149 us 0.0140 us 0.6847 0.0095 - 4.21 KB
    ChunkedList 3.728 us 0.0238 us 0.0222 us 0.6943 0.0076 - 4.28 KB

    Результаты при добавлении 50000 элементов (разбивается на несколько массивов):
    Method Mean Error StdDev Gen 0/1k Op Gen 1/1k Op Gen 2/1k Op Allocated Memory/Op
    StandardList 146.273 us 3.1466 us 4.8053 us 124.7559 124.7559 124.7559 513.23 KB
    ChunkedList 287.687 us 1.4630 us 1.2969 us 41.5039 20.5078 - 256.75 KB

    Подробное описание колонок в результатах
    BenchmarkDotNet=v0.11.4, OS=Windows 10.0.17763.379 (1809/October2018Update/Redstone5)
    Intel Core i7-8700K CPU 3.70GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
      [Host]     : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.3324.0
      DefaultJob : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.3324.0
    
    // * Hints *
    Outliers
      ListAdd.StandardList: Default -> 2 outliers were removed
      ListAdd.ChunkedList: Default  -> 1 outlier  was  removed
    
    // * Legends *
      Mean                : Arithmetic mean of all measurements
      Error               : Half of 99.9% confidence interval
      StdDev              : Standard deviation of all measurements
      Gen 0/1k Op         : GC Generation 0 collects per 1k Operations
      Gen 1/1k Op         : GC Generation 1 collects per 1k Operations
      Gen 2/1k Op         : GC Generation 2 collects per 1k Operations
      Allocated Memory/Op : Allocated memory per single operation (managed only, inclusive, 1KB = 1024B)
      1 us                : 1 Microsecond (0.000001 sec)


    Если посмотреть на столбец 'Mean', в котором отображено среднее время выполнения теста, видно, что наша реализация медленнее стандартной всего в 2-2.5 раза. С учетом того, что в реальном коде операции со списками — лишь малая часть всех выполняемых действий, эта разница становится несущественной. Зато столбец 'Gen 2/1k op' (количество сборок 2-го поколения за 1000 выполнений теста) показывает, что мы добились цели: при большом количестве элементов ChunkedList не создает мусора во 2-м поколении, что и было нашей задачей.

    Кусочное множество


    Аналогично ChunkedHashSet<T> реализует интерфейс ISet<T>. При написании ChunkedHashSet<T> мы повторно использовали логику разбиения на небольшие куски, уже реализованную в ChunkedList. Для этого мы взяли готовую реализацию HashSet<T> из .NET Reference Source, доступного по лицензии MIT, и заменили в ней массивы на ChunkedList-ы.

    В юнит тестах тоже воспользуемся тем же трюком, что и для списков: будем сравнивать поведение ChunkedHashSet<T> с эталонным HashSet<T>.

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

    public ChunkedHashSet<int> ChunkedHashSet(int[][] source)
    {
    	var set = new ChunkedHashSet<int>();
    	foreach (var arr in source)
    		set.UnionWith(arr);
    	return set;
    }

    И точно такой же тест для стандартного HashSet. Первый тест для небольших множеств:

    var source = new int[][] {
    	Enumerable.Range(0, 300).ToArray(),
    	Enumerable.Range(100, 600).ToArray(),
    	Enumerable.Range(300, 1000).ToArray(),
    }

    Method Mean Error StdDev Gen 0/1k Op Gen 1/1k Op Gen 2/1k Op Allocated Memory/Op
    StandardHashSet 30.16 us 0.1046 us 0.0979 us 9.3079 1.6785 - 57.41 KB
    ChunkedHashSet 73.54 us 0.5919 us 0.5247 us 9.5215 1.5869 - 58.84 KB

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

    var source = new int[][] {
    	Enumerable.Range(0, 30000).ToArray(),
    	Enumerable.Range(10000, 60000).ToArray(),
    	Enumerable.Range(30000, 100000).ToArray(),
    }

    Method Mean Error StdDev Gen 0/1k Op Gen 1/1k Op Gen 2/1k Op Allocated Memory/Op
    StandardHashSet 3,031.30 us 32.0797 us 28.4378 us 699.2188 667.9688 664.0625 4718.23 KB
    ChunkedHashSet 7,189.66 us 25.6319 us 23.9761 us 539.0625 265.6250 7.8125 3280.71 KB

    Результаты схожи со списками. ChunkedHashSet медленнее в 2-2.5 раза, но при этом на больших множествах нагружает 2-е поколение на 2 порядка меньше.

    Сериализация в JSON


    Веб-сервер Pyrus предоставляет несколько API, в которых используется разная сериализация. Мы обнаружили создание больших объектов в API, используемом ботами и утилитой синхронизации (далее Public API). Заметим, что в основном API используется собственная сериализация, которая не подвержена данной проблеме. Мы об этом писали в статье https://habr.com/ru/post/227595/, в разделе «2. Вы не знаете, где узкое место вашего приложения». То есть, основной API уже работает хорошо, а проблема проявилась в Public API по мере роста количества запросов и объемов данных в ответах.

    Займемся оптимизацией Public API. На примере основного API мы знаем, что можно возвращать ответ пользователю в потоковом режиме. То есть, нужно не создавать промежуточные буферы, содержащие ответ целиком, а писать сразу ответ в поток (stream).

    При ближайшем рассмотрении мы выяснили, что в процессе сериализации ответа мы создаем временный буфер для промежуточного результата ('content' — массив байтов, содержащий JSON в кодировке UTF-8):

    var serializer = Newtonsoft.Json.JsonSerializer.Create(...);
    
    byte[] content;
    var sw = new StreamWriter(new MemoryStream(), new UTF8Encoding(false));
    using (var writer = new Newtonsoft.Json.JsonTextWriter(sw))
    {
    	serializer.Serialize(writer, result);
    	writer.Flush();				
    	content = ms.ToArray();
    }

    Проследим, где используется content. По историческим причинам Public API основан на WCF, для которого стандартным форматом запросов и ответов является XML. В нашем случае в XML-ответе имеется единственный элемент 'Binary', внутри которого записан JSON, закодированный в Base64:

    public class RawBodyWriter : BodyWriter
    {
    	private readonly byte[] _content;
    
    	public RawBodyWriter(byte[] content)
    		: base(true)
    	{
    		_content = content;
    	}
    
    	protected override void OnWriteBodyContents(XmlDictionaryWriter writer)
    	{
    		writer.WriteStartElement("Binary");
    		writer.WriteBase64(_content, 0, _content.Length);
    		writer.WriteEndElement();
    	}
    }

    Заметим, что временный буфер здесь не нужен. JSON можно писать сразу в буфер XmlWriter, который нам предоставляет WCF, на лету кодируя его в Base64. Таким образом, мы пойдем по первому пути, избавившись от выделения памяти:

    protected override void OnWriteBodyContents(XmlDictionaryWriter writer)
    {
    	var serializer = Newtonsoft.Json.JsonSerializer.Create(...);
    
    	writer.WriteStartElement("Binary");
    	Stream stream = new Base64Writer(writer);
    	Var sw = new StreamWriter(stream, new UTF8Encoding(false));
    	using (var jsonWriter = new Newtonsoft.Json.JsonTextWriter(sw))
    	{
    		serializer.Serialize(jsonWriter, _result);
    		jsonWriter.Flush();
    	}
    	writer.WriteEndElement();
    }

    Здесь Base64Writer — это простая обертка над XmlWriter, реализующая интерфейс Stream, которая пишет в XmlWriter в виде Base64. При этом из всего интерфейса достаточно реализовать только один метод Write, который вызывается в StreamWriter:

    public class Base64Writer : Stream
    {
    	private readonly XmlWriter _writer;
    
    	public Base64Writer(XmlWriter writer)
    	{
    		_writer = writer;
    	}
    
    	public override void Write(byte[] buffer, int offset, int count)
    	{
    		_writer.WriteBase64(buffer, offset, count);
    	}
    	
    	<...>
    }

    Induced GC


    Попробуем разобраться с загадочными индуцированными сборками мусора. Мы 10 раз перепроверили наш код на наличие вызовов GC.Collect, но это не дало результатов. Удалось поймать эти события в PerfView, но стек вызовов не особо показателен (событие DotNETRuntime/GC/Triggered):



    Есть маленькая зацепка — вызов RecycleLimitMonitor.RaiseRecycleLimitEvent перед индуцированной сборкой мусора. Проследим стек вызовов метода RaiseRecycleLimitEvent:

    RecycleLimitMonitor.RaiseRecycleLimitEvent(...)
    RecycleLimitMonitor.RecycleLimitMonitorSingleton.AlertProxyMonitors(...)
    RecycleLimitMonitor.RecycleLimitMonitorSingleton.CollectInfrequently(...)
    RecycleLimitMonitor.RecycleLimitMonitorSingleton.PBytesMonitorThread(...)

    Названия методов вполне соответствуют их функциям:
    • В конструкторе RecycleLimitMonitor.RecycleLimitMonitorSingleton создается таймер, с определенным интервалом вызывающий PBytesMonitorThread.
    • PBytesMonitorThread собирает статистику по использованию памяти и при каких-то условиях вызывает CollectInfrequently.
    • CollectInfrequently вызывает AlertProxyMonitors, получает в результате bool, и вызывает GC.Collect(), если получает true. Также он следит за временем, прошедшим с прошлого вызова сборщика мусора, и не вызывает его слишком часто.
    • AlertProxyMonitors проходит по списку запущенных IIS веб-приложений, для каждого поднимает соответствующий объект RecycleLimitMonitor, и вызывает RaiseRecycleLimitEvent.
    • RaiseRecycleLimitEvent поднимает список IObserver<RecycleLimitInfo>. Обработчики получают в качестве параметра RecycleLimitInfo, в котором они могут установить флаг RequestGC, который и возвращается в CollectInfrequently, вызывая индуцированную сборку мусора.


    Дальнейшее расследование показывает, обработчики IObserver<RecycleLimitInfo> добавляются в методе RecycleLimitMonitor.Subscribe(), который вызывается в методе AspNetMemoryMonitor.Subscribe(). Также в классе AspNetMemoryMonitor вешается обработчик IObserver<RecycleLimitInfo> по умолчанию (класс RecycleLimitObserver), который чистит кэши ASP.NET, и иногда запрашивает сборку мусора.

    Загадка Induced GC почти разгадана. Осталось выяснить вопрос, для чего вызывается эта сборка мусора. RecycleLimitMonitor следит за использованием памяти IIS (точнее, за цифрой private bytes), и когда ее использование приближается к определенному пределу, начинает по довольно запутанному алгоритму вызывать событие RaiseRecycleLimitEvent. В качестве предела памяти используется значение AspNetMemoryMonitor.ProcessPrivateBytesLimit, а в нем в свою очередь находится следующая логика:
    • Если для Application Pool в IIS настроено значение 'Private Memory Limit (KB)', то значение в килобайтах берется оттуда
    • Иначе для 64-битных систем берется 60% физической памяти (для 32-битных логика сложнее).

    Вывод расследования такой: ASP.NET по использованию памяти приближается к своему пределу и начинает регулярно вызывать сборку мусора. Для 'Private Memory Limit (KB)' не было установлено значение, поэтому ASP.NET ограничивался 60% от физической памяти. Проблема маскировалась тем, что на сервере Task Manager показывал много свободной памяти и казалось, что ее хватает. Мы повысили значение 'Private Memory Limit (KB)' в настройках Application Pool в IIS до 80% от физической памяти. Это стимулирует ASP.NET использовать больше имеющейся памяти. Также мы добавили мониторинг счетчика производительности '.NET CLR Memory / # Induced GC', чтобы не пропустить, когда в следующий раз ASP.NET решит, что он приближается к пределу использования памяти.

    Повторные измерения


    Посмотрим, что же произошло со сборками мусора после всех этих изменений. Начнем с perfview /GCCollectOnly (время трассировки — 1 час), отчет GCStats:



    Видно, что сборок 2-го поколения теперь на 2 порядка меньше, чем 0-го и 1-го. Также, время этих сборок уменьшились. Индуцированных сборок больше не наблюдается. Посмотрим на список сборок 2-го поколения:



    Из столбца Gen видно, что все сборки 2-го поколения стали фоновыми ('2B' означает 2-е поколение, Background). То есть, большая часть работы выполняется параллельно с выполнением приложения, а все потоки блокируются ненадолго (столбец 'Pause MSec'). Посмотрим на паузы при создании больших объектов:



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

    Итоги


    Благодаря описанным в статье изменениям удалось значительно снизить количество и продолжительность сборок 2-го поколения. Удалось найти причину индуцированных сборок, и избавиться от них. Увеличилось количество сборок 0-го и 1-го поколения, но при этом уменьшилась их средняя продолжительность (с ~200 мсек до ~60 мсек). Максимальная продолжительность сборок 0-го и 1-го поколений уменьшилась, но не так заметно. Сборки 2-го поколения стали быстрее, длинные паузы до 1000мс полностью ушли.

    Что касается ключевой метрики — «процент медленных запросов», она уменьшилась на 40% после всех изменений.

    Благодаря проведенной работе мы поняли, какие счетчики производительности нужны для оценки ситуации с памятью и сборкой мусора, добавив их в Zabbix для постоянного мониторинга. Вот список самых важных, на которые мы обращаем внимание, и выясняем причину (например, повышенный поток запросов, большой объем передаваемых данных, баг в приложении):
    Счетчик производительности Описание Когда стоит обратить внимание
    \Process(*)\Private Bytes Количество памяти, выделенной для приложения Значения сильно превышают порог. В качестве порога можно взять медиану за 2 недели от максимальных дневных показателей.
    \.NET CLR Memory(*)\# Gen 2 Collections Объем памяти в старшем поколении
    \.NET CLR Memory(*)\Large Object Heap size Объем памяти для больших объектов
    \.NET CLR Memory(*)\% Time in GC Процент времени, потраченный на сборку мусора Значение больше 5%.
    \.NET CLR Memory(*)\# Induced GC Количество индуцированных сборок Значение больше 0.
    Share post

    Similar posts

    Comments 28

      +2
      Спасибо за статью. А не пробовали экспериментировать с режимом работы GC? Планируете ли опубликовать получившиеся «кусочные» реализации коллекций?
        0
        Мы сравнивали режимы работы сборщика мусора GCLatencyMode.Interactive и GCLatencyMode.SustainedLowLatency. На замерах разница в поведении вызывалась многими факторами, например количеством запросов, объемом памяти, уже используемым приложением. На фоне этих различий влияние режима не заметно.
        Если же обратиться к документации (https://docs.microsoft.com/en-us/dotnet/api/system.runtime.gclatencymode?view=netframework-4.8), то отличие SustainedLowLatency в том, что он старается не выполнять блокирующую сборку 2-го поколения. Наши замеры показывают, что в веб-сервисах Pyrus происходят только фоновые сборки мусора, а следовательно, режим SustainedLowLatency не должен дать никаких изменений.
        Вот так у нас практика сходится с теорией.
        По поводу публикации «кусочных» коллекций — мы над этим думаем. Если есть интерес, то почему бы и нет.
        0
        Отличная статья, большое спасибо, теперь можно не писать такую для коллег =)
        По теме — нам кроме собственно уменьшения количества выделяемой памяти и объектов помог GCLatencyMode.SustainedLowLatency. Heap у нас с вами сопоставимого размера, выделений у нас раза в полтора поменьше.
        0
        В языках типа C/C++ или Rust используется ручное управление памятью, поэтому программисты тратят больше времени на написание кода, управление временем жизни объектов, а затем на отладку.

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

          0

          я так понял, borrow checker заставляет следить программиста ;)

          0
          Снимаю шляпу, прекрасная статья :)
            0
            Спасибо!
            –1
            Интересно, можно организовать два независимых дублирующих процесса, чтобы когда один приостановился, то второй обрабатывает запросы? Можно даже на разных компьютерах
              +1
              Я слышал, что кто-то именно так и делал. Можно подписаться на событие сборщика мусора, которое он кидает незадолго до сборки: https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/notifications. По этому событию сервер посылает сообщение балансировщику нагрузки, и тот временно перестает распределять запросы на этот сервер. Но это экзотика, редко используется.
              +3
              А сколько времени заняло от «Вооружившись недавно вышедшей книгой» до «Сборки 2-го поколения стали быстрее, длинные паузы до 1000мс полностью ушли»?
                +1
                В статье описан результат работы почти двух месяцев неполной занятости. Это итеративный процесс: замер, исправление, деплой, повторный замер. Пока ждем деплоя и результатов замера, занимаемся другими задачами.
                0
                Посмотрел как себя ведет ArrayPool, создает объект и держит его, давая другим использовать его, если объект заблокирован каким то потоком, то создает еще. Лучше в этом направлении было копать, так вы просто завуалировали объект который среда бы положила в LOH, думаю не спроста она это делает. Похоже на костыли.
                  0
                  Возможно, с ArrayPool-ом правильнее, но сложнее:
                  1) Поверх ArrayPool все равно придется писать реализацию List и HashSet.
                  2) Добавляется ручное управление временем жизни: массивы, полученные из ArrayPool-а, надо возвращать обратно.
                    0
                    Сомневаюсь что «поверх» получится. Используйте его подход. Думаю лучше использовать перечислимое обобщение. Надо глянуть что там в исходниках, возможно сам попробую написать интереса ради.
                    полученные из ArrayPool-а, надо возвращать обратно.

                    Операция «возврата» по-видимому, просто снимает блокировку выданного массива и позволяет другим его взять.
                  0
                  Уже в который раз вижу как Newtonsoft.Json является причиной просадки производительности. Жаль конечно что сейчас от него отказаться не так просто- много библиотек от Microsoft его используют. Надеюсь что выпилят его, как и обещали.
                    +1
                    Есть альтернатива лучше?
                        0
                        github.com/neuecc/Utf8Json вот этот поинтереснее, имхо. Аллокаций меньше точно, скорость аналогична Newtonsoft.Json (чуть лучше на самом деле, но в пределах нескольких %). Пробовал заменить форматеры в asp.net на него — дали повышение rps ~ на 5-10%.
                        0
                        Ниже\выше уже привели примеры, но могу отметить что в high-load зачастую используют кастомные оптимизации- у меня был опыт написания очень простенького сериализатора «на коленке» для выплевывания geoJSON в мир. Ни одна general purpose библиотека не сможет это поделку обогнать, т.к. сериализатор по сути заточен на один тип входных данных и способен заранее оптимизировать потребление памяти.
                        Не в обиду Newtonsoft все это сказано — я сам даже контрибутал в него, но уж больно он «зарос» фичами за свою историю, в ущерб производительности.
                        +1
                        В .net core 3 — его уже в базе нет. Но без него даже на preview 5 работать нормально сложно. System.Text.Json, а точнее видимо форматер на его основе, еще имеет серьезные баги, типа не поддерживает различные *case, кроме CamelCase.
                          0
                          да, в курсе. к сожалению в Enterprise мире не всегда можно так просто взять и использовать новый фреймворк
                        +1
                        Классная статья, которая кроме всего прочего замечательно иллюстрирует отличие языков с ручным управлением памяти и со сборщиком мусора

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

                        а во вторых программист тратит время на попытки заставить капризный GC делать то, что нужно :)

                        (Что так-то гораздо сложнее, чем вставить куда надо shared_ptr, а куда надо — weak_ptr на С++).
                          +1
                          Я бы сказал, что во-вторых, программист откладывает проблему управлением памятью на неопределенный срок. И во многих проектах (большинстве) этот срок никогда не наступает. :)
                            0
                            В «языках с ручным управлением памятью» тоже не получится «вставить куда надо shared_ptr».
                            Например очень неприятная проблема с фрагментацией адресного пространства (и нет, на 64-битных системах она не исчезает, а просто отложенно проявляется в виде странного падения производительности). Или кажущееся произвольным падение производительности выделения памяти (особенно в многопоточных приложениях).
                            Написал «неаккуратный» десериализатор JSON для относительно нагруженного многопоточного сервиса, не использовал пулинг и правильный аллокатор и всё, приехали, утечек памяти нет, а процесс через неделю сожрал несколько десятков гигов, а latency подскочила в 500 раз.
                            Резюме: если объектов выделяется мало, на производительность условно наплевать (особенно на long tail latency), либо процесс работает недолго — да, можно не беспокоиться. Но обычно в таком случае, можно не беспокоиться и со сборщиком мусора =)

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

                                Ну принято же считать, что если есть GC — то можно даже и не задумываться и извозчик довезёт


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

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

                              Для реализации ListPool и HashSetPool вы можете использовать библиотеку Microsoft.Extensions.ObjectPool. Пример реализации ListPool есть в юнит-тестах этой библиотеки.

                              Only users with full accounts can post comments. Log in, please.