Память — достаточно дефицитный ресурс для многих компьютеров потребительского уровня, поэтому логично создать функцию, ограничивающую объём используемой процессом памяти; и Microsoft действительно реализовала такую функцию. Однако:

  • Компания её не задокументировала (!)

  • Её реализация на самом деле не экономит память

  • Реализация может иметь чрезмерно высокие затраты ресурсов CPU

Эта функция ограничивает рабочий набор процесса (количество памяти, отображённое в адресное пространство процесса) 32 мегабайтами. Прежде чем читать дальше, попробуйте предположить, какое максимальное замедление может вызывать эта функция. То есть если процесс многократно затрагивает больше, чем 32 МБ памяти (допустим 64 МБ памяти), то насколько больше будут занимать эти операции с памятью по сравнению с ситуацией без ограничений рабочего набора? Остановитесь на минуту и запишите своё предположение. Ответ будет ниже в посте.

Это исследование началось с того, что пользователь Chrome написал мне в Twitter о том, что постоянно наблюдает, как setup.exe браузера Chrome забирает кучу ресурсов CPU. Изучение странных проблем с производительностью Chrome — это в буквальном смысле моя работа, поэтому мы начали общаться. В конечном итоге пользователь запустил UIforETW в режиме записи кольцевого буфера (трассировка выполняется, и буферы сохраняются при возникновении проблемы), чтобы записать трассировку ETW. Он сообщил о баге Chromium, отправил трассировку, и я начал её изучать.

Трассировка действительно показала, что много времени CPU тратится на setup.exe (частота сэмплирования составляет 1 кГц, так что каждый сэмпл представляет примерно 1 мс времени CPU), но очевидных проблем заметить не удалось:

WPA CPU Usage (Sampled) screenshot showing setup.exe spending its time applying a patch
Скриншот WPA с использованием CPU (сэмплированным), на котором видно, что setup.exe тратит время на применение патча

То есть на первый взгляд не происходит ничего ненормального, однако, углубившись в самый «горячий» стек вызовов, я обнаружил нечто неожиданное:

WPA CPU Usage (Sampled) screenshot showing setup.exe spending its time applying a patch, but mostly in KiPageFault
Скриншот WPA с использованием CPU (сэмплированным), на котором видно, что setup.exe тратит время на применение патча, но в основном в KiPageFault

Было бы вполне нормально, если бы в KiPageFault попало несколько сотен сэмплов, но больше 20 тысяч — это определённо странно.

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

Так как KiPageFault встречается во многих стеках вызовов (в конце концов, страница память ведь может быть взята почти откуда угодно), мне нужно было воспользоваться режимом butterfly view, чтобы определить общие затраты и получить подсказки о том, почему на это тратится так много времени. Я нажал правой клавишей мыши на KiPageFault и выбрал View CalleesBy Function. После этого я увидел две интересные детали:

WPA CPU Usage (Sampled) screenshot showing setup.exe spending 99% of its time in KiPageFault
Скриншот WPA с использованием CPU (сэмплированным), на котором видно, что setup.exe тратит 99% своего времени в KiPageFault

Первая деталь: из 46912 сэмплов CPU, сделанных для этого процесса, целых 46444 из них (99%!) были проведены внутри KiPageFault. Это интересно. В процессе с устойчивым состоянием (не выполняющем чрезмерно распределения) и в системе с большим объёмом памяти (в этой системе было 64 ГиБ ОЗУ и примерно 47 ГиБ из них было свободно) количество ошибок страниц должно быть близко к нулю, а это было далеко не так.

Вторая деталь: основная часть времени внутри KiPageFault тратилась на MiTrimWorkingSet. Это логично. Но в то же время это довольно странно. Похоже, при каждом отсутствии страницы в процессе система немедленно модифицирует рабочий набор, предположительно удаляя из него ещё одну страницу. Это затратный процесс, повышающий вероятность ошибки страниц в будущем. То есть это объясняет, почему процесс проводит так много времени в KiPageFault, но всё же странно, потому что я не знаю, зачем Windows это делать.

WPA Total Commit table showing setup.exe with 47.418 of commit
В таблице WPA Total Commit видно, что setup.exe имеет commit 47,418

