Бессильный сборщик мусора или неуправляемая память в .NET

15 августа 2023 г.8 мин

Если вы готовитесь к собеседованию и гуглите список вопросов для кандидата на C# разработчика, то сто процентов один из вопросов будет о сборщике мусора. На собеседованиях этот вопрос действительно частенько задают, но как только они заканчиваются, магическим образом все знания улетучиваются, прямо как после экзамена. Долгое время я не понимал, зачем мне нужно знать как именно работает сборщик мусора, ну собирает он как-то мусор, ну и пусть собирает дальше.

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

Проблема

Однажды к нам пришёл один из клиентов и говорит “Ваше приложение потребляет слишком много памяти”, очень понятная проблема, не правда ли, новинка от создателей “Ниче не работает” или “Хорошо делайте, а плохо не делайте”. После общения с клиентом мы смогли воспроизвести проблему, и вот что выяснили:

  1. В сценарии клиента, приложение производит вычисления, которые в сумме потребляют около 8Гб оперативной памяти
  2. После завершения вычислений память не освобождается

После того как мы смогли воспроизвести проблему, DotMemory нарисовал нам вот такой график.

Даже спустя 4 часа память всё ещё не освободилась
Даже спустя 4 часа память всё ещё не освободилась

Мы решили, что всё достаточно очевидно — нужно просто принудительно запустить сборщик мусора после завершения вычислений. И мы были правы, но не совсем. После вызова GC.Collect() DotMemory нарисовал нам немного другую картину (На самом деле, перед этим мы ещё исправили пару утечек памяти, но сегодня не об этом).

Мусор собран, на приложение всё ещё потребляет 8Гб памяти
Мусор собран, на приложение всё ещё потребляет 8Гб памяти

Очевидно, сборщик собрал весь мусор накопленный во втором поколении (зелёная область), но приложение всё ещё занимает 8 Гб, несмотря на то, что реальный объём потребляемой памяти приложением всего 1 ГБ.

Именно это и подразумевалось, когда мы говорили, что приложение не освобождает память. И тут сразу стоит отметить, что мы не используем никакие нативные функции, WPF и прочие штуки, которые могли бы использовать неуправляемую память, мы используем ASP.NET и .NET 7.

Думаю теперь проблема ясна, давайте разбираться.

Неуправляемая память

Если присмотреться к графику, который рисует dotMemory, то несложно увидеть, что почти вся область закрашена серым — это так называемая неуправляемая память (Unmanaged memory).

Хорошее определение можно найти в туториале dotMemory.

Неуправляемая память — память, выделенная за пределами управляемой кучи и не управляемая сборщиком мусора. Как правило, это память, необходимая для .NET CLR, динамических библиотек, графического буфера (особенно большого для приложений WPF, интенсивно использующих графику) и т. д. Эта часть памяти не может быть проанализирована в профилировщике.

То есть сборщик мусора не может повлиять на неуправляемую память — потому что он ей, очевидно, не управляет, на то эта память и неуправляемая. Погуглив, я нашёл схожую проблему на Stack Overflow, убедившись, что не только мы столкнулись с этой проблемой.

Иначе говоря, в нашем случае неуправляемая память — это память выделенная процессу, и необязательно используемая им. То есть, если нашему приложению потребовалось 8 Гб памяти, а затем оно освободило эту память (как в нашем случае) — то оно не будет торопиться возвращать эту память.

Воспроизводим проблему

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

Чтобы воспроизвести проблему, нам необходимо:

  1. Запустить процесс, который будет потреблять много памяти — команда start
  2. Дождаться пока процесс займёт несколько гигабайт
  3. Остановить рост памяти — команда stop
  4. Очистить списки, которые мы заполняли под капотом (именно так я эмулирую потребление памяти), чтобы приложение освободило занимаемую память — команда clear
  5. Понаблюдать, что будет происходить с памятью приложения
  6. Запустить сборщик мусора gc и снова понаблюдать за памятью

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

Эмуляция потребления памяти происходит очень просто — по команде start в 4-х потоках выполняется заполнение списков строками, до тех пока не будет выполнена команда stop.

Воспроизведение проблемы с помощью консольного приложения
Воспроизведение проблемы с помощью консольного приложения

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

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

Но, когда же приложение освободит эту неуправляемую память? Чтобы узнать это — проведём несколько экспериментов.

Эксперименты

