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

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

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

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

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

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

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

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

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

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

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

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

  1. Устанавливаем dotnet tool install --global dotnet-dump

  2. Находим “Process ID” в Менеджере задач во вкладке Details и создаём дамп с помощью команды dotnet-dump collect -p 10132

  3. Запускаем анализатор dotnet-dump analyze ./core_20230808_073105.dmp

  4. Посмотрим, что находится в нашем дампе памяти с помощью команды 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Гб

Выводы

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

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

Комментарии (41)


  1. mayorovp
    15.08.2023 07:07
    +5

    Странно что эта память классифицируется как неуправляемая, хотя на самом деле это же просто свободная память в управляемой куче.


    Действительно неуправляемая память — это та, что возвращается NativeMemory.Alloc и другими подобными методами.


    1. mvv-rus
      15.08.2023 07:07
      +1

      Это, скорее всего — придержанная память: сегменты, некогда отведенные под управляемую память, которые были впоследствии целиком освобождены, но не возвращены ОС. Подробнее о механизме придерживание виртуальной памяти (VM Hoarding) можно прочитать в книге Конрада Кокосы "Управление памятью в .NET" в конце 5-главы (в русском издании 2020 года). Естественно классифицировать их именно такие сегменты именно как неуправляемую память: ни одной из управляемых куч эта память на текущий момент не отведена.
      PS Сведений по деталям управления придержанной памятью именно в .NET 7.0 в книге Кокосы, естественно нет, так что предположение сделано из общих соображений. Но если автору статьи интересно, он может найти в этой книге описание инструментов, годных для того, чтобы выяснить это достоверно.


      1. mockingbird_jay
        15.08.2023 07:07

        Про VM Hoarding лучше почитать в блоге Microsoft у Maoni Stephens (Large Object Heap Uncovered (an old MSDN article).

        В свое время читала Конрада Кокосу, но в оригинале, так как в русском издании много неточностей перевода. Хорошая добротная книга.


        1. mvv-rus
          15.08.2023 07:07

          Про VM Hoarding лучше почитать в блоге Microsoft у Maoni Stephens (Large Object Heap Uncovered (an old MSDN article).

          Вы уверены, что это лучше — читать статью написанную в 2008 году и переопубликованную в 2016?
          PS Никаких заметных на глаз помарок перевода книги Кокосы на русский я не помню. Но вообще-то соавторы перевода написали тут на Хабре статью про свой труд, так что недостатки их перевода лучше, наверное, обсудить с ними.


      1. mayorovp
        15.08.2023 07:07

        Естественно классифицировать их именно такие сегменты именно как неуправляемую память: ни одной из управляемых куч эта память на текущий момент не отведена.

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


        1. mvv-rus
          15.08.2023 07:07

          Тем не менее, в настоящий момент эта память управляемой куче не выделена. А в описании утилиты DotMemory (см. цитату в статье), явно написано, что утилита считает такую память — за пределами управляемой кучи — неуправляемой (хотя сама CLR, естественно, этой памятью управляет и может вернуть эту память ОС — что она и делает в дальнейшем, когда в системе возникает спрос на виртуальную память). Короче, вопрос IMO чисто терминологический, все пожелания — авторам утилиты.
          PS А так-то при необходимости среда исполнения может управляемой куче выделить и передать память, которую она получит от ОС, а не только ту, которую она придержала.


          1. mayorovp
            15.08.2023 07:07

            А в описании утилиты DotMemory (см. цитату в статье), явно написано, что утилита считает такую память — за пределами управляемой кучи — неуправляемой

            Вот именно к этому у меня и претензия. Какого хрена утилита DotMemory считает такую память неуправляемой?


      1. SanSYS
        15.08.2023 07:07

        Емнип в GC Server по куче на каждое ядро (что является ныне дефолтом) и "освобождение" кучи (или переезд) не есть освобождение памяти выделенной ОС процессу. Быстро проверить – включить в конфиге GC Workstation

        VMMap для быстро проверить, что же там с памятью https://learn.microsoft.com/en-us/sysinternals/downloads/vmmap

        Testlimit – создать дефицит памяти в ОС и глянуть, как себя поведет процесс (возвращает память = у вас нет проблемы) https://learn.microsoft.com/en-us/sysinternals/downloads/testlimit

        +Просто вызвать gc.collect недостаточно, там 4 аргумента, + в гц сеттингс отдельно для loh и коллекта был параметр + эвейтифинализаторов и повторный вызов //сорян, без кода, я с телефона


  1. saneks222
    15.08.2023 07:07

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


    1. cherkalexander Автор
      15.08.2023 07:07

      Круто, спасибо за рекомендацию! Сложно читается?


      1. saneks222
        15.08.2023 07:07
        +1

        В целом чтиво не самое простое, но написано все понятно если не отвлекаться и вдумчиво читать то нормально идет.


        1. cherkalexander Автор
          15.08.2023 07:07

          Ну понятно, о таком совсем просто вряд ли получится написать. Ещё раз спасибо!


  1. Aquahawk
    15.08.2023 07:07
    +5

    А где самый главный вывод? Как не ограничивать приложение в памяти но заставить приложение отдать всю ненужную ему память когда интенсивная нагрузка закончилась?


    1. cherkalexander Автор
      15.08.2023 07:07

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

      Основная идея, если приложение не отдаёт память - значит оно никому особа и не нужна. Насильно у вас нет доступа заставить приложение вернуть всю не нужную память (круто, если бы кто-то пришёл и сказал, что это не так).

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

      Мы никак не стали это исправлять, память в итоге всё равно освобождается. Я постарался показать это в описанных примерах.


      1. Aquahawk
        15.08.2023 07:07
        +1

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


        1. cherkalexander Автор
          15.08.2023 07:07

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


  1. Getequ
    15.08.2023 07:07

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


    1. cherkalexander Автор
      15.08.2023 07:07

      Главное попутно ничего не сломать ????


    1. mayorovp
      15.08.2023 07:07

      Проще для тяжёлых по памяти операций запускать отдельный процесс.


  1. ptr128
    15.08.2023 07:07

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

    И пару раз нарвавшись на такие проблемы, сразу приучаешься к явному вызову Delete() для независимости от сборщика мусора, а что-то вроде Clear() используешь только тогда, когда это действительно нужно.


    1. mayorovp
      15.08.2023 07:07

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


      Явный же вызов чего-то вроде Delete() выглядит очень странно, потому что уже есть Dispose() и using.


      1. ptr128
        15.08.2023 07:07
        -2

        Вот после такого подхода и пришлось рефакторить почти весь код АСКУЭ, как только вместо района попробовали запустить область. На пиках сборщик мусора просто не успевал сам вызывать деструкторы. После рефакторинга с Delete, пик потребляемой памяти снизился почти в пять раз. То есть k8s хватило четверти терабайта оперативки, вместо терабайта.

        Неужели Вы считаете, что если Вы явно не вызвали Delete, его не вызовет потом сборщик мусора? Вот только это потом может быть уже тогда, когда все станет жутко тормозить на свопе.


        1. mayorovp
          15.08.2023 07:07
          +2

          О каком вообще Delete() вы, блин, говорите? Что этот Delete такого магического может сделать? В .NET не существует никакого Delete().


    1. cherkalexander Автор
      15.08.2023 07:07

      О каком именно Clear() вы говорите?


      1. ptr128
        15.08.2023 07:07
        -2

        Об упомянутом в статье, который, по факту, вызывает Dispose(), а не Delete() на дочерние объекты, оставляя необходимость вызова Delete() уже сборщику мусора.


  1. Artem07
    15.08.2023 07:07

    полезная статья, спасибо)))!!!


  1. mockingbird_jay
    15.08.2023 07:07
    -2

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

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

    Как самый настоящий старпер, я бы обратила внимание на счётчики производительности или на результаты VMMAp. Убедилась бы, что растёт размер управляемой кучи, и она занимает столько-то гигабайт (визуализировала, собрала бы статистику и проч.).
    И очень зря, что вы опустили рассказ по утечкам памяти, так как нет полной картины. Осталось какое-то послевкусие незавершенности.


    1. cherkalexander Автор
      15.08.2023 07:07

      А чем ждут то не угодил? ????

      Спасибо за упоминание VMMap ????

      Утечки памяти были банальные, мы подписались на событие а при удалении объекта, забывали отписаться. Эти утечки памяти не влияют на данную статью, к тому же не хотелось её делать слишком большой.


      1. mvv-rus
        15.08.2023 07:07

        У меня есть предположение, что это за неуправляемая память, см. мой предыдущий комментарий: https://habr.com/ru/articles/754248/#comment_25862456


        1. cherkalexander Автор
          15.08.2023 07:07

          ????


        1. mockingbird_jay
          15.08.2023 07:07

          Это не похоже на VM Hoarding.

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


  1. kolebynov
    15.08.2023 07:07

    В .NET 7 завесли новый GC основанный на сегментах и регионах. Возможно это влиет на стратегию возврата памяти OC. В этой статье рассказано как происходит decommit памяти в новом GC


    1. cherkalexander Автор
      15.08.2023 07:07

      Посмотрю, спасибо


  1. Deosis
    15.08.2023 07:07
    +2

    Есть настройка System.GC.RetainVM, которая определяет, что делает сборщик мусора со свободной памятью, придерживает или возвращает обратно ОС.


    1. cherkalexander Автор
      15.08.2023 07:07

      Проверю, спасибо ????


      1. cherkalexander Автор
        15.08.2023 07:07

        Проверил. Эти настройки как-то особо не влияют в данном случае. System.GC.RetainVM по-умолчанию - false, поэтому память по-умолчанию должна отдаваться операционной системе

        https://learn.microsoft.com/en-us/dotnet/core/runtime-config/garbage-collector

        RetainVM: true
        RetainVM: true
        RetainVM: false
        RetainVM: false


  1. Temoxa
    15.08.2023 07:07

    как по итогу решилась то проблема? Или так и оставили у клиента?))


    1. cherkalexander Автор
      15.08.2023 07:07

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


  1. mikegordan
    15.08.2023 07:07

    Не C# , но всё же об этом. Пожалуйста не делайте как chrome.

    Посмотрите, свободно 30 ГБ ФИЗИЧЕСКОЙ памяти, но при открытие любого сайта , вылетает out of memory :D


    1. cherkalexander Автор
      15.08.2023 07:07

      Это как так? Не сталкивался с этим


    1. mayorovp
      15.08.2023 07:07

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


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


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