Современные языки программирования можно разделить на две группы. В языках типа 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. Мы не нашли какой-нибудь подходящей библиотеки, поэтому классы пришлось бы реализовывать самим.
- Разнообразие используемых коллекций и типов элементов в них: используются HashSet, List и Array (2 последних можно объединить). В коллекциях хранятся Int32, Int64, а также всевозможные классы данных. Для каждого используемого типа понадобится свой пул, который к тому же будет хранить коллекции разных размеров.
- Использование маленьких объектов. Большой массив можно разбить на несколько маленьких кусочков, которые не буду нагружать Large Object Heap, а будут создаваться в 0-м поколении, а дальше идти стандартным путем в 1-е и 2-е. Мы надеемся, что они не доживут до 2-го, а будут собраны сборщиком мусора в 0-м, или в крайнем случае в 1-м поколении. Плюс этого подхода в том, что изменения имеющегося кода минимальные. Сложности:
- Реализация. Мы не нашли подходящих библиотек, поэтому классы пришлось бы писать самим. Отсутствие библиотек объяснимо, так как сценарий «коллекции, не нагружающие Large Object Heap» — это очень узкая область применения.
Мы решили пойти по 3-му пути и
Кусочный список
Наш 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. |
Комментарии (24)
onyxmaster
18.05.2019 09:24Отличная статья, большое спасибо, теперь можно не писать такую для коллег =)
По теме — нам кроме собственно уменьшения количества выделяемой памяти и объектов помог GCLatencyMode.SustainedLowLatency. Heap у нас с вами сопоставимого размера, выделений у нас раза в полтора поменьше.Pyrus Автор
18.05.2019 19:09Спасибо! Рады, что статья оказалась полезной. Про режимы мы написали в другом комментарии.
freecoder_xx
18.05.2019 21:06В языках типа C/C++ или Rust используется ручное управление памятью, поэтому программисты тратят больше времени на написание кода, управление временем жизни объектов, а затем на отладку.
Вообще-то в Rust автоматическое управление памятью и за соответствием времен жизни ссылок и объектов следит компилятор.
dim2r
19.05.2019 09:44Интересно, можно организовать два независимых дублирующих процесса, чтобы когда один приостановился, то второй обрабатывает запросы? Можно даже на разных компьютерах
Pyrus Автор
19.05.2019 18:34+1Я слышал, что кто-то именно так и делал. Можно подписаться на событие сборщика мусора, которое он кидает незадолго до сборки: https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/notifications. По этому событию сервер посылает сообщение балансировщику нагрузки, и тот временно перестает распределять запросы на этот сервер. Но это экзотика, редко используется.
Caraul
19.05.2019 12:20А сколько времени заняло от «Вооружившись недавно вышедшей книгой» до «Сборки 2-го поколения стали быстрее, длинные паузы до 1000мс полностью ушли»?
Pyrus Автор
20.05.2019 22:27В статье описан результат работы почти двух месяцев неполной занятости. Это итеративный процесс: замер, исправление, деплой, повторный замер. Пока ждем деплоя и результатов замера, занимаемся другими задачами.
Ascar
19.05.2019 15:07Посмотрел как себя ведет ArrayPool, создает объект и держит его, давая другим использовать его, если объект заблокирован каким то потоком, то создает еще. Лучше в этом направлении было копать, так вы просто завуалировали объект который среда бы положила в LOH, думаю не спроста она это делает. Похоже на костыли.
Pyrus Автор
19.05.2019 18:35Возможно, с ArrayPool-ом правильнее, но сложнее:
1) Поверх ArrayPool все равно придется писать реализацию List и HashSet.
2) Добавляется ручное управление временем жизни: массивы, полученные из ArrayPool-а, надо возвращать обратно.Ascar
19.05.2019 18:58Сомневаюсь что «поверх» получится. Используйте его подход. Думаю лучше использовать перечислимое обобщение. Надо глянуть что там в исходниках, возможно сам попробую написать интереса ради.
полученные из ArrayPool-а, надо возвращать обратно.
Операция «возврата» по-видимому, просто снимает блокировку выданного массива и позволяет другим его взять.
10E137
20.05.2019 13:36Уже в который раз вижу как Newtonsoft.Json является причиной просадки производительности. Жаль конечно что сейчас от него отказаться не так просто- много библиотек от Microsoft его используют. Надеюсь что выпилят его, как и обещали.
RouR
20.05.2019 15:03Есть альтернатива лучше?
onyxmaster
20.05.2019 16:00+1github.com/kevin-montrose/Jil
github.com/dotnet/corefx/blob/master/src/System.Text.Json/docs/SerializerProgrammingModel.md.raptor
20.05.2019 23:46github.com/neuecc/Utf8Json вот этот поинтереснее, имхо. Аллокаций меньше точно, скорость аналогична Newtonsoft.Json (чуть лучше на самом деле, но в пределах нескольких %). Пробовал заменить форматеры в asp.net на него — дали повышение rps ~ на 5-10%.
raptor
20.05.2019 23:44В .net core 3 — его уже в базе нет. Но без него даже на preview 5 работать нормально сложно. System.Text.Json, а точнее видимо форматер на его основе, еще имеет серьезные баги, типа не поддерживает различные *case, кроме CamelCase.
blainemono
20.05.2019 13:36Классная статья, которая кроме всего прочего замечательно иллюстрирует отличие языков с ручным управлением памяти и со сборщиком мусора
в первых программист тратит время на дизайн и отладку логики времени жизни объектов
а во вторых программист тратит время на попытки заставить капризный GC делать то, что нужно :)
(Что так-то гораздо сложнее, чем вставить куда надо shared_ptr, а куда надо — weak_ptr на С++).Pyrus Автор
21.05.2019 10:17Я бы сказал, что во-вторых, программист откладывает проблему управлением памятью на неопределенный срок. И во многих проектах (большинстве) этот срок никогда не наступает. :)
onyxmaster
21.05.2019 13:02В «языках с ручным управлением памятью» тоже не получится «вставить куда надо shared_ptr».
Например очень неприятная проблема с фрагментацией адресного пространства (и нет, на 64-битных системах она не исчезает, а просто отложенно проявляется в виде странного падения производительности). Или кажущееся произвольным падение производительности выделения памяти (особенно в многопоточных приложениях).
Написал «неаккуратный» десериализатор JSON для относительно нагруженного многопоточного сервиса, не использовал пулинг и правильный аллокатор и всё, приехали, утечек памяти нет, а процесс через неделю сожрал несколько десятков гигов, а latency подскочила в 500 раз.
Резюме: если объектов выделяется мало, на производительность условно наплевать (особенно на long tail latency), либо процесс работает недолго — да, можно не беспокоиться. Но обычно в таком случае, можно не беспокоиться и со сборщиком мусора =)
Taritsyn
21.05.2019 12:18Реализация. Есть ArrayPool от Microsoft, но нам нужны еще List и HashSet. Мы не нашли какой-нибудь подходящей библиотеки, поэтому классы пришлось бы реализовывать самим.
Для реализации ListPool и HashSetPool вы можете использовать библиотеку Microsoft.Extensions.ObjectPool. Пример реализации ListPool есть в юнит-тестах этой библиотеки.
Lelushak
Спасибо за статью. А не пробовали экспериментировать с режимом работы GC? Планируете ли опубликовать получившиеся «кусочные» реализации коллекций?
Pyrus Автор
Мы сравнивали режимы работы сборщика мусора GCLatencyMode.Interactive и GCLatencyMode.SustainedLowLatency. На замерах разница в поведении вызывалась многими факторами, например количеством запросов, объемом памяти, уже используемым приложением. На фоне этих различий влияние режима не заметно.
Если же обратиться к документации (https://docs.microsoft.com/en-us/dotnet/api/system.runtime.gclatencymode?view=netframework-4.8), то отличие SustainedLowLatency в том, что он старается не выполнять блокирующую сборку 2-го поколения. Наши замеры показывают, что в веб-сервисах Pyrus происходят только фоновые сборки мусора, а следовательно, режим SustainedLowLatency не должен дать никаких изменений.
Вот так у нас практика сходится с теорией.
По поводу публикации «кусочных» коллекций — мы над этим думаем. Если есть интерес, то почему бы и нет.