Pull to refresh

Невызванная функция замедляет программу в 5 раз

Reading time 5 min
Views 42K
Original author: Bruce Dawson
Замедляем Windows, часть 3: завершение процессов



Автор занимается оптимизацией производительности Chrome в компании Google — прим. пер.

Летом 2017 года я боролся с проблемой производительности Windows. Завершение процессов происходило медленно, сериализованно и блокировало системную очередь ввода, что приводило к многократным подвисаниям курсора мыши при сборке Chrome. Основная причина заключалась в том, что при завершении процессов Windows тратила много времени на поиск объектов GDI, удерживая при этом критическую секцию system-global user32. Я рассказывал об этом в статье «24-ядерный процессор, а я не могу сдвинуть курсор».

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

Но на самом деле баг не вернулся. Причина оказалась в изменении нашего кода.

Проблема 2017 года


Каждый процесс Windows содержит несколько стандартных дескрипторов объектов GDI. Для процессов, которые ничего не делают с графикой, эти дескрипторы обычно имеют значение NULL. При завершении процесса Windows вызывает некоторые функции для этих дескрипторов, даже если они NULL. Это не имело значения — функции работали быстро — до выхода Windows 10 Anniversary Edition, в которой некоторые изменения в безопасности сделали эти функции медленными. Во время работы они удерживали ту же блокировку, которая использовалась для событий ввода. При одновременном завершении большого количества процессов каждый делает несколько вызовов медленной функции, которая удерживает эту критическую блокировку, что в итоге приводит к блокировке пользовательского ввода и к подвисанию курсора.

Патч Microsoft заключался в том, чтобы не вызывать эти функции для процессов без объектов GDI. Я не знаю подробностей, но думаю, что исправление Microsoft было примерно таким:

+ if (IsGUIProcess())
+ NtGdiCloseProcess();
– NtGdiCloseProcess();


То есть просто пропустить очистку GDI, если процесс не является процессом GUI/GDI.

Поскольку компиляторы и другие процессы, которые у нас быстро создаются и завершаются, не использовали объекты GDI, этого патча оказалось достаточно, чтобы исправить подвисание UI.

Проблема 2018 года


Оказалось, что процессам очень легко фактически выделяются некоторые стандартные объекты GDI. Если ваш процесс загружает gdi32.dll, то вы автоматически получите объекты GDI (DC, поверхности, регионы, кисти, шрифты и т.д.), нужны они вам или нет (обратите внимание, что эти стандартные объекты GDI не отображаются в Диспетчере задач среди объектов GDI для процесса).

Но это не должно быть проблемой. Я имею в виду, зачем компилятору загружать gdi32.dll? Ну, оказалось, что если загрузить user32.dll, shell32.dll, ole32.dll или многие другие DLL, то вы автоматически получите вдобавок gdi32.dll (с вышеупомянутыми стандартными объектами GDI). И очень легко случайно загрузить одну из этих библиотек.

Тесты LLVM при загрузке каждого процесса вызывали CommandLineToArgvW (shell32.dll), а иногда вызывали SHGetKnownFolderPath (тоже shell32.dll) Этих вызовов оказалось достаточно, чтобы вытянуть gdi32.dll и сгенерировать эти страшные стандартные объекты GDI. Поскольку набор тестов LLVM генерирует очень много процессов, он в конечном итоге сериализуется при завершении процессов, вызывая огромные задержки и зависания ввода, намного хуже, чем те, что были в 2017 году.

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

Первым делом мы избавились от вызова CommandLineToArgvW, вручную отпарсив командную строку. После этого набор тестов LLVM редко вызывал какие-либо функции из любой проблемной библиотеки DLL. Но мы заранее знали, что это никак не повлияет на производительность. Причина заключалась в том, что даже оставшегося условного вызова оказалось достаточно, чтобы всегда вытягивать shell32.dll, который в свою очередь вытягивал gdi32.dll, создающий стандартные объекты GDI.

Вторым исправлением стала отложенная загрузка shell32.dll. Отложенная загрузка означает, что библиотека загружается по требованию — при вызове функции — вместо загрузки при запуске процесса. Это означало, что shell32.dll и gdi32.dll будет загружаться редко, а не всегда.

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

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

Путь выполнения не принят


Стоит повторить, что мы обратили внимание на код, который не выполнялся — и это стало ключевым изменением. Если у вас есть инструмент командной строки, который не обращается к gdi32.dll, то добавление кода с условным вызовом функции многократно замедлит завершение процессов, если загружается gdi32.dll. В приведённом ниже примере CommandLineToArgvW никогда не вызывается, но даже простое присутствие в коде (без задержки вызова) негативно отражается на производительности:

int main(int argc, char* argv[]) {
  if (argc < 0) {
    CommandLineToArgvW(nullptr, nullptr); // shell32.dll, pulls in gdi32.dll
  }
}

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

Воспроизведение патологии


Когда я исследовал начальную ошибку, я написал программу (ProcessCreateTests), которая создавала 1000 процессов, а затем параллельно их все убивала. Это воспроизвело зависание, и когда Microsoft исправила ошибку, я использовал тестовую программу для проверки патча: см. видео. После реинкарнации бага я изменил свою программу, добавив опцию -user32, которая для каждого из тысячи тестовых процессов загружает user32.dll. Как и ожидалось, время завершения всех тестовых процессов резко возрастает с этой опцией, и легко обнаружить подвисания курсора мыши. Время создания процессов также увеличивается с параметром -user32, но во время создания процессов нет подвисаний курсора. Можете использовать эту программу и посмотреть, насколько ужасной может быть проблема. Здесь показаны некоторые типичные результаты моего четырёхъядерного/восьмипоточного ноутбука после недели аптайма. Опция -user32 увеличивает время для всего, но особенно драматично увеличивается блокировка UserCrit при завершении процессов:

> ProcessCreatetests.exe
Process creation took 2.448 s (2.448 ms per process).
Lock blocked for 0.008 s total, maximum was 0.001 s.

Process destruction took 0.801 s (0.801 ms per process).
Lock blocked for 0.004 s total, maximum was 0.001 s.

> ProcessCreatetests.exe -user32
Testing with 1000 descendant processes with user32.dll loaded.
Process creation took 3.154 s (3.154 ms per process).
Lock blocked for 0.032 s total, maximum was 0.007 s.

Process destruction took 2.240 s (2.240 ms per process).
Lock blocked for 1.991 s total, maximum was 0.864 s.


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


Я подумал о некоторых методах ETW, которые можно применить для более детального изучения проблемы, и уже начал писать их. Но натолкнулся на такое необъяснимое поведение, которому решил посвятить отдельную статью. Достаточно сказать, что в этом случае Windows ведёт себя ещё более странно.

Другие статьи цикла:


Литература


Tags:
Hubs:
+71
Comments 49
Comments Comments 49

Articles