Поделиться через


Управление памятью и сборка мусора (GC) в ASP.NET Core

Себастиен Росс и Рик Андерсон

Управление памятью сложно, даже в управляемой платформе, такой как .NET. Анализ и понимание проблем с памятью может быть сложной задачей. В этой статье:

  • Было мотивировано многими утечками памяти и GC не работает проблем. Большинство этих проблем были вызваны не пониманием того, как работает потребление памяти в .NET Core, или не понимает, как это измеряется.
  • Демонстрирует использование проблемной памяти и предлагает альтернативные подходы.

Как работает сборка мусора (GC) в .NET Core

GC выделяет сегменты кучи, где каждый сегмент является непрерывным диапазоном памяти. Объекты, помещенные в кучу, классифицируются на один из 3 поколений: 0, 1 или 2. Поколение определяет частоту попытки GC освободить память на управляемых объектах, на которые больше не ссылается приложение. Более низкие числа поколений являются GC чаще.

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

Когда запускается приложение ASP.NET Core, GC:

  • Резервирует некоторую память для начальных сегментов кучи.
  • Фиксирует небольшую часть памяти при загрузке среды выполнения.

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

GC. Сбор предостережения

Как правило, приложения ASP.NET Core в рабочей среде не должны использовать GC. Сбор явным образом. Индуцирование сборок мусора в подоптимальным временем может значительно снизить производительность.

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

Анализ использования памяти приложения

Выделенные средства могут помочь в анализе использования памяти:

  • Подсчет ссылок на объекты
  • Измерение влияния GC на использование ЦП
  • Измерение пространства памяти, используемого для каждого поколения

Используйте следующие средства для анализа использования памяти:

Обнаружение проблем с памятью

Диспетчер задач можно использовать для получения сведений о том, сколько памяти использует приложение ASP.NET. Значение памяти диспетчера задач:

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

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

Пример приложения использования памяти

Пример приложения MemoryLeak доступен на сайте GitHub. Приложение MemoryLeak:

  • Включает в себя диагностический контроллер, который собирает данные памяти в режиме реального времени и GC для приложения.
  • Содержит страницу индекса, отображающую данные памяти и GC. Страница индекса обновляется каждую секунду.
  • Содержит контроллер API, предоставляющий различные шаблоны загрузки памяти.
  • Однако не поддерживается средство, которое можно использовать для отображения шаблонов использования памяти приложений ASP.NET Core.

Запустите MemoryLeak. Выделенная память медленно увеличивается до тех пор, пока не будет происходить сборка данных. Увеличение памяти увеличивается, так как средство выделяет пользовательский объект для записи данных. На следующем рисунке показана страница "Индекс MemoryLeak" при возникновении сборки 0-го поколения. На диаграмме показано 0 RPS (запросы в секунду), так как конечные точки API из контроллера API не были вызваны.

Диаграмма с 0 запросами в секунду (RPS)

На диаграмме отображаются два значения для использования памяти:

  • Выделено: объем памяти, занятой управляемыми объектами
  • Рабочий набор: набор страниц в виртуальном адресном пространстве процесса, который в настоящее время находится в физической памяти. Показанный рабочий набор — это то же значение, что и диспетчер задач.

Временные объекты

Следующий API создает экземпляр строки размером 20 КБ и возвращает его клиенту. При каждом запросе новый объект выделяется в памяти и записывается в ответ. Строки хранятся в виде символов UTF-16 в .NET, поэтому каждый символ занимает 2 байта в памяти.

[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
    return new String('x', 10 * 1024);
}

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

Диаграмма, показывающая выделение памяти для относительно небольшой нагрузки

На предыдущей диаграмме показана следующая диаграмма:

  • 4K RPS (запросы в секунду).
  • Коллекции GC поколения 0 происходят примерно каждые две секунды.
  • Рабочий набор является константой примерно в 500 МБ.
  • ЦП составляет 12%.
  • Потребление памяти и выпуск (через GC) стабильно.

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

Диаграмма с максимальной пропускной способностью

На предыдущей диаграмме показана следующая диаграмма:

  • 22K RPS
  • Коллекции GC поколения 0 происходят несколько раз в секунду.
  • Коллекции поколения 1 активируются, так как приложение выделяет значительно больше памяти в секунду.
  • Рабочий набор является константой примерно в 500 МБ.
  • ЦП составляет 33%.
  • Потребление памяти и выпуск (через GC) стабильно.
  • ЦП (33%) не используется чрезмерно, поэтому сборка мусора может поддерживать большое количество выделений.

РАБОЧАЯ станция GC и серверная сборка

Сборщик мусора .NET имеет два разных режима:

  • GC рабочей станции: оптимизировано для рабочего стола.
  • Серверная сборка данных. По умолчанию GC для приложений ASP.NET Core. Оптимизировано для сервера.

