Бессильный сборщик мусора или неуправляемая память в .NET
Если вы готовитесь к собеседованию и гуглите список вопросов для кандидата на C# разработчика, то сто процентов один из вопросов будет о сборщике мусора. На собеседованиях этот вопрос действительно частенько задают, но как только они заканчиваются, магическим образом все знания улетучиваются, прямо как после экзамена. Долгое время я не понимал, зачем мне нужно знать как именно работает сборщик мусора, ну собирает он как-то мусор, ну и пусть собирает дальше.
Так я думал пока я не столкнулся с проблемами потребления памяти. Сегодня я хочу рассказать об одной из таких проблем, связанных с неуправляемой памятью. Перед прочтением можно кратко прочитать о сборщике мусора здесь или здесь.
Однажды к нам пришёл один из клиентов и говорит “Ваше приложение потребляет слишком много памяти”, очень понятная проблема, не правда ли, новинка от создателей “Ниче не работает” или “Хорошо делайте, а плохо не делайте”. После общения с клиентом мы смогли воспроизвести проблему, и вот что выяснили:
После того как мы смогли воспроизвести проблему, DotMemory нарисовал нам вот такой график.
Мы решили, что всё достаточно очевидно — нужно просто принудительно запустить сборщик мусора после завершения вычислений. И мы были правы, но не совсем. После вызова GC.Collect()
DotMemory нарисовал нам немного другую картину (На самом деле, перед этим мы ещё исправили пару утечек памяти, но сегодня не об этом).
Очевидно, сборщик собрал весь мусор накопленный во втором поколении (зелёная область), но приложение всё ещё занимает 8 Гб, несмотря на то, что реальный объём потребляемой памяти приложением всего 1 ГБ.
Именно это и подразумевалось, когда мы говорили, что приложение не освобождает память. И тут сразу стоит отметить, что мы не используем никакие нативные функции, WPF и прочие штуки, которые могли бы использовать неуправляемую память, мы используем ASP.NET и .NET 7.
Думаю теперь проблема ясна, давайте разбираться.
Если присмотреться к графику, который рисует dotMemory, то несложно увидеть, что почти вся область закрашена серым — это так называемая неуправляемая память (Unmanaged memory).
Хорошее определение можно найти в туториале dotMemory.
Неуправляемая память — память, выделенная за пределами управляемой кучи и не управляемая сборщиком мусора. Как правило, это память, необходимая для .NET CLR, динамических библиотек, графического буфера (особенно большого для приложений WPF, интенсивно использующих графику) и т. д. Эта часть памяти не может быть проанализирована в профилировщике.
То есть сборщик мусора не может повлиять на неуправляемую память — потому что он ей, очевидно, не управляет, на то эта память и неуправляемая. Погуглив, я нашёл схожую проблему на Stack Overflow, убедившись, что не только мы столкнулись с этой проблемой.
Иначе говоря, в нашем случае неуправляемая память — это память выделенная процессу, и необязательно используемая им. То есть, если нашему приложению потребовалось 8 Гб памяти, а затем оно освободило эту память (как в нашем случае) — то оно не будет торопиться возвращать эту память.
Чтобы воспроизвести проблему и убедиться, что проблема не где-то в нашем приложении, я написал небольшое консольное приложение, которое может потреблять большой объём памяти и запускать сборщик мусора — упрощённый сценарий того, что делает наше ASP.NET приложение. Все действия в приложении запускаются по команде, через консоль.
Чтобы воспроизвести проблему, нам необходимо:
start
stop
clear
gc
и снова понаблюдать за памятьюПеред запуском команды start
нужно подключиться с помощью dotMemory
, чтобы наблюдать за изменением памяти.
Эмуляция потребления памяти происходит очень просто — по команде start
в 4-х потоках выполняется заполнение списков строками, до тех пока не будет выполнена команда stop
.
Участок между clear
и gc
— наша первоначальная проблема, которую мы решили с помощью принудительного запуска сборщика мусора. Участок после gc
, это та самая неуправляемая память.
Проблему мы воспроизвели достаточно легко, после запуска сборщика мусора наше приложение всё ещё потребляет 8Гб памяти, хотя по факту использует лишь несколько мегабайт.
Но, когда же приложение освободит эту неуправляемую память? Чтобы узнать это — проведём несколько экспериментов.
Если на машине, где выполняется наше приложение не запущено других процессов, которым нужно много памяти, то эта неуправляемая память может не освобождаться часами.
Я оставил приложение работать на ночь, и утром совершенно ничего не изменилось, приложение всё также потребляет 6.4Гб памяти.
Даже спустя 11 часа память всё ещё не освободилась
Теперь ясно, что наше приложение не отдаст память без боя. Ну что ж, давайте проверим, что произойдёт, если повторно запустить команду start
. То есть, мы проделаем, описанный выше алгоритм дважды.
Как видно на графике dotMemory, как только мы запустили команду, и приложение снова начало потреблять память, неуправляемая память начала быстро освобождаться.
Теперь давайте посмотрим, поделится ли наше жадное приложение памятью, если рядом запустить второй экземпляр этого приложения, которому память будет нужнее.
Для этого мы проделаем всё те же операции на первом приложении, а потом запустим команду start
на втором и посмотрим, как будет себя вести неуправляемая память.
Как мы видим, как только второму приложению потребовалось больше памяти, произошло перераспределение ресурсов и первое приложение начало отдавать не используемую им неуправляемую память.
Последнее, что мы сделаем — проанализируем дамп памяти с помощью утилиты dotnet-dump:
dotnet tool install --global dotnet-dump
dotnet-dump collect -p 10132 \
dotnet-dump analyze ./core_20230808_073105.dmp
dumpheap -stat
Как мы видим, большая часть памяти занятая приложением свободна — это и есть та самая неуправляемая память.
Вместо dotnet-dump можно использовать windbg, он покажет такие же результаты.
Из всех проведённых экспериментов ясно, если приложение используется достаточно активно — то неуправляемая память не будет занята процессом на протяжении долгого периода времени и простаивать без дела. Как только либо тому же, либо другому процессу понадобится память, наше приложение не будет очень жадничать и начнёт его освобождать. В общем, ждун дождался освобождения памяти и теперь счастлив.
Пока я разбирался с этими нюансами работы сборщика мусора, я наткнулся на некоторые вещи которыми хотел бы поделиться прежде чем мы попрощаемся.
Нет, я не ругаюсь, LOH и POH это — Large Object Heap и Pinned Object Heap соответственно. POH — это достаточно низкоуровневая штука, и в данном случае она нас не интересует. Я упоминаю её, лишь потому, что dotMemory отображает их вместе, подробнее о POH вы можете почитать здесь.
Large Object Heap, как очевидно следует из названия, — куча, в которую помещаются объекты размером более 85,000 байта. Сборщик мусора, очищает LOH во время очистки второго поколения.
Почему я решил об этом упомянуть? В более ранней версии этого консольного приложения, я заметил, что после запуска сборщика мусора оно всё ещё занимает 1Гб. И вся эта память находится в секции LOH and POH.
Почему так происходило? Дело в том, что списки, которые я использую под капотом для эмуляции потребления памяти, я очищал так:
Класс List
в C# представляет собой динамический массив, в котором есть 2 поля:
Так вот, при вызове list.Clear()
происходит удаление элементов из внутреннего массива, но его размер остаётся прежним. Это хорошо видно ниже, все списки пусты (Count = 0), но их ёмкость равна 33 554 432
и очевидно эти массивы попадут в LOH.
Оказывается, сборщик мусора можно немного подтюнить через переменные окружения или конфигурационный файл. Конечно, вероятность того, что вы будете этим заниматься - крайне мала, его поведение по-умолчанию оптимально для большинства случаев. Но я всё же приведу пару настроек, которые легки в понимании и могут быть полезны.
Мы можем указать максимальный объём памяти, которое может потреблять приложение с помощью следующих настроек:
System.GC.HeapHardLimit
System.GC.HeapHardLimitPercent
В первом случае мы указываем в байтах максимальный объём оперативной памяти для приложение. А во втором случае — в процентах, от общего доступного объёма оперативки на устройстве. В принципе это может быть полезно, если вы пишете небольшую утилиту и хотите убедиться, что она не займёт слишком много памяти, если вы вдруг где-то допустили ошибку.
Конфигурация происходит в файле runtimeconfig.json, который должен находиться рядом с исполняемым файлом. Я использую файл runtimeconfig.template.json, находящийся в проекте, и содержимое которого будет скопировано в runtimeconfig.json рядом с exe-файлом.
Если я не хочу, чтобы моё приложение могло потреблять больше чем 2Гб оперативной памяти, я могу использовать следующую конфигурацию:
Теперь давайте запустим наше приложение и посмотрим, что произойдёт, если приложение попробует занять больше 2Гб памяти:
Итоги эксперимента мы уже подвели выше. От себя добавлю, что важно разобраться более детально с тем как работает сборщик мусора нежели на уровне теории, которая зубрится при подготовке к собеседованию. Надеюсь эта статья вам в этом поможет. Если статья была полезной — можете подписаться на канал, где я пишу о программировании.
Если вам есть что добавить или уточнить или у вас есть вопросы — можем обсудить в комментариях. Спасибо, что дочитали и поменьше вам утечек памяти!