Хабрахабр

[Перевод] 24-ядерный CPU, а я не могу набрать электронное письмо (часть 2)

В прошлой статье я обещал подробнее раскрыть некоторые детали, которые опустил во время расследования [подвисаний Gmail в Chrome под Windows — прим. пер.], включая таблицы страниц, блокировки, WMI и ошибку vmmap. Сейчас восполняю эти пробелы вместе с обновлёнными примерами кода. Но сначала вкратце изложим суть.

Поэтому если вы продолжаете выделять и освобождать исполняемую память по разным адресам, то процесс накапливает произвольный объём памяти CFG. Речь шла о том, что процесс с поддержкой Control Flow Guard (CFG) выделяет исполняемую память, одновременно выделяя память CFG, которую Windows никогда не освобождает. Браузер Chrome делает это, что приводит к практически неограниченной утечке памяти и подвисаниям на некоторых машинах.

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

И память программы, и память CFG в конечном счёте выделяются 4-килобайтными страницами (подробнее об этом позже). Поскольку 4 КБ памяти CFG могут описывать 256 КБ памяти программы (подробнее об этом позже), это означает, что если выделить блок памяти 256 КБ, выровненный по 256 КБ, то вы получите одну страницу CFG на 4 КБ. И если выделить блок исполняемой памяти 4 КБ, то вы всё равно получите страницу CFG на 4 КБ, но её бóльшая часть не будет использоваться.

Если применить функцию VirtualFree на блоке исполняемой памяти, не кратном 256 КБ или не выровненном по 256 КБ, то ОС должна провести некоторый анализ и проверить, что какая-нибудь другая исполняемая память не использует CFG-страницу. Всё сложнее, если освобождается исполняемая память. Весьма прискорбно. Авторы CFG решили не заморачиваться — и просто навсегда оставляют выделенную память CFG. Это означает, что когда моя тестовая программа выделяет, а затем освобождает 1 гигабайт выровненной исполняемой памяти, то она оставляет 16 МБ памяти CFG.

Поскольку Chrome неоднократно выделяет и освобождает память по случайным адресам, то это приводит к вышеописанной проблеме. На практике выходит, что когда JavaScript-движок Chrome выделяет, а затем освобождает 128 МБ выровненной исполняемой памяти (не вся она была использована, но весь диапазон выделен и сразу освобождён), то до 2 МБ памяти CFG останется выделенной, хотя тривиально освободить её целиком.

В любой современной ОС каждый процесс получает собственное адресное пространство виртуальной памяти, чтобы ОС изолировала процессы и защитила память. Это делается с помощью блока управления памятью (MMU) и таблиц страниц. Память разбита на страницы по 4 КБ. Это минимальный объём памяти, который даёт вам ОС. На каждую страницу указывает восьмибайтовая запись в таблице страниц, а сами записи сохраняются в страницах по 4 КБ. Каждая из них указывает максимум на 512 различных страниц памяти, поэтому нам нужна иерархия таблиц страниц. Для 48-разрядного адресного пространства в 64-битной операционной системе система такая:

  • Таблица 1-го уровня охватывает 256 ТБ (48 бит), указывая на 512 различных таблиц страниц 2-го уровня
  • Каждая таблица уровня 2 покрывает 512 ГБ, указывая на 512 таблиц уровня 3
  • Каждая таблица уровня 3 покрывает 1 ГБ, указывая на 512 таблиц уровня 4
  • Каждая таблица уровня 4 охватывает 2 МБ, указывая на 512 физических страниц

MMU индексирует таблицу 1-го уровня в первых 9 (из 48) битах адреса, таблицы 2-го уровня — в следующих 9 битах, и остальным уровням выдаётся по 9 бит, то есть всего 36 бит. Оставшиеся 12 бит используются для индексации 4-килобайтных страниц из таблицы 4-го уровня. Ну и ну.

