Если вы готовитесь к собеседованию и гуглите список вопросов для кандидата на C# разработчика, то сто процентов один из вопросов будет о сборщике мусора. На собеседованиях этот вопрос действительно частенько задают, но как только они заканчиваются, магическим образом все знания улетучиваются, прямо как после экзамена. Долгое время я не понимал, зачем мне нужно знать как именно работает сборщик мусора, ну собирает он как-то мусор, ну и пусть собирает дальше.
Так я думал пока я не столкнулся с проблемами потребления памяти. Сегодня я хочу рассказать об одной из таких проблем, связанных с неуправляемой памятью. Перед прочтением можно кратко прочитать о сборщике мусора здесь или здесь.
Проблема
Однажды к нам пришёл один из клиентов и говорит “Ваше приложение потребляет слишком много памяти”, очень понятная проблема, не правда ли, новинка от создателей “Ниче не работает” или "Хорошо делайте, а плохо не делайте". После общения с клиентом мы смогли воспроизвести проблему, и вот что выяснили:
В сценарии клиента, приложение производит вычисления, которые в сумме потребляют около 8Гб оперативной памяти
После завершения вычислений память не освобождается
После того как мы смогли воспроизвести проблему, 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
и снова понаблюдать за памятью
<aside>