Трассировки ETW содержат большой объём информации, поэтому я взглянул в таблицу «Total Commit» и обнаружил. что setup.exe имеет суммарный commit на 47,418 МиБ. Это общая величина распределённой памяти в этом процессе, плюс нескольких других типов памяти, например стека, и модифицированных глобальных переменных. 47,418 МБ — это довольно скромная величина, которая не должна занимать больше 10 мс (см. подробности в Hidden Costs of Memory Allocation), а во время трассировки не было новых распределений, так что трата времени на KiPageFault определённо оказывается излишней.

WPA Virtual Memory Snapshots table showing the working set varying but always staying around 32 MiB
Из таблицы WPA Virtual Memory Snapshots видно, что рабочий набор варьируется, но всегда остаётся примерно равным 32 МиБ

Затем я взглянул на столбец Working Set в таблице Virtual Memory Snapshots. В этом столбце содержится время от времени сэмплируемая информация о рабочем наборе — в моём случае 19 сэмплов в течение 48 секунд. Из этих сэмплов видно, что размер рабочего набора варьируется от 31,922 МиБ до 32,004 МиБ. То есть сэмплируемый рабочий набор колеблется от 32 МиБ минус 80 КиБ до 32 МиБ плюс 4 КиБ. А это очень узкий диапазон.

Прокрастинация

Я думал, что причиной этого поведения может быть SetProcessWorkingSetSize, а мой коллега предположил, что оказывать влияние может SetPriorityClass с PROCESS_MODE_BACKGROUND_BEGIN, поэтому мне хотелось поэкспериментировать с этими функциями. Но отчёт о проблеме касался Windows 11, поэтому я предположил. что должна существовать какая-то нестандартная конфигурация, приводящая к подобному пограничному поведению, поэтому не думал, что мои тесты будут полезны, и ничего не делал в течение трёх недель.

Потом я всё же добрался до бага и решил начать с выполнения с самого простого теста. Я написал код, распределяющий 64 МиБ ОЗУ, задействовавший всю эту память, использовавший EmptyWorkingSetSetProcessWorkingSetSize и SetPriorityClass с PROCESS_MODE_BACKGROUND_BEGIN, а затем снова задействовавший память. Для мониторинга рабочего набора я воспользовался вызовами Sleep(5000) и Task Manager. Я не ожидал, что самый простой тест позволит выявить проблему.

Мои тесты показали, что EmptyWorkingSet и SetProcessWorkingSetSize опустошали рабочий набор почти до нуля, но при повторном касании памяти рабочий набор снова «перезаполнялся». То есть документация этих функций (какой бы безумной и архаичной она ни была) по большей мере казалась точной. И эти функции не могли вызывать проблемы, если не вызывались крайне часто.

С другой стороны, мои тесты показали, что SetPriorityClass с PROCESS_MODE_BACKGROUND_BEGIN вызывали обрезание рабочего набора до 32 МиБ и оставляли его в этом состоянии, пока я снова не касался памяти. То есть хотя в обычной ситуации активация 64 МиБ памяти приводила бы к ошибкам этих страниц и повышению размера рабочего набора до 64 МиБ или выше, в реальности рабочий набор оставался ограниченным.

Ого, какая дичь. Я не думал, что всё будет так просто. Я немного усовершенствовал код теста, но он всё равно оставался достаточно простым. В своём окончательном виде код распределяет 64 МиБ памяти, а затем многократно обходит эту память (выполняя под одной записи на каждую страницу), чтобы проверить, сколько раз он может пройти по этой памяти за секунду. Затем он выполняет то же самое с процессом, находящимся в фоновом режиме. Разница впечатляет:

Screenshot of command-prompt output from BackgroundBegin.exe showing normal mode scanning memory ~4400 times per second, while background mode does it 6-17 times
Скриншот вывода в командную строку из BackgroundBegin.exe, показывающий, что обычный режим сканирует память примерно 4400 раз в секунду, а фоновый режим — 6-17 раз