Состояние простоя

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

Я оставил приложение работать на ночь, и утром совершенно ничего не изменилось, приложение всё также потребляет 6.4Гб памяти.

Даже спустя 11 часа память всё ещё не освободилась

Повторный запуск процесса

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

Повторный запуск команды start приводит к уменьшение неуправляемой памяти
Повторный запуск команды start приводит к уменьшение неуправляемой памяти

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

Запуск другого приложения, которое потребляет много памяти

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

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

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

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

Анализируем дамп памяти

Последнее, что мы сделаем — проанализируем дамп памяти с помощью утилиты dotnet-dump:

  1. Устанавливаем dotnet tool install --global dotnet-dump
  2. Находим “Process ID” в Менеджере задач во вкладке Details и создаём дамп с помощью команды dotnet-dump collect -p 10132 \
  1. Запускаем анализатор dotnet-dump analyze ./core_20230808_073105.dmp
  2. Посмотрим, что находится в нашем дампе памяти с помощью команды dumpheap -stat

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

Как выглядит неуправляемая память в dotnet-dump
Как выглядит неуправляемая память в dotnet-dump

Вместо dotnet-dump можно использовать windbg, он покажет такие же результаты.

Как выглядит неуправляемая память в windbg
Как выглядит неуправляемая память в windbg

Подведём итог

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

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

Ещё немного о сборщике мусора

ЛОХ и ПОХ

Нет, я не ругаюсь, LOH и POH это — Large Object Heap и Pinned Object Heap соответственно. POH — это достаточно низкоуровневая штука, и в данном случае она нас не интересует. Я упоминаю её, лишь потому, что dotMemory отображает их вместе, подробнее о POH вы можете почитать здесь.

Large Object Heap, как очевидно следует из названия, — куча, в которую помещаются объекты размером более 85,000 байта. Сборщик мусора, очищает LOH во время очистки второго поколения.

Почему я решил об этом упомянуть? В более ранней версии этого консольного приложения, я заметил, что после запуска сборщика мусора оно всё ещё занимает 1Гб. И вся эта память находится в секции LOH and POH.

Списки в Large Object Heap
Списки в Large Object Heap

Почему так происходило? Дело в том, что списки, которые я использую под капотом для эмуляции потребления памяти, я очищал так:

list.Clear();

Класс List в C# представляет собой динамический массив, в котором есть 2 поля:

  • Count — количество элементов в коллекции
  • Capacity — количество элементов, которое может в себя вместить список, прежде чем придётся скопировать все элементы в массив побольше. Иными словами это размер массива, который используется под капотом.

Так вот, при вызове list.Clear() происходит удаление элементов из внутреннего массива, но его размер остаётся прежним. Это хорошо видно ниже, все списки пусты (Count = 0), но их ёмкость равна 33 554 432 и очевидно эти массивы попадут в LOH.

Декомпилированные исходники метода List.Clear
Декомпилированные исходники метода List.Clear
Декомпилированные исходники метода List.Clear
Свойства Count и Capacity списков в отладчике
Свойства Count и Capacity списков в отладчике

Конфигурация сборщика мусора

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

Heap Limit

Мы можем указать максимальный объём памяти, которое может потреблять приложение с помощью следующих настроек:

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

Конфигурация происходит в файле runtimeconfig.json, который должен находиться рядом с исполняемым файлом. Я использую файл runtimeconfig.template.json, находящийся в проекте, и содержимое которого будет скопировано в runtimeconfig.json рядом с exe-файлом.

Если я не хочу, чтобы моё приложение могло потреблять больше чем 2Гб оперативной памяти, я могу использовать следующую конфигурацию:

//runtimeconfig.template.json
{
    "configProperties": {
      "System.GC.HeapHardLimit": 2147483648
    }
}

Теперь давайте запустим наше приложение и посмотрим, что произойдёт, если приложение попробует занять больше 2Гб памяти:

  • Приложение займёт 2Гб памяти
  • Как только этот порог будет достигнут, сборщик мусора попробует очистить неиспользуемую память
  • Приложение упадёт, так как очистить память не удалось
Приложение падает при достижении лимита памяти в 2Гб
Приложение падает при достижении лимита памяти в 2Гб

Выводы

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

Если вам есть что добавить или уточнить или у вас есть вопросы — можем обсудить в комментариях. Спасибо, что дочитали и поменьше вам утечек памяти!