Режим GC можно задать явным образом в файле проекта или в runtimeconfig.json файле опубликованного приложения. В следующей разметке показан параметр ServerGarbageCollection в файле проекта:

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

Для изменения ServerGarbageCollection в файле проекта требуется перестроить приложение.

Примечание. Сборка мусора сервера недоступна на компьютерах с одним ядром. Дополнительные сведения см. в разделе IsServerGC.

На следующем рисунке показан профиль памяти в 5K RPS с помощью рабочей станции GC.

Диаграмма с профилем памяти для рабочей станции GC

Различия между этой диаграммой и версией сервера важны:

  • Рабочий набор отпадает от 500 МБ до 70 МБ.
  • GC выполняет коллекции поколения 0 несколько раз в секунду, а не каждые две секунды.
  • GC удаляется с 300 МБ до 10 МБ.

В типичной среде веб-сервера использование ЦП более важно, чем память, поэтому GC сервера лучше. Если загрузка памяти высока, а загрузка ЦП относительно низка, GC рабочей станции может оказаться более эффективной. Например, высокая плотность размещения нескольких веб-приложений, где память ограничена.

GC с использованием DOCKER и небольших контейнеров

Если на одном компьютере запущено несколько контейнерных приложений, 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 Объект собирается под давлением памяти. Реализация использования WeakReferenceпо умолчаниюIMemoryCache.

Собственная память

Некоторые объекты .NET Core используют собственную память. Не удается собрать собственную память с помощью GC. Объект .NET, использующий собственную память, должен освободить его с помощью машинного кода.

.NET предоставляет IDisposable интерфейс, чтобы разработчики выпускали собственную память. Даже если Dispose не вызывается, правильно реализован вызов Dispose классов при выполнении средства завершения.

Рассмотрим следующий код:

[HttpGet("fileprovider")]
public void GetFileProvider()
{
    var fp = new PhysicalFileProvider(TempPath);
    fp.Watch("*.*");
}

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

На следующем рисунке показан профиль памяти при непрерывном вызове fileprovider API.

Диаграмма, показывающая утечку памяти в собственном коде

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

Такая же утечка может произойти в пользовательском коде одним из следующих способов:

  • Неправильное освобождение класса.
  • Забыв вызвать Dispose метод зависимых объектов, которые должны быть удалены.

Куча больших объектов

Частое выделение памяти и свободные циклы могут фрагментировать память, особенно при выделении больших блоков памяти. Объекты выделяются в смежных блоках памяти. Чтобы устранить фрагментацию, когда GC освобождает память, она пытается дефрагментировать ее. Этот процесс называется сжатием. Сжатие включает перемещение объектов. Перемещение больших объектов накладывает штраф за производительность. По этой причине GC создает специальную зону памяти для больших объектов, называемую кучей больших объектов (LOH). Объекты, превышающие 85 000 байт (примерно 83 КБ), являются:

  • Помещено на loH.
  • Не сжимается.
  • Собирается во время поколения 2 ГК.

Когда loH будет заполнен, сборка сборок активирует коллекцию поколения 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 точки, выделение всего одного байта:

Диаграмма с профилем памяти выделения еще одного байта

Примечание. Структура byte[] имеет байты накладных расходов. Поэтому 84 976 байт активирует ограничение в 85 000 байтов.

Сравнение двух предыдущих диаграмм:

  • Рабочий набор аналогичен обоим сценариям, около 450 МБ.
  • В разделе loH-запросов (84 975 байт) отображаются в основном коллекции поколения 0.
  • Запросы loH создают коллекции констант поколения 2. Коллекции поколения 2 являются дорогостоящими. Требуется больше ЦП и пропускная способность снижается почти на 50 %.

Временные большие объекты особенно проблематичны, так как они вызывают 2-го поколения.

Для максимальной производительности следует свести к минимуму использование больших объектов. По возможности разбийте большие объекты. Например, по промежуточному слоям кэширования ответа в ASP.NET Core разделите записи кэша на блоки менее 85 000 байт.

В следующих ссылках показан подход ASP.NET Core к поддержанию объектов в соответствии с ограничением loH:

Дополнительные сведения см. в разделе:

HttpClient

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

  • Не хватает памяти.
  • Проблематичны при утечке, чем память.

Опытные разработчики .NET знают, чтобы вызывать Dispose объекты, реализующие IDisposable. Не удаляя объекты, которые реализуют IDisposable , как правило, приводят к утечке памяти или утечке системных ресурсов.

HttpClientIDisposableреализует, но не следует удалять при каждом вызове. 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 с умеренной нагрузкой:

Диаграмма с вызовами 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.

Дополнительные ресурсы