Производительность сканирования памяти в обычном режиме достаточно стабильна, на одно сканирование требуется примерно 0,2 мс. Сканирование в фоновом режиме обычно занимает примерно в 250 раз больше времени. Иногда сканирование в фоновом режиме существенно замедляется — увеличение составляет до 800 раз, то есть 160 мс для 64 МиБ.

Такое существенное увеличение времени CPU не способствует снижению влияния фоновых процессов.

Ограничение рабочего набора не экономит память!

Ну ладно, из-за PROCESS_MODE_BACKGROUND_BEGIN некоторые операции выполняются в 250 раз дольше, но он хотя бы экономит память. Ведь правда?

Ну, на самом деле, нет. По крайней мере, ни в одной из ситуаций, которые я могу представить.

Урезание рабочего набора процесса не экономит память. Оно просто перемещает память из рабочего набора процесса в список ожидания. Затем, если система находится в условиях дефицита памяти, страницы в списке ожидания могут сжиматься или сбрасываться (если они не изменены и записаны в файл), или записываться в файл подкачки. Но здесь важно слово «могут». В общем случае операционная система не делает ничего со страницей мгновенно. А если у системы есть куча свободной и доступной памяти, то она может никогда и не сделать ничего со страницей, то есть урезание окажется бессмысленным. Память не «экономится», она просто перемещается из одного списка в другой. Это цифровой аналог перекладывания бумажек.

Ещё одна причина бессмысленности этого урезания заключается в том, что в системе уже есть механизм для управления рабочими наборами (гораздо более эффективный). Каждую секунду пробуждается системный процесс и запускает KeBalanceSetManager. Среди прочего эта функция вызывает MiProcessWorkingSets, которая вызывает MiTrimOrAgeWorkingSet:

Screenshot of WPA's CPU Usage (Sampled) graph showing the system process running KeBalanceSetManager
Скриншот WPA с графом использования CPU (сэмплированного), показывающего системный процесс, выполняющий KeBalanceSetManager

Всё, что я знаю об этой системе — это имена функций и частота её работы, но я вполне уверен, что примерно этим она и занимается. При этом это гораздо более качественное решение проблемы. Вот почему MiTrimOrAgeWorkingSet лучше, чем PROCESS_MODE_BACKGROUND_BEGIN:

  • Урезание рабочего набора раз в секунду гораздо эффективнее (тратит меньше времени CPU), чем его урезание при каждом отсутствии страницы, и существенно снижает вероятность урезания страницы прямо перед тем, как она понадобится

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

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

Решение проблемы

Решение этой проблемы для Chrome простое — не вызывать эту функцию, а значит, не переводить процесс установки Chrome в этот режим. Мы всё равно работаем в режиме с низким приоритетом, но не в проблемном «фоновом» режиме.

Однако эта функция продолжает существовать, готовясь навредить какому-нибудь разработчику в будущем. Проще всего компании Microsoft было бы изменить документацию, сообщив о таком поведении. Например, добавить крупную красную пометку жирным шрифтом: «если ваш процесс использует больше 32 МиБ памяти, то ваша программа будет работать в 250 раз медленнее и при этом не экономить память, так что, возможно, стоит использовать THREAD_MODE_BACKGROUND_BEGIN». Но исправление документации будет не так полезно, как исправление фонового режима. Я не могу представить ситуацию, в которой ограничение рабочего набора будет лучшим решением, чем урезание рабочего набора, реализованное в системной процессе, поэтому от устранения этой функциональности выиграют все.

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

Забавно, что причиной использования PROCESS_MODE_BACKGROUND_BEGIN в Chrome стал баг Chrome 2012 года, из-за которого приложение обновления тратило слишком много времени CPU.

Отчёт об изложенной в статье проблеме был отправлен для Windows 11, но я обнаружил баг Mozilla с обсуждением этого флага, ссылающийся на ответ на Stack Overflow за 2015 год, в котором говорится, что PROCESS_MODE_BACKGROUND_BEGIN ограничивает рабочий набор 32 МиБ в Windows 7. Эта проблема известна восемь лет, встречается во многих версиях Windows, но всё ещё не была устранена или задокументирована. Надеюсь, теперь всё изменится.

Дополнения

Уточню, что урезается до 32 МиБ именно рабочий набор, а не приватный рабочий набор. То есть в 32 МиБ включается как код, так и данные.

