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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Если вы готовитесь к собеседованию и гуглите список вопросов для кандидата на 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 и снова понаблюдать за памятью

<aside>

Источник: https://habr.com/ru/articles/754248/


Интересные статьи

Интересные статьи

На сегодняшний день в .NET существует несколько видов кодогенерации: новомодные Source Generators, компилируемые Expression Trees, динамические сборки и динамические методы. Каждый способ имеет свои о...
В интернете огромное количество статей и диаграмм на эту тему, однако, по моему мнению, ни одна из них не позволяет сформировать общее представление об использовании памя...
Немного о себе: медицинский психолог, долгое время работаю в психиатрической больнице. Имею специализацию по детской нейропсихологии (работал с детьми с ограниченными воз...
К сожалению, мир машинного обучения принадлежит python. Он давно закрепился, как рабочий язык для Data Silence, с чем Microsoft решила поспорить. Так появился ML.NET, кросс-платформенная...
Здравствуй, Хабр. Хочу поделиться с миром достаточно нетипичной, по крайней мере для меня, задачкой и её решением, которое мне кажется вполне приемлемым. Описанное ниже, возможно, не является ...