[Перевод] Баг памяти Windows, которому не менее восьми лет
Память — достаточно дефицитный ресурс для многих компьютеров потребительского уровня, поэтому логично создать функцию, ограничивающую объём используемой процессом памяти; и Microsoft действительно реализовала такую функцию. Однако:
Компания её не задокументировала (!)
Её реализация на самом деле не экономит память
Реализация может иметь чрезмерно высокие затраты ресурсов CPU
Эта функция ограничивает рабочий набор процесса (количество памяти, отображённое в адресное пространство процесса) 32 мегабайтами. Прежде чем читать дальше, попробуйте предположить, какое максимальное замедление может вызывать эта функция. То есть если процесс многократно затрагивает больше, чем 32 МБ памяти (допустим 64 МБ памяти), то насколько больше будут занимать эти операции с памятью по сравнению с ситуацией без ограничений рабочего набора? Остановитесь на минуту и запишите своё предположение. Ответ будет ниже в посте.
Это исследование началось с того, что пользователь Chrome написал мне в Twitter о том, что постоянно наблюдает, как setup.exe браузера Chrome забирает кучу ресурсов CPU. Изучение странных проблем с производительностью Chrome — это в буквальном смысле моя работа, поэтому мы начали общаться. В конечном итоге пользователь запустил UIforETW в режиме записи кольцевого буфера (трассировка выполняется и буферы сохраняются при возникновении проблемы), чтобы записать трассировку ETW. Он сообщил о баге Chromium, отправил трассировку, и я начал её изучать.
Трассировка действительно показала, что много времени CPU тратится на setup.exe (частота сэмплирования составляет 1 кГц, так что каждый сэмпл представляет примерно 1 мс времени CPU), но очевидных проблем заметить не удалось:
Скриншот WPA с использованием CPU (сэмплированным), на котором видно, что setup.exe тратит время на применение патча
То есть на первый взгляд не происходит ничего ненормального, однако углубившись в самый «горячий» стек вызовов, я обнаружил нечто неожиданное:
Скриншот WPA с использованием CPU (сэмплированным), на котором видно, что setup.exe тратит время на применение патча, но в основном в KiPageFault
Было бы вполне нормально, если бы в KiPageFault попало несколько сотен сэмплов, но больше 20 тысяч — это определённо странно.
KiPageFault срабатывает, когда процесс затрагивает память, которая в настоящее время не находится в рабочем наборе процесса. Память, с которой связана ошибка, может быть обнулённой страницей (первым использованием распределённой страницы), страницей из списка ожидания (страницы в памяти, содержащие данные), сжатой страницей или связанной с файлом страницей (файл отображаемой памяти или файл подкачки). Каким бы ни был источник, эта функция изменяет таблицы страниц, чтобы страница стала видимой внутри процесса, а затем перезапускает сбойную команду.
Так как KiPageFault встречается во многих стеках вызовов (в конце концов, страница память ведь может быть взята почти откуда угодно), мне нужно было воспользоваться режимом butterfly view, чтобы определить общие затраты и получить подсказки о том, почему на это тратится так много времени. Я нажал правой клавишей мыши на KiPageFault и выбрал View Callees, By Function. После этого я увидел две интересные детали:
Скриншот WPA с использованием CPU (сэмплированным), на котором видно, что setup.exe тратит 99% своего времени в KiPageFault
Первая деталь: из 46912 сэмплов CPU, сделанных для этого процесса, целых 46444 из них (99%!) были проведены внутри KiPageFault. Это интересно. В процессе с устойчивым состоянием (не выполняющем чрезмерно распределения) и в системе с большим объёмом памяти (в этой системе было 64 ГиБ ОЗУ и примерно 47 ГиБ из них было свободно) количество ошибок страниц должно быть близко к нулю, а это было далеко не так.
Вторая деталь: основная часть времени внутри KiPageFault тратилась на MiTrimWorkingSet. Это логично. Но в то же время это довольно странно. Похоже, при каждом отсутствии страницы в процессе система немедленно модифицирует рабочий набор, предположительно удаляя из него ещё одну страницу. Это затратный процесс, повышающий вероятность ошибки страниц в будущем. То есть это объясняет, почему процесс проводит так много времени в KiPageFault, но всё же странно, потому что я не знаю, зачем Windows это делать.
В таблице 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 видно, что рабочий набор варьируется, но всегда остаётся примерно равным 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 МиБ ОЗУ, задействовавший всю эту память, использовавший EmptyWorkingSet, SetProcessWorkingSetSize и SetPriorityClass с PROCESS_MODE_BACKGROUND_BEGIN, а затем снова задействовавший память. Для мониторинга рабочего набора я воспользовался вызовами Sleep (5000) и Task Manager. Я не ожидал, что самый простой тест позволит выявить проблему.
Мои тесты показали, что EmptyWorkingSet и SetProcessWorkingSetSize опустошали рабочий набор почти до нуля, но при повторном касании памяти рабочий набор снова «перезаполнялся». То есть документация этих функций (какой бы безумной и архаичной она ни была) по большей мере казалась точной. И эти функции не могли вызывать проблемы, если не вызывались крайне часто.
С другой стороны, мои тесты показали, что SetPriorityClass с PROCESS_MODE_BACKGROUND_BEGIN вызывали обрезание рабочего набора до 32 МиБ и оставляли его в этом состоянии, пока я снова не касался памяти. То есть хотя в обычной ситуации активация 64 МиБ памяти приводила бы к ошибкам этих страниц и повышению размера рабочего набора до 64 МиБ или выше, в реальности рабочий набор оставался ограниченным.
Ого, какая дичь. Я не думал, что всё будет так просто. Я немного усовершенствовал код теста, но он всё равно оставался достаточно простым. В своём окончательном виде код распределяет 64 МиБ памяти, а затем многократно обходит эту память (выполняя под одной записи на каждую страницу), чтобы проверить, сколько раз он может пройти по этой памяти за секунду. Затем он выполняет то же самое с процессом, находящемся в фоновом режиме. Разница впечатляет:
Скриншот вывода в командную строку из BackgroundBegin.exe, показывающий, что обычный режим сканирует память примерно 4400 раз в секунду, а фоновый режим — 6–17 раз
Производительность сканирования памяти в обычном режиме достаточно стабильна, на одно сканирование требуется примерно 0,2 мс. Сканирование в фоновом режиме обычно занимает примерно в 250 раз больше времени. Иногда сканирование в фоновом режиме существенно замедляется — увеличение составляет до 800 раз, то есть 160 мс для 64 МиБ.
Такое существенное увеличение времени CPU не способствует снижению влияния фоновых процессов.
Ограничение рабочего набора не экономит память!
Ну ладно, из-за PROCESS_MODE_BACKGROUND_BEGIN некоторые операции выполняются в 250 раз дольше, но он хотя бы экономит память. Ведь правда?
Ну, на самом деле, нет. По крайней мере, ни в одной из ситуаций, которые я могу представить.
Урезание рабочего набора процесса не экономит память. Оно просто перемещает память из рабочего набора процесса в список ожидания. Затем, если система находится в условиях дефицита памяти, страницы в списке ожидания могут сжиматься или сбрасываться (если они не изменены и записаны в файл), или записываться в файл подкачки. Но здесь важно слово «могут». В общем случае операционная система не делает ничего со страницей мгновенно. А если у системы есть куча свободной и доступной памяти, то она может никогда и не сделать ничего со страницей, то есть урезание окажется бессмысленным. Память не «экономится», она просто перемещается из одного списка в другой. Это цифровой аналог перекладывания бумажек.
Ещё одна причина бессмысленности этого урезания заключается в том, что в системе уже есть механизм для управления рабочими наборами (гораздо более эффективный). Каждую секунду пробуждается системный процесс и запускает KeBalanceSetManager. Среди прочего эта функция вызывает MiProcessWorkingSets, которая вызывает MiTrimOrAgeWorkingSet:
Скриншот 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 за 201 5 год, в котором говорится, что PROCESS_MODE_BACKGROUND_BEGIN ограничивает рабочий набор 32 МиБ в Windows 7. Эта проблема известна восемь лет, встречается во многих версиях Windows, но всё ещё не была устранена или задокументирована. Надеюсь, теперь всё изменится.
Дополнения
Уточню, что урезается до 32 МиБ именно рабочий набор, а не приватный рабочий набор. То есть в 32 МиБ включается как код, так и данные.
Кроме того, опубликовав статью, я поэкспериментировал и выяснил, что при сбросе процесса при помощи PROCESS_MODE_BACKGROUND_END происходит урезание рабочего набора. Это не наносит никакого вреда, но поведение странное. Почему вывод процесса из фонового режима вызывает урезание рабочего набора, как будто процесс вызвал EmptyWorkingSet?
Пользователь Twitter опубликовал небольшую историю и инструмент (непроверенный!), создающий список состояния рабочих наборов для процессов в системе.