Кроме того, опубликовав статью, я поэкспериментировал и выяснил, что при сбросе процесса при помощи PROCESS_MODE_BACKGROUND_END происходит урезание рабочего набора. Это не наносит никакого вреда, но поведение странное. Почему вывод процесса из фонового режима вызывает урезание рабочего набора, как будто процесс вызвал EmptyWorkingSet?

Пользователь Twitter опубликовал небольшую историю и инструмент (непроверенный!), создающий список состояния рабочих наборов для процессов в системе.

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


  1. dartraiden
    06.10.2023 16:05
    +2

    А это "перекладывание бумажек" отражается на цифрах потребления памяти в диспетчере задач? Если да, то это, возможно, ответ на претензии пользователей "почему процессы потребляют столько памяти?! сделайте меньше".

    Причем, эти претензии чаще всего возникают даже тогда, когда свободной памяти ещё полно. Игнорируя тот факт, что незадействованная память - это память, простаивающая впустую. Если её можно задействовать для чего-то полезного (допустим, увеличить разные кэши: кэш ресурсов браузера, кэш файловых операций), то её нужно задействовать. Лишь бы приложения могли оперативно отдать эту память, когда системе она понадобится для чего-то более важного, например, для запуска ещё какого-то процесса. Можно провести пример с финансовой подушкой: если вы просто держите некоторую сумму денег под подушкой в ожидании "черного дня", который наступит неизвестно когда и вообще неизвестно наступит ли - это неэффективно. Разумнее их пустить в какое-то дело, откуда их можно при необходимости достаточно оперативно вывести.

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


    1. CaptainFlint
      06.10.2023 16:05

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


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


      1. Fahrain
        06.10.2023 16:05
        +2

        У винды уже много лет сохраняется "фича" - засовывание в оперативку огромных файлов при малейшем к ним обращении. Т.е. это (как минимум до десятки) на всех версиях можно посмотреть примерно так: запускаем любое кино, выключаем, запускаем RamMap и смотрим вкладку File Summary - видео файл будет там видно. Причем там будет прям гигабайтами забито.

        И ладно бы - мало ли зачем файл вдруг повторно понадобится. Но по какой-то причине ос считает эти данные более приоритетными, чем личные данные программ. И в условиях недостатка памяти (<16 гигов оперативки, программами занято 80%+ памяти) в своп почему-то начинают улетать именно программы и их данные. А не очищаться кешированные бесполезные файлы. А еще эти кешированные файлы почему-то сами не чистятся с течением времени.

        Вот тут как раз очистка Standby-list'а и помогает на некоторое время. Пока ос опять какое-нить кино не закеширует.


        1. Sap_ru
          06.10.2023 16:05
          +3

          Фича ещё и сгубляется шаманской работой из prefetcher'ов всех поколений. Которые грузят в ОЗУ вообще чёрте что, вроде просмотренного вчера фильма. И не просто в ОЗУ, они ещё и копии всего загружаемого на диске делают.
          Там полнейшее безумие.


        1. LoadRunner
          06.10.2023 16:05

          Так ваши фильмы - это отображаемые на память файлы и после закрытия видео память уходит в Standby и будет выделена другим процессам при нехватке Free памяти.

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


          1. Fahrain
            06.10.2023 16:05

            Так я и не говорю, что это плохо. Я говорю, что там или баг, или проблемы с логикой. Потому что файлы кеширует в ущерб программам - вытесняя их в своп. И даже если потом к этим программам обращаться, то этот кеш не чистит и программы теперь начинают драться за остаток памяти, постоянно улетая в своп. А бесполезный видео файл, просмотренных 2 дня назад - так и будет занимать память.
            Всё это особенно хорошо видно в условиях нехватки памяти. Ну не знаю, если на 6-8 гигах оперативки работать, занимая её на 90%.


            1. LoadRunner
              06.10.2023 16:05

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

              Да и что значит в ущерб программам? При конкуренции за память, предпочтение отдаётся активным процессам.


              1. Fahrain
                06.10.2023 16:05

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

                При конкуренции за память, предпочтение отдаётся активным процессам.

                Ага. В идеале. А по факту - часть памяти занято кешированными файлами и активным процессам ее не хватает, поэтому они идут в своп. С появлением ССД это стало не так сильно заметно, но всё равно подлагивания, например, вкладок у хрома было видно (у меня их много).


      1. nervoushammer
        06.10.2023 16:05

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

        Не знаю как там во всяких Linux и серверных версиях, но в "бытовых" Windows ничего не простаивает. Вся свободная оперативная память идёт на дисковый кэш. Значение Available в Task Manager стабильно равно значению Cached. И RAMMap всё показывает соответственно.


        1. CaptainFlint
          06.10.2023 16:05

          Task Manager'ом я перестал пользоваться ещё со времён XP, так что сорри, с ним я не имею накопленных наблюдений. Сам пользуюсь Process Explorer'ом и Process Hacker'ом, и ориентируюсь на их показания занятости физической оперативки. Не могу с уверенностью сказать, что именно они меряют, так как в разных типах кэшей, маппинга и прочих особенностях управления памятью сам чёрт ногу сломит. Но зависимость прослеживается совершенно чётко: если они показывают, что свободной оперативки мало, то можно быть уверенным: когда она потребуется, никто мне волшебным образом её не освободит, а будет жуткий своппинг и будут ошибки выделения памяти. Конечно, какая-то часть задействованной другими программами памяти, если она не используется активно, может уйти в своп, и это освободит некоторый объём. Но это всё равно вызывает тормоза и никоим образом не соответствует распространённым уверениям, что "Хром на самом деле ничего не сожрал, стоит только намекнуть, как он в мгновение ока всё вам радостно вернёт".


          1. LoadRunner
            06.10.2023 16:05

            А что вы называете свободной оперативкой? Если в системе есть Free память, и она никак не используется системой, то значит, что её избыток. Ну и когда требуется память, то да, в первую очередь отдаётся из списка Free, потом Zeroed, и потом уже Standby. И разумеется, что Active вам никто не отдаст. И вот этот показатель как раз и является важным - сколько на самом деле памяти используется в работе.


            1. CaptainFlint
              06.10.2023 16:05

              Я (как и большинство не слишком опытных пользователей) просто открываю диалог сводной информации, и вижу там общую сводку в категории Physical memory: Current — столько-то, Total — столько-то. И я вижу, что когда хромоподобные браузеры (да и любые другие программы) начинают жрать память, значение Current начинает увеличиваться. И я неоднократно наблюдал, что когда значение Current слишком приближается к Total, это означает проблемы.


              Я не изучал детально, как конкретно Process Explorer и Process Hacker подсчитывают объём потребления физической памяти. Но по опыту выяснил, что это адекватный параметр, позволяющий оценить реальное состояние системы. Так что с практической точки зрения оно не столь уж и существенно, какие конкретно виды страниц и кэшей там учитываются и каким образом. Главное результат.


              1. LoadRunner
                06.10.2023 16:05

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

                Плюс рекомендация иметь на диске С запас свободного места в 15% (отсюда и окраска в синий-красный цвет полоски заполненности) тоже имеет под собой некоторые весомые причины.

                Ну и вообще, если в процессе работы за ПК Current близок к Total - это идеальная ситуация. Значит память утилизируется полностью и достигнут баланс.


                1. CaptainFlint
                  06.10.2023 16:05

                  Разумеется, своп у меня включён. Хотя бы по той причине, что в случае BSOD'а без свопа не создаётся дамп, по которому можно попытаться найти источник проблемы.


                  Касательно "идеальной ситуации" позволю себе не согласиться. То есть, если абсолютно все нужные программы уже запущены, загрузили все нужные им данные и спокойно работают, то да, это норма. Но в реальной жизни так не бывает. Разве что на каких-то серверах, которые что-то там молотят, и их никто не трогает. А пользователю обычно нужно запускать другие программы, загружать в память новые данные. И если вся память уже занята, то новым программам негде развернуться.


  1. Sap_ru
    06.10.2023 16:05
    +1

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


  1. Tzimie
    06.10.2023 16:05

    А как работают процессы, которые большие by design? SQL server, например?