Это означает, что при выделении страницы памяти ОС выделяет некоторые таблицы страниц — от нуля до трёх, в зависимости от того, находятся выделенные адреса в ранее неиспользуемой области 2 МБ, ранее неиспользуемой области 1 ГБ или ранее неиспользуемой области 512 ГБ (таблица страниц уровня 1 выделяется всегда). Если сразу заполнить все уровни таблиц, то потребуется более 512 ГБ оперативной памяти, поэтому они заполняются по мере необходимости.

Утечки CFG случаются довольно редко, так что когда vmmap показал 412 480 КБ использованных таблиц страниц в Chrome, я предположил правильность цифр. Короче говоря, выделения по случайным адресам обходятся значительно дороже, чем выделения близлежащих адресов, так как в первом случае нельзя совместно использовать таблицы страниц. Вот скриншот vmmap с раскладкой памяти chrome.exe из прошлой статьи, но со строкой Page Table:

Я решил добавить симулятор таблиц страниц в свой инструмент VirtualScan. Но что-то показалось неправильным. Нужно просто просканировать выделенную память, прибавляя к счётчику по единице на каждом числе, кратном 2 МБ, 1 ГБ или 512 ГБ. Он подсчитывает, сколько страниц таблиц страниц необходимо для всей выделенной памяти в процессе сканирования.

Разница примерно соответствует выделенной памяти CFG. Быстро обнаружилось, что результаты симулятора соответствуют vmmap на нормальных процессах, но не на процессах с большим количеством памяти CFG. Для вышеупомянутого процесса, где vmmap говорит о 402,8 МБ (412 480 КБ) таблиц страниц мой инструмент показывает 67,7 МБ.

Scan time, Committed, page tables, committed blocks
Total: 41.763s, 1457.7 MiB, 67.7 MiB, 32112, 98 code blocks
CFG: 41.759s, 353.3 MiB, 59.2 MiB, 24866

vmmap утверждал, что выделил 2 гигабайта таблиц страниц: Я убедился в ошибке vmmap, запустив VAllocStress, который в настройках по умолчанию заставляет Windows выделять 2 гигабайта CFG-памяти.

Итак, vmmap ошибается, мои вычисления с таблицами страниц верны, и после плодотворного обсуждения в твиттере я отправил отчёт об ошибке vmmap, которую должны исправить. И когда я завершил процесс через Диспетчер задач, vmmap показал, что объём выделенной памяти снизился всего на 2 гигабайта. Память CFG по-прежнему расходует много записей таблиц страниц (59,2 МБ в вышеприведённом примере), но не так много, как говорит vmmap, а после исправления вообще не будет расходовать практически ничего.

Хочу немного отступить назад и более подробно рассказать, что такое CFG.

Это метод защиты от эксплойтов с помощью перезаписи указателей на функции. CFG — сокращение от Control Flow Guard. Сначала соответствующий управляющий байт CFG загружается из зарезервированной области CFG в 2 ТБ. При включенном CFG компилятор и ОС вместе проверяют валидность целевого объекта ветви. 64-разрядный процесс в Windows распоряжается адресным пространством 128 ТБ, поэтому деление адреса на 64 позволяет найти соответствующий байт CFG для этого объекта.

uint8_t cfg_byte = cfg_base[size_t(target_addr) / 64];

Для этого CFG обрабатывает байт как четыре двухбитных значения, каждое из которых соответствует 16-байтовому диапазону. Теперь у нас есть один байт, который должен описывать, какие адреса в 64-байтовом диапазоне являются допустимыми целями ветвей. Это двухбитное число (значение которого от нуля до трёх) интерпретируется следующим образом:

  • 0 — все цели в этом 16-байтовом блоке являются невалидными целями косвенных веток
  • 1 — начальный адрес в этом 16-байтовом блоке является валидной целью косвенной ветви
  • 2 — связано с «подавленными» вызовами CFG; адрес потенциально недопустим
  • 3 — невыровненные адреса в этом 16-байтовом блоке являются допустимыми целями косвенной ветви, однако 16-байтовый выровненный адрес потенциально недопустим

Если цель косвенной ветви оказывается недопустимой, процесс завершается, и эксплойт предотвращается. Ура!

Из этого можно сделать вывод, что для максимальной безопасности косвенные цели ветви следует выравнивать по 16 байтам, и можно понять, почему объём памяти CFG для процесса примерно равен 1/64 от объёма памяти программы.

Многие источники описывают память CFG как однобитную на 8 байт, а не двухбитную на 16 байт. Реально CFG загружает по 32 бита за раз, но это детали реализации. Моё объяснение лучше.

Подвисания Gmail происходят по двум причинам. Во-первых, сканирование CFG-памяти на Windows 10 16299 или более ранних версий мучительно медленно. Я видел, как сканирование адресного пространства процесса занимает 40 или более секунд, и буквально 99,99% этого времени сканируется зарезервированная память CFG, хотя она составляет всего около 75% зафиксированных блоков памяти. Не знаю, почему сканирование происходило так медленно, но в Windows 10 17134 это исправили, так что нет смысла изучать проблему более подробно.

Но блокировка резервирования памяти не удерживалась на протяжении всего сканирования. Из-за медленного сканирования возникало подвисание, потому что Gmail хотел получить резервирование CFG, а WMI держал блокировку на время сканирования. Поэтому блокировка получалась и освобождалась ~49 000 раз и каждый раз удерживалась менее 1 миллисекунды. В моём примере в области CFG примерно 49 000 блоков, а функция NtQueryVirtualMemory, которая получает и освобождает блокировку, вызывалась по разу для каждого из них.

Это несправедливо! Но хотя блокировка освобождалась 49 000 раз, процесс Chrome по какой-то причине так и не смог её получить.

Как я писал в прошлый раз: Именно в этом суть проблемы.

Это потому что блокировки Windows по своей природе несправедливы — и если поток освобождает блокировку, а затем немедленно её запрашивает снова, то может получать её вечно.

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

Они просто захватывают блокировку, как упоминается в статье Джо Даффи. Несправедливые блокировки дешевле, и они не заставляют потоки ждать в очереди. Он также пишет:

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

Как же соотнести заявление Джо от 2006 года о редкости голода с моим опытом на 100% повторяемой и длительной проблеме? Думаю, главная причина в том, что произошло в 2006 году. Intel выпустила Core Duo, и многоядерные компьютеры стали повсеместными.

В такой системе поток WMI снимет блокировку, подаст сигнал потоку Chrome о пробуждении и продолжит работу. Ведь оказывается, что эта проблема голода происходит только на многоядерной системе! Поскольку поток WMI уже запущен, у него «фора» перед потоком Chrome, так что он с лёгкостью повторно вызывает NtQueryVirtualMemory и повторно получает блокировку, прежде чем Chrome имеет шансы сделать это.

Как правило, Windows повышает приоритет нового потока, а повышение приоритета означает, что при освобождении блокировки новый поток Chrome будет готов и немедленно опередит поток WMI. Очевидно, что в одноядерной системе одновременно может работать только один поток. Это даёт потоку Chrome много времени, чтобы проснуться и получить блокировку, а голод никогда не наступает.

В многоядерной системе повышение приоритета в большинстве случаев не влияет на поток WMI, так как он будет выполняться на другом ядре! Понимаете?

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

Подозреваю, что решением может быть то, что я называю «иногда справедливыми» блокировками. Таким образом, несправедливые блокировки повышают производительность, но могут привести к голоду. Это с большего сохранит преимущества производительности, избежав проблемы голода. Скажем, 99% времени они будут несправедливыми, но в 1% отдавать блокировку другому процессу. Отказ от ответственности: я не эксперт по блокировкам или инженер ОС, но мне интересно услышать мысли об этом, и по крайней мере я не первый, кто предлагает нечто подобное. Раньше блокировки в Windows распределялись справедливо и, вероятно, можно частично вернуться к этому, найдя идеального баланс.

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

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

Для всех этих исследований я полагался на трассировку ETW, так что слегка испугался, когда в начале расследования оказалось, что Windows Performance Analyzer (WPA) не мог загрузить символы Chrome. Уверен, буквально на прошлой неделе всё работало. Что же случилось…

Если запустить dumpbin и посмотреть на отладочную информацию, вы увидите: Случилось то, что вышел Chrome M68, а он скомпонован с помощью lld-link вместо компоновщика VC++.

C:\b\c\b\win64_clang\src\out\Release_x64\./initialexe/chrome.exe.pdb

Но всё равно это не имеет смысла, потому что я изменил компоновщик на lld-link, и помню, что тестировал WPA до этого, так что же случилось… Ладно, наверное, WPA не нравятся эти слэши.

Я протестировал компоновку lld-Link — и она нормально работала в WPA 16299. Оказалось, что причина в новой WPA версии 17134. Новый компоновщик и новая WPA оказались несовместимы. Как совпало!

Теперь можно вернуться к WPA 17134, когда M69 скомпонуют исправленным линкером. Я поставил старую версию WPA, чтобы продолжить расследование (xcopy с машины со старой версией) и сообщил о баге lld-link, который разработчики быстро исправили.

Триггер зависания WMI является оснасткой управления Windows (Windows Management Instrumentation), а в этом я плохо разбираюсь. Я обнаружил, что в 2014 году или ранее кто-то столкнулся с проблемой значительного использования CPU в WmiPrvSE.exe внутри perfproc!GetProcessVaData, но они не предоставили достаточно информации, чтобы понять причины бага. В какой-то момент я сделал ошибку и пытался выяснить, какой сумасшедший запрос WMI может повесить Gmail на несколько секунд. Я подключил к расследованию некоторых экспертов и потратил кучу времени, пытаясь найти этот волшебный запрос. Я записал активность Microsoft-Windows-WMI-Activity в трассировках ETW, экспериментировал с PowerShell, чтобы найти все запросы Win32_Perf, и заплутал ещё по нескольким окольным путям, которые слишком скучны для обсуждения. В конце концов я обнаружил, что зависание Gmail вызывает такой счётчик Win32_PerfRawData_PerfProc_ProcessAddressSpace_Costly, запускаемый однострочником PowerShell:

measure-command

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

WMI не делал ничего плохого — не совсем — он просто сканировал память. Но детали WMI не имеют значения. Написание собственного кода сканирования оказалось гораздо полезнее в расследовании проблемы.

Chrome выпустил патч, остальное за Microsoft.

  1. Ускорить сканирование регионов CFG — хорошо, это сделано
  2. Освобождать память CFG, когда освобождается исполняемая память — по крайней мере, в случае выравнивания по 256 КБ, это легко
  3. Рассмотреть флаг, позволяющий выделять исполняемую память без памяти CFG, или использовать для этой цели PAGE_TARGETS_INVALID. Обратите внимание, что руководство Windows Internals Part 1 7th Edition говорит, что «следует выделять [CFG] страницы только с по крайней мере одним битовым набором {1,X}» — если Windows 10 реализует это, то флаг PAGE_TARGETS_INVALID (который сейчас используется движком v8) позволит избежать выделения памяти
  4. Исправить расчёт таблиц страниц в vmmap для процессов с большим количеством выделений CFG

Я обновил примеры кода, особенно VAllocStress. Туда включено 20 строк для демонстрации, как найти резервацию CFG для процесса. Я также добавил тестовый код, который использует SetProcessValidCallTargets для проверки значения битов CFG и демонстрации трюков, необходимых для их успешного вызова (подсказка: вызов через GetProcAddress, вероятно, приведёт к нарушению CFG!)

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть