Замедляем 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 ведёт себя ещё более странно.

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


Литература


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


  1. CoolCmd
    04.12.2018 19:14

    задержка загрузки shell32.dll

    правильнее перевести как отложенная загрузка

    автор патчей был так благодарен за моё расследование, что выдвинул меня на корпоративный бонус

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


    1. SergeyMax
      04.12.2018 23:52
      +1

      сотрудники гугла
      До гугла точно так же делал Марк Руссинович, в частности я имею в виду его цикл статей "дело о..."


    1. unclejocker
      05.12.2018 10:59

      Зачем так сложно? Теоретически достаточно сговора троих инженеров и менеджера. Инженеры номинируют друг-друга по кругу (двоих мало, т.к. нельзя номинировать того, кто номинировал тебя), менеджер апрувит.


  1. qw1
    04.12.2018 21:01
    +1

    удерживая при этом критический раздел system-global user32
    Не стоило так переводить "критическую секцию".


  1. slonopotamus
    04.12.2018 21:46

    Ссылка на "часть 2" ведет на часть 1.


  1. apro
    05.12.2018 00:16

    А что в самом MS происходит раз они выпускают Update с такой проблемой? У них же самих должен быть набор тестов для WIN32 API, и он идее прогон их тестового набора тоже должен был замедлиться в несколько раз, им было на это наплевать?


    1. qw1
      05.12.2018 00:41

      Маловероятно, что у них есть тест, проверяющий завершение 1000 процессов в течении 1 секунды (я бы до такого не додумался). А если и есть тест, он может проверять только стабильность системы (что ничего не упало, все ресурсы освобождены), и выполняется в автоматическом режиме. То есть, тест пройден, но некому отметить, что во время выполнения теста весь UI подвис.


      1. apro
        05.12.2018 01:54

        Маловероятно, что у них есть тест, проверяющий завершение 1000 процессов

        Это же одна из самых востребованных функций ОС — запуск новых процессов,
        думаете у них теста одновременного запуска максимально возможного количества процессов? А как иначе тестировать обработку максимального количества объектов разного рода от дескрипторов файлов до GDI объектов и как ОС обрабатывает эту ситуацию. Очень странно было бы не автоматизировать проверку обработки этого крайнего случая.


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


        1. qw1
          05.12.2018 12:12

          Я о том и написал, что тест на завершение 1000 процессов, если и есть, то не проверяет отзывчивость UI в этот момент. Ну да, он завершается успешно через несколько секунд, ничего не упало, утечек памяти нет — тест пройден.


  1. ktod
    05.12.2018 08:48

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


    1. bvn13
      05.12.2018 09:29

      как воспроизвести?


      1. ktod
        05.12.2018 09:44

        Кликаете на пиктограмму «свернуть окно» и без паузы двигаете указатель мыши. Указатель остается на месте пока проигрывается «анимация» сворачивания.


        1. bvn13
          05.12.2018 10:03

          видимо, SSD и 16Гб ОЗУ делает свое дело…


          1. AngReload
            05.12.2018 10:11
            +1

            HDD и 8Гб ОЗУ — курсор бегает без проблем. Вообще никогда не слышал о такой проблеме.


            1. SergeyMax
              05.12.2018 11:19

              Такое было на виавских чипсетах времён первого пентиума, когда драйверы Bus Master IDE не были установлены, или чот типа такого


            1. ua30
              05.12.2018 12:29

              Как мало надо для курсора… Помню в детстве ZX Spectrum был с 48 КБ ОЗУ. Не понимал, ну зачем так много памяти делать, кому столько пригодится!?


              1. vassabi
                05.12.2018 12:33

                вы видели — какой нынче есть стандарт иконок для айпада с ретиной?
                я когда увидел дизайнеров с просьбой им спаковать 1024х1024, я чуть не упал. А ведь нет — таки иконка!


                1. ua30
                  05.12.2018 12:41

                  Вспомнились ASCII «картинки для взрослых» в монохроме. А как увидел первые, наверное, 256х256 фото на 4-битном CGA (16 цветов, но цветов!) — не спал неделю. Были ведь времена! В такие моменты понимаешь, что ты уже не молод. Хоть еще и не стар!


                  1. vanxant
                    06.12.2018 10:15

                    Наверно всё-же EGA мониторе. CGA не умел в графику 16 цветов (точнее умел в особо диком режиме 160х200, но не каждый).


                1. FreeNickname
                  06.12.2018 01:25

                  1024x1024 Apple всё же использует для featuring в AppStore, не на рабочем столе)


          1. ktod
            05.12.2018 12:41

            32ГБ и nvme. Ничего не решают.


            1. springimport
              05.12.2018 18:03

              На самом деле в таких вещах решает цп. Для полноты картины нужен 9900k 5.0ггц.


        1. namikiri
          05.12.2018 16:24

          8 Гб, обычный жестак — курсор перемещается в процессе отрисовки.


    1. roscomtheend
      05.12.2018 10:41
      +1

      Никогда не слышал о такой баге (использую с версии 3.1), только что проверил (под рукой только Win10 на ноуте) — ничего не фризится (но и анимация сворачивания быстрая).


    1. old_gamer
      05.12.2018 19:37

      О, спасибо, тестируя, случайно обнаружил, что если зажать ЛКМ на кнопке приложения в таскбаре и провести мышью вверх, то вызывается меню, доступное по щелчку ПКМ. Не знаю, зачем мне это знание, но прикольно.
      Вин 7.


  1. vassabi
    05.12.2018 11:36
    +1

    напомнило случай, когда я работал на игроделов, в то вермя у них была проблема со скоростью завершения игры. Обычно с уровня выход идет в меню, а потом уже из программы, поэтому процесс выгрузки объектов был не так заметен. Но в этом случае — они хотели, чтобы было быстро прямо с уровня…
    Долго мучались, ускоряли и спрямляли логику, даже нашли и пофиксили 10+ багов в разных деструкторах. Уже второй дедлайн пропущен. Пока не заметили (прямо как в истории про «платье короля», интерн спросил техлида — «смотрите, а почему это так ?»), что при креше программа «завершается практически мгновенно».
    После этого прикрутили флажок к глобальному обработчику (все нормально, это мы выходим, не пугай пользователя) и после flush на файл сейва — делили единицу на ноль (или разыменовывали NULL, уже не помню). Еле потом уговорили ПМа, что так на самом деле делать нельзя и в других играх такого делать больше не будем.


    1. Gorthauer87
      05.12.2018 12:07

      А разве нет возможности забить на вызов деструкторов статических переменных и выйти так без креша?


      1. qw1
        05.12.2018 12:15

        Например, в каждом деструкторе написать что-то типа

        if (g_isFastExiting) return;

        Но это сколько надо исходников менять + зависимость всех объектов от модуля с флагом — нехорошо.

        Либо ExitProcess(0);


      1. vassabi
        05.12.2018 12:24

        там было много причин, и не только в статических переменных.
        Вообще, стремление было сделать выход с «останавливаем все потоки, закрываем все дескрипторы, и помечаем всю выделенную память как пустую».
        Так что «выход с крешем» просто был самым быстрым вариантом для реализации (второй дедлайн, рождество через неделю), чтобы не плодить лишних зависимостей никуда, кроме обработчика сигналов.


        1. qw1
          05.12.2018 12:27

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


          1. vassabi
            05.12.2018 12:41

            когда до дедлайна еще две недели — то игроделы нормально работают: утечки ищут, скорость считают. А когда после запланированной даты сдачи проекта про*ли уже второй срок сдачи проекта, то…


      1. qw1
        05.12.2018 12:25

        -


    1. Chaos_Optima
      05.12.2018 12:13

      А почему деление на 0 а не std::abort?


      1. vassabi
        05.12.2018 12:31

        увы, если бы то решение писал я, я бы вам точно ответил. Я в это время работал над другой игрушкой, просто в одном опенспейсе сидели.
        Рискну предположить, что abort уже был обвязан своей логикой (т.е. SIGABRT уже что-то делал), в том числе и прочие штатные пути типа onexit/atexit. А хотелось — чтобы никаких деструкторов, никаких раскруток стеков, просто стопануть, всю память освободить (прямо на уровне «железа» — пометить страницы свободными и досвидания, гори дом вместе с тараканами) и все.


        1. mayorovp
          05.12.2018 13:48

          Тогда надо вызывать std::terminate(). Необработанные исключения вызывают именно его.


          1. vassabi
            05.12.2018 15:25

            эээ… а разве не наоборот — хендлер для необработанных исключений вызывает std::terminate, который в штатном варианте вызвает std::abort, который вызывает сигнал SIGABRT, который обрабатывается его обработчиком? При этом сигнал SIGSEGV ( *NULL=0; ) — сразу летит на свой обработчик (да, можно пошаманить с __try… __catch, но смысл ?).

            перечитал доки по std::terminate() — сейчас я бы наверно пробовал что-то типа std::_Exit(EXIT_SUCCESS);, но, как говорится: «был бы я такой умный, как моя жена завтра» :)

            UPD: вспомнил еще засаду для всех вариантов с std::zzzz: там был свой набор libc/libcpp/etc, так что вполне могло быть что и std::_Exit был перегруженый, с вызовом внутреннего менеджера памяти, так что сложно сказать — сработало бы или нет…


            1. mayorovp
              05.12.2018 15:30

              Хм, и правда. Тогда тем более не понятно чем std::abort не устроил.


        1. amarao
          05.12.2018 15:30

          man 2 exit

          void _exit(int status);
          The function _exit() terminates the calling process «immediately». Any open file descriptors belonging to the process are closed.


    1. Evengard
      05.12.2018 12:21

      А почему нельзя было сделать просто ExitProcess(0)?


      1. vassabi
        05.12.2018 12:53

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


  1. leR12
    05.12.2018 18:26

    вообще без проблем на W7. один раз настроил ( лет 6 назад) мгновенно всё то что надо отрубается. память очищается.