В мае 2019 года меня попросили взглянуть на потенциально опасный баг Chrome. Поначалу я диагностировал его как неважный, потратив таким образом впустую две недели. Позже, когда я вернулся к расследованию, он превратился в причину номер один вылетов процесса браузера в beta-канале Chrome. Упс.
6 июня, в тот же день, когда я осознал свою ошибку в интерпретации данных вылетов, баг был помечен как ReleaseBlock-Stable. Это означало, что мы не сможем выпустить новую версию Chrome для большинства пользователей, пока не разберёмся, что происходит.
Вылет происходит, потому что у нас заканчивались объекты GDI (Graphics Device Interface), но мы не знали, какого типа эти объекты GDI, диагностические данные не давали никаких подсказок о том, где возникает проблема, и мы не могли её воссоздать.
Многие люди из нашей команды упорно работали над этим багом 6-7 июня, они тестировали свои теории, но так и не продвинулись вперёд. 8 июня я решил проверить свою почту, и Chrome сразу же вылетел. Это был тот самый сбой.
Какая ирония. Пока я искал изменения и исследовал отчёты о сбоях, пытаясь понять, что же могло заставлять процесс браузера Chrome вызывать утечку объектов GDI, количество объектов GDI в моём браузере неумолимо стремилось вверх, и к утру 8 июня превзошло волшебное число — 10 000. В этот момент одна из операций выделения памяти под объект GDI завершилась ошибкой и мы намеренно обрушили браузер. Это была невероятная удача.
Если ты можешь воспроизвести баг, то неизбежно сможешь его исправить. Мне оставалось всего лишь разобраться, как я вызвал этот баг, после чего мы сможем его устранить.
Для начала небольшая история вопроса
В большинстве мест кода Chromium при попытке выделения памяти под объект GDI мы сначала проверяем, успешно ли выполнено это выделение. Если память выделить не удалось, то мы записываем некую информацию в стек и намеренно выполняем вылет, как можно увидеть в этом исходном коде. Сбой вызывается намеренно, потому что если мы не можем выделить память под объекты GDI, то не сможем выполнять рендеринг на экран — лучше сообщить о проблеме (если включены отчёты о сбоях) и перезапустить процесс, чем отображать пустой UI. По умолчанию максимально можно создавать 10 000 объектов GDI на один процесс, а обычно используется всего несколько сотен. Поэтому если мы превысили этот лимит, то что-то пошло совсем не так.
Когда мы получаем один из отчётов о сбое, в котором говорится об ошибке выделения памяти для объекта GDI, у нас есть стек вызовов и всевозможная другая полезная информация. Отлично! Но проблема в том, что такие дампы сбоев не обязательно связаны с багом. Так происходит, потому что код, вызывающий утечку объектов GDI, и код, сообщающий о сбое, может быть не одним и тем же кодом.
То есть, грубо говоря, у нас есть два типа кода:
void GoodCode() { auto x = AllocateGDIObject(); if (!x) CollectGDIUsageAndDie (); UseGDIObject(x); FreeGDIObject(x); } void BadCode() { auto x = AllocateGDIObject(); UseGDIObject(x); }
Хороший код замечает, что выделение памяти завершилось ошибкой, и сообщает об этом, а плохой код игнорирует сбои и производит утечку объектов, таким образом «подставляя» хороший код, чтобы тот взял ответственность на себя.
Chromium содержит несколько миллионов строк кода. Мы не знали, какая из функций имела ошибку, и даже не знали, объекты GDI какого типа утекают. Один из моих коллег добавил код, который перед сбоем обходил Process Environment Block для получения количества объектов GDI каждого типа, но для всех перечисляемых типов (контексты устройства, области, битовые карты, палитры, кисти, перья и неизвестное) количество не превышало одной сотни. Странно.
Выяснилось, что объекты, под которые мы выделяем память напрямую, находятся в этой таблице, но объектов, созданных от нашего лица ядром, в ней нет, и они существуют где-то в диспетчере объектов Windows. Это означало, что GDIView столь же слеп к этой проблеме, что и мы (к тому же GDIView полезен только при локальном воспроизведении сбоя). Потому что у нас происходила утечка курсоров, а курсоры — это объекты USER32 с привязанными к ним объектами GDI; память под эти объекты GDI выделяется ядром, и мы не могли видеть, что происходит.
Ошибочное толкование
У нашей функции CollectGDIUsageAndDie — очень яркое название, и думаю, в этом вы со мной согласитесь. Очень экспрессивное.
Проблема в том, что она выполняет слишком много действий. CollectGDIUsageAndDie проверяла примерно с десяток различных типов сбоев выделения памяти под объекты GDI, и из-за встраивания кода они в результате получали одинаковую сигнатуру сбоя – все они вылетали в функции main и объединялись вместе. Поэтому один из моих коллег мудро внёс изменение, разбив разные проверки на отдельные (не встроенные) функции. Благодаря этому мы теперь с первого взгляда могли понять, какая проверка заканчивалась сбоем.
Увы, это привело к тому, что когда мы начали получать отчёты о сбоях от CrashIfExcessiveHandles, я уверенно сказала: «это не причина сбоя, это просто вызвано изменением сигнатуры».
Но я ошибался. Это была причина сбоя и изменение сигнатуры. Упс. Неуклюжий анализ, Доусон. Никаких тебе печенек.
Вернёмся к нашей истории
На этом этапе я уже знал, что нечто, сделанное мной 7 июня, использовало почти 10 000 объектов GDI за день. Если бы я мог понять что, то разгадал бы загадку.
В диспетчере задач Windows есть дополнительный столбец GDI objects, который можно использовать для поиска утечек. 7 июня я работал из дома, подключившись к моей рабочей машине, и этот столбец был включен на рабочей машине, потому что я прогонял тесты и пытался воспроизвести сценарий сбоя. Но тем временем в браузере на моей домашней машине происходили утечки объектов GDI.
Основная задача, для которой я использовал браузер дома — подключение к рабочей машине при помощи приложения Chrome Remote Desktop (CRD). Поэтому я включил столбец GDI objects на домашней машине и начал экспериментировать. Совсем скоро я получил результаты.
На самом деле шкала времени бага показывает, что с момента «у меня возник сбой» (14:00) до «это как-то связано с CRD», а потом и до «дело в курсорах» прошло всего 35 минут. Я уже говорил, насколько проще исследовать баги, когда можно воспроизвести их локально?
Оказалось, что каждый раз, когда приложение CRD (или любое приложение Chrome?) меняло курсоры, это приводило к утечке шести объектов GDI. Если поводить мышью по нужной части экрана во время работы с Chrome Remote Desktop, то могут произойти сотни утечек объектов GDI в минуту и тысячи в час.
Спустя месяц отсутствия всяческого прогресса в решении этой проблемы она внезапно превратилась из неустранимой в вопрос простого исправления. Я быстро написал черновой фикс, а потом один из коллег (над этим багом работал не я) создал настоящий фикс. Он был загружен 10 июня в 11:16, а был выпущен в 13:00. Спустя несколько мерджей баг исчез.
На этом всё?
Мы устранили баг, и это здорово, но гораздо важнее, чтобы такие баги никогда не повторялись. Очевидно, правильно использовать для управления ресурсами объекты C++ (RAII), но в данном случае баг содержался в классе WebCursor.
Когда дело доходит до утечек памяти, то существует надёжный набор систем. У Microsoft есть снэпшоты куч, в Chromium есть профилирование куч для пользовательских версий и устранитель утечек на тестовых машинах. Но похоже, что утечки объектов GDI были обделены вниманием. Process Information Block содержит неполную информацию, некоторые объекты GDI можно перечислять только в режиме ядра, и отсутствует единая точка для выделения и освобождения памяти под объекты, способная облегчить трассировку. Это была не первая утечка объектов GDI, с которой мне пришлось иметь дело, и она не будет последней, потому что надёжный способ отслеживания их отсутствует. Вот мои рекомендации для следующих релизов Windows:
- Сделать процесс получения количества всех типов объектов GDI тривиальным, без необходимости непонятного чтения PEB (и без игнорирования курсоров)
- Создать поддерживаемый способ перехвата и трассировки всех операций создания и уничтожения объектов GDI для надёжного отслеживания; в том числе и для тех, которые были созданы косвенно
- Отразить всё это в документации
Вот и всё. Подобное отслеживание даже реализовать не особо сложно, потому что объекты GDI обязательно ограничены так, как не ограничена память. Было бы здорово, если бы использование этих странных, но неизбежных объектов GDI стало более безопасным. Ну пожалуйста.
Здесь можно прочитать обсуждение на Reddit. Тема в Twitter начинается здесь.
korsarer
Звучит так, будто в Chrome работают дураки.