Примечание.
Для доступа к этой странице требуется авторизация. Вы можете попробовать войти или изменить каталоги.
Для доступа к этой странице требуется авторизация. Вы можете попробовать изменить каталоги.
Управление памятью сложно, даже в управляемой платформе, такой как .NET. Анализ и устранение проблем с памятью может быть сложным. Проблемы, связанные с утечками памяти и сборкой мусора, обычно возникают из-за отсутствия понимания того, как работает потребление памяти в .NET, или отсутствия понимания процесса измерения использования.
В этой статье показаны распространенные шаблоны использования памяти, которые могут быть проблематичными, и предлагает альтернативные подходы.
Изучение сборки мусора (GC) в .NET
Процесс GC выделяет сегменты кучи, где каждый сегмент является непрерывным диапазоном памяти. Объекты, помещенные в кучу, классифицируются по одному из трех поколений: 0, 1 или 2. Поколение определяет частоту GC в попытках освободить память на управляемых объектах, на которые приложение больше не ссылается. GC чаще обращается к более низким числам поколений.
Объекты перемещаются из одного поколения в другое в зависимости от их времени жизни. Поскольку объекты живут дольше, они перемещаются в более высокое поколение. Как упоминалось ранее, GC работает реже на более высоких уровнях. Краткоживущие объекты всегда остаются в нулевом поколении. Например, объекты, на которые ссылаются в течение срока действия веб-запроса, являются короткими. Объекты-одиночки уровня приложения обычно мигрируют в Поколение 2.
При запуске приложения ASP.NET Core процесс GC:
- Резервирует некоторую память для начальных сегментов кучи.
- Фиксирует небольшую часть памяти при загрузке среды выполнения.
Предыдущие выделения памяти выполняются по соображениям производительности. Преимущество производительности происходит от сегментов кучи в непрерывной памяти.
Просмотрите ограничения при использовании GC.Collect
Как правило, приложения ASP.NET Core в рабочей среде должны не использовать метод GC.Collect явно. Индуцирование сборок мусора в неоптимальные периоды может значительно снизить производительность.
GC.Collect полезно при расследовании утечек памяти. Вызов GC.Collect() активирует блокирующий цикл сборки мусора, который пытается восстановить все объекты, недоступные из управляемого кода. Это полезный способ понять размер доступных динамических объектов в куче и отслеживать рост размера памяти с течением времени.
Анализ использования памяти приложения
Выделенные средства могут помочь проанализировать использование памяти, в том числе:
- Подсчет ссылок на объекты.
- Измерение влияния GC на использование ЦП.
- Измерение пространства памяти, используемого для каждого поколения.
Используйте следующие средства для анализа использования памяти:
- служебная программа dotnet-trace (может использоваться на рабочих компьютерах)
- Анализ использования памяти без отладчика Visual Studio
- Измерение использования памяти в Visual Studio
Обнаружение проблем с памятью
Диспетчер задач можно использовать для получения сведений о том, сколько памяти использует приложение ASP.NET. Значение памяти диспетчера задач:
- Представляет объем памяти, используемой процессом ASP.NET.
- Включает в себя живые объекты приложения и другие потребители памяти, такие как использование собственной памяти.
Если значение памяти диспетчера задач увеличивается на неопределенный срок и никогда не сглаживается, приложение имеет утечку памяти. В следующих разделах показано несколько шаблонов использования памяти.
Ознакомьтесь с примером использования приложения для отображения памяти дисплея
Пример приложения MemoryLeak доступен на сайте GitHub. Приложение MemoryLeak:
- Включает в себя диагностический контроллер, который собирает данные памяти в режиме реального времени и GC для приложения.
- Содержит страницу индекса, отображающую данные памяти и GC. Страница индекса обновляется каждую секунду.
- Содержит контроллер API, предоставляющий различные шаблоны загрузки памяти.
- Можно использовать для отображения шаблонов использования памяти приложений ASP.NET Core, но это не поддерживается.
Запустите MemoryLeak. Выделенная память медленно увеличивается до тех пор, пока не будет происходить сборка данных. Память увеличивается, так как средство выделяет пользовательский объект для захвата данных. На следующем рисунке показана страница "Индекс MemoryLeak" при возникновении сборки 0-го поколения. На диаграмме показано 0 RPS (запросы в секунду), так как конечные точки API из контроллера API не были вызваны.
На диаграмме отображаются два значения для использования памяти:
- Выделено: объем памяти, занятой управляемыми объектами.
- Рабочий набор: набор страниц в виртуальном адресном пространстве процесса, который в настоящее время находится в физической памяти. Показанный рабочий набор — это то же значение, что показывает диспетчер задач. Дополнительные сведения см. в разделе "Рабочий набор".
Временные объекты
Следующий API создает экземпляр строки размером 20 КБ и возвращает его клиенту. При каждом запросе новый объект выделяется в памяти и записывается в ответ. Строки хранятся в виде символов UTF-16 в .NET, поэтому каждый символ занимает 2 байта в памяти.
[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
return new String('x', 10 * 1024);
}
На следующей диаграмме, построенной при использовании относительно небольшой нагрузки, показывается, как GC влияет на распределение памяти.
На диаграмме показаны следующие сведения:
- 4K RPS (запросы в секунду)
- Коллекции GC поколения 0 происходят примерно каждые 2 секунды
- Постоянный рабочий набор, приблизительно 500 МБ
- ЦП — 12%
- Стабильное потребление и освобождение памяти (через GC)
На следующей схеме представлена максимальная пропускная способность, которую может обрабатывать машина.
На диаграмме показаны следующие сведения:
- 22 тыс. запросов в секунду
- Сборки мусора GC 0-го поколения происходят несколько раз в секунду
- Триггер коллекций 1-го поколения, так как приложение выделяет значительно больше памяти в секунду
- Постоянный рабочий набор, приблизительно 500 МБ
- ЦП составляет 33%
- Стабильное использование памяти и её освобождение (с помощью GC)
- ЦП (33%) не используется чрезмерно, поэтому GC может поддерживать большое количество выделений.
Сборка мусора рабочей станции против сборки мусора сервера
Сборщик мусора .NET имеет два разных режима:
- Рабочая станция GC: оптимизирована для рабочего стола.
- Server GC: GC по умолчанию для приложений ASP.NET Core. Оптимизировано для сервера.
Режим GC можно задать явным образом в файле проекта или в файлеruntimeconfig.json опубликованного приложения. В следующей разметке показан параметр ServerGarbageCollection в файле проекта:
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
Для изменения ServerGarbageCollection в файле проекта требуется перестроить приложение.
Note
Сборка мусора сервера недоступна на компьютерах с одним ядром. Дополнительные сведения см. в свойстве IsServerGC.
На следующем рисунке показан профиль памяти в 5K RPS с помощью рабочей станции GC.
Различия между этой диаграммой и версией сервера важны:
- Рабочий набор уменьшается с 500 МБ до 70 МБ
- GC выполняет коллекции 0-го поколения несколько раз в секунду, а не каждые 2 секунды
- Объём памяти GC уменьшается с 300 МБ до 10 МБ
В типичной среде веб-сервера использование ЦП более важно, чем память, поэтому GC сервера лучше. Если загрузка памяти высока, а загрузка ЦП относительно низка, GC рабочей станции может оказаться более эффективной. Например, высокая плотность размещения нескольких веб-приложений, где память ограничена.
GC с помощью Docker и небольших контейнеров
При запуске нескольких контейнерных приложений на одном компьютере GC рабочей станции может быть более производительной, чем серверная сборка данных. Дополнительные сведения см. в блоге "Запуск с сервером GC в небольшом контейнере" и блоге "Запуск с сервером GC в сценарии с небольшим контейнером. Часть 1 — жесткий лимит для кучи GC".
Ссылки на постоянные объекты
GC не может освободить объекты, на которые есть ссылки. Объекты, на которые ссылаются, но больше не нужны, приводят к утечке памяти. Если приложение часто выделяет объекты и не освобождает их после того, как они больше не нужны, использование памяти увеличивается со временем.
Следующий API создает экземпляр строки размером 20 КБ и возвращает его клиенту. Разница с предыдущим примером заключается в том, что статический элемент ссылается на этот экземпляр, что означает, что экземпляр никогда недоступен для коллекции.
private static ConcurrentBag<string> _staticStrings = new ConcurrentBag<string>();
[HttpGet("staticstring")]
public ActionResult<string> GetStaticString()
{
var bigString = new String('x', 10 * 1024);
_staticStrings.Add(bigString);
return bigString;
}
Предыдущий код:
- Демонстрирует типичную утечку памяти.
- При частых вызовах память приложения увеличивается до тех пор, пока процесс не завершится сбоем с
OutOfMemoryисключением.
На диаграмме показаны следующие сведения:
- Нагрузочное тестирование конечной
/api/staticstringточки приводит к линейному увеличению памяти - GC пытается освободить память по мере роста давления памяти путем вызова коллекции 2-го поколения
- GC не может освободить утеченную память; выделенная память и рабочий набор увеличиваются со временем
Для некоторых сценариев, таких как кэширование, требуется хранить ссылки на объекты до тех пор, пока давление памяти не заставит их освободить. Класс WeakReference можно использовать для этого типа кода кэширования. Объект WeakReference собирается при нагрузках на память. Реализация интерфейса IMemoryCache по умолчанию использует WeakReference.
Собственная память
Некоторые объекты .NET полагаются на независимую память, но независимая память не собирается сборщиком мусора. Объект .NET, использующий собственную память, должен освободить его с помощью машинного кода.
.NET предоставляет IDisposable интерфейс, чтобы разработчики освобождали нативную память. Даже если метод Dispose не вызывается, правильно реализованные классы вызывают Dispose, когда выполняется финализатор.
Рассмотрим следующий код:
[HttpGet("fileprovider")]
public void GetFileProvider()
{
var fp = new PhysicalFileProvider(TempPath);
fp.Watch("*.*");
}
PhysicalFileProvider — это управляемый класс, поэтому все экземпляры этого класса собираются в конце запроса.
На следующем рисунке показан профиль памяти при непрерывном вызове fileprovider API.
На приведенной выше диаграмме показана очевидная проблема с реализацией этого класса, так как она продолжает увеличивать использование памяти. Это известная проблема, отслеживаемая в GitHub проблема dotnet/aspnetcore #844.
Такая же утечка возникает в пользовательском коде в следующих сценариях:
- Неправильное освобождение класса
- Забыв вызвать метод
Disposeдля зависимых объектов, которые должны быть освобождены.
Куча больших объектов
Частые циклы выделения и освобождения памяти приводят к фрагментации памяти, особенно при выделении больших блоков памяти. Объекты выделяются в смежных блоках памяти. Чтобы устранить фрагментацию, когда GC освобождает память, она пытается дефрагментировать ее. Этот процесс называется сжатием. Сжатие включает перемещение объектов. Перемещение больших объектов влечет снижение производительности. По этой причине GC создает специальную зону памяти для больших объектов, называемую кучей больших объектов (LOH). Объекты, превышающие 85 000 байт (примерно 83 КБ), являются:
- Размещено на LOH
- Не уплотняется
- Обработано в процессе сбора данных второго поколения
Когда loH заполнен, GC активирует коллекцию 2-го поколения.
- Коллекции 2-го поколения в своей основе медленные.
- Они могут понести затраты на инициацию коллекции в остальных поколениях.
Следующий код немедленно сжимает LOH.
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
Сведения о сжатии LOH см. в свойстве LargeObjectHeapCompactionMode.
В контейнерах, использующих .NET Core 3.0 или более поздней версии, loH автоматически сжимается.
Следующий API, иллюстрирующий это поведение:
[HttpGet("loh/{size=85000}")]
public int GetLOH1(int size)
{
return new byte[size].Length;
}
На следующей диаграмме показан профиль памяти вызова конечной /api/loh/84975 точки при максимальной нагрузке:
На следующей диаграмме показан профиль памяти для вызова конечной /api/loh/84976 точки, выделение всего одного байта:
Note
Структура byte[] имеет накладные байты, поэтому 84 976 байт активирует ограничение в 85 000.
Сравнение двух предыдущих диаграмм:
- Рабочий набор аналогичен для обоих сценариев, около 450 МБ
- В запросах по LOH (84 975 байт) в основном отображаются коллекции поколения 0.
- По запросу LOH создаются постоянные коллекции 2-го поколения, которые являются дорогостоящими. Требуется больше ЦП и пропускная способность снижается примерно на 50%.
Временные большие объекты являются проблематичными, так как они вызывают коллекции 2-го поколения.
Для максимальной производительности свести к минимуму использование больших объектов. По возможности разбийте большие объекты. Например, Промежуточное ПО для кеширования ответов в ASP.NET Core разделяет записи кеша на блоки менее 85 000 байт.
В следующих ссылках показан подход ASP.NET Core к удержанию объектов в пределах ограничения LOH:
Дополнительные сведения см. в разделе:
HttpClient
Неправильное использование HttpClient класса может привести к утечке ресурсов.
Системные ресурсы (например, подключения к базе данных, сокеты, дескрипторы файлов и т. д.) представляют две проблемы:
- Они более скудные, чем память.
- Они более проблематичны при утечке, чем память.
Опытные .NET разработчики знают, как вызвать метод Dispose для объектов, реализующих интерфейс IDisposable. Неудаление объектов, которые реализуют IDisposable, обычно вызывает утечку памяти или системных ресурсов.
HttpClient реализует IDisposable, но не должен освобождаться при каждом вызове. Вместо этого следует повторно использовать HttpClient.
Следующая конечная точка создает и удаляет новый HttpClient экземпляр по каждому запросу:
[HttpGet("httpclient1")]
public async Task<int> GetHttpClient1(string url)
{
using (var httpClient = new HttpClient())
{
var result = await httpClient.GetAsync(url);
return (int)result.StatusCode;
}
}
При загрузке регистрируются следующие сообщения об ошибках:
fail: Microsoft.AspNetCore.Server.Kestrel[13]
Connection id "0HLG70PBE1CR1", Request id "0HLG70PBE1CR1:00000031":
An unhandled exception was thrown by the application.
System.Net.Http.HttpRequestException: Only one usage of each socket address
(protocol/network address/port) is normally permitted --->
System.Net.Sockets.SocketException: Only one usage of each socket address
(protocol/network address/port) is normally permitted
at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port,
CancellationToken cancellationToken)
Несмотря на то, что HttpClient экземпляры удаляются, для освобождения реального сетевого соединения операционной системе требуется некоторое время. Процесс непрерывного создания новых подключений приводит к исчерпанию портов. Для каждого подключения клиента требуется собственный клиентский порт.
Одним из способов предотвращения исчерпания портов является повторное использование одного экземпляра HttpClient :
private static readonly HttpClient _httpClient = new HttpClient();
[HttpGet("httpclient2")]
public async Task<int> GetHttpClient2(string url)
{
var result = await _httpClient.GetAsync(url);
return (int)result.StatusCode;
}
Экземпляр HttpClient освобождается при остановке приложения. В этом примере показано, что не каждый одноразовый ресурс следует утилизировать после каждого использования.
В следующих статьях описан лучший способ обработки времени существования экземпляра HttpClient :
Пул объектов
В предыдущем примере показано, как экземпляр HttpClient можно сделать статическим и повторно использовать всеми запросами. Повторное использование предотвращает отсутствие ресурсов.
Пул объектов является альтернативой:
- Он использует шаблон повторного использования.
- Дизайн идеально подходит для объектов, которые являются дорогостоящими для создания.
Пул — это коллекция прединициализированных объектов, которые могут быть зарезервированы и освобождены между потоками. Пулы могут определять такие правила распределения, как ограничения, предопределенные размеры или темп роста.
Пакет NuGet Microsoft.Extensions.ObjectPool содержит классы, помогающие управлять такими пулами.
Следующая конечная точка API создает byte буфер, который заполняется случайными числами при каждом запросе.
[HttpGet("array/{size}")]
public byte[] GetArray(int size)
{
var random = new Random();
var array = new byte[size];
random.NextBytes(array);
return array;
}
На следующей диаграмме показан вызов предыдущего API с умеренной нагрузкой:
На диаграмме показано, что сборки поколения 0 происходят примерно один раз в секунду.
Код можно оптимизировать, объединяя byte буфер с помощью класса ArrayPool<T> . Статический экземпляр повторно используется в запросах.
В этом подходе отличительным является то, что из API возвращается объект из пула:
- Объект выходит из-под вашего контроля, как только вы вернетесь из метода.
- Невозможно освободить объект.
Чтобы настроить удаление объекта, выполните следующие действия.
- Инкапсуляция массива с пулом в объекте, который можно удалить.
- Зарегистрируйте объект в пуле с помощью метода HttpContext.Response.RegisterForDispose .
RegisterForDispose заботится о вызове Dispose целевого объекта, поэтому объект освобождается только после завершения HTTP-запроса.
private static ArrayPool<byte> _arrayPool = ArrayPool<byte>.Create();
private class PooledArray : IDisposable
{
public byte[] Array { get; private set; }
public PooledArray(int size)
{
Array = _arrayPool.Rent(size);
}
public void Dispose()
{
_arrayPool.Return(Array);
}
}
[HttpGet("pooledarray/{size}")]
public byte[] GetPooledArray(int size)
{
var pooledArray = new PooledArray(size);
var random = new Random();
random.NextBytes(pooledArray.Array);
HttpContext.Response.RegisterForDispose(pooledArray);
return pooledArray.Array;
}
Применение той же нагрузки, что и непулированная версия, приводит к следующей диаграмме:
Основное различие — это выделенные байты, и, как следствие, меньше коллекций 0-го поколения.
Связанный контент
ASP.NET Core