История одной проблемы со Speedometer, или Как Chromium управляет памятью
Современный браузер — это крайне сложный проект, в котором даже безобидные с виду изменения могут приводить к неожиданным сюрпризам. Поэтому существует множество внутренних тестов, которые должны такие изменения отловить до релиза. Тестов никогда слишком много не бывает, поэтому полезно использовать в том числе сторонние публичные бенчмарки.
Сегодня я расскажу читателям Хабра о том, как устроено управление памятью в проекте Chromium на примере одной загадочной проблемы, которая приводила к падению производительности в тесте Speedometer. Меня зовут Андрей Логвинов, я работаю в группе разработки рендеринг-движка Яндекс.Браузера в Нижнем Новгороде. Этот пост основан на моём докладе с мероприятия Яндекс.Изнутри.
Этот тест измеряет совокупную производительность браузера на приближенном к реальности приложении — списке дел, где тест добавляет пункты в список и затем вычёркивает их.
Однажды на нашем дашборде производительности мы увидели ухудшение скорости работы теста Speedometer. Тест Speedometer состоит из нескольких подтестов, где тестовое приложение написано с использованием одного из популярных JS-фреймворков, например jQuery или ReactJS. На результаты теста влияет как производительность JS-движка V8, так и скорость отрисовки страниц в движке Blink. Стоит отметить, что тест не ставит своей целью оценить производительность фреймворков, они используются только для того, чтобы сделать тест менее синтетическим и более приближенным к реальным веб-приложениям. Общий результат теста определяется как среднее для результатов по всем фреймворкам, но тест позволяет посмотреть производительность по каждому фреймворку в отдельности. А это уже интересно, согласитесь. Детализация по подтестам показала, что ухудшение наблюдается только для версии тестового приложения, созданного с использованием jQuery.
Для этого у нас хранятся сборки Яндекс.Браузера на каждый (!) коммит за последние несколько лет (собирать заново было бы непрактично, так как сборка занимает несколько часов). Расследование таких ситуаций начинается достаточно стандартно — мы определяем, какой именно коммит в код привёл к проблеме. Но в этот раз быстро не получилось. Пространства на серверах это занимает немало, но обычно помогает быстро найти источник проблемы. Результат не обнадеживающий, потому что новая версия Хромиума приносит огромное количество изменений разом. Оказалось, что ухудшение результатов теста совпало с коммитом, интегрирующим очередную версию Хромиума.
Для этого мы при помощи Developer Tools сняли трейсы теста. Поскольку информации, указывающей на конкретное изменение, мы не получили, пришлось заняться исследованием проблемы по существу. Заметили странную особенность — «рваные» интервалы исполнения Javascript-функций теста.
Снимаем более технический трейс при помощи about:tracing и видим, что это сбор мусора (GC) в Blink.
На трейсе памяти ниже видно, что эти GC-паузы не только занимают много времени, но и никак не помогают остановить рост потребляемой памяти.
Значит, утечек памяти у нас нет, а проблема связана с особенностями работы сборщика. Но если вставить в тест явный вызов GC, то мы видим совсем другую картину — память держится в районе нуля и не утекает. Запускаем отладчик и видим, что сборщик мусора обошел около 500 тысяч объектов! Продолжаем копать. Но откуда они взялись? Такое количество объектов не могло не повлиять на производительность.
Он удаляет мертвые объекты, но не перемещает живые, что позволяет оперировать «голыми» указателями в локальных переменных в коде C++. И здесь нам понадобится небольшой флешбек про устройство сборщика мусора в Blink. Но он имеет и свою цену — при сборе мусора приходится сканировать стек потока, и если там обнаруживается что-то похожее на указатель на объект из кучи (heap), то считать объект и всё, на что он ссылается прямо или косвенно, живыми. Этот паттерн активно используется в Blink. Поэтому такая форма сбора мусора ещё называется консервативной. Это приводит к тому, что некоторые фактически недоступные и поэтому «мертвые» объекты идентифицируются как живые.
Проблема исчезла. Проверяем связь со сканированием стека и пропускаем его.
Ставим точку остановки в функцию добавления объектов — в числе прочего видим там подозрительное: Что же может быть такого в стеке, что удерживает 500 тысяч объектов?
blink::TraceTrait<blink::HeapHashTableBacking<WTF::HashTable<blink::WeakMember…
Проверяем гипотезу, пропуская добавление этой ссылки. Ссылка на хэш-таблицу — вероятный подозреваемый! Отлично, мы стали ещё на шаг ближе к разгадке. Проблема исчезла.
В нашем случае вхолостую. Вспоминаем другую особенность сборщика мусора в Blink: если он видит указатель на внутренности хэш-таблицы, то считает это признаком продолжающейся итерации по таблице, а значит, считает все ссылки этой таблицы полезными и продолжает их обходить. Но какая же функция является источником этой ссылки?
Это функция с названием ScheduleGCIfNeeded. Продвигаемся на несколько кадров стека выше, берем текущую позицию сканера, смотрим, в кадр стека какой функции она попадает. Тем более что это уже часть собственно сборщика мусора, и ссылаться на объекты из кучи Blink ей просто незачем. Казалось бы, вот он виновник, но… смотрим на исходный код функции и видим, что никаких хэш-таблиц там и в помине нет. Откуда же тогда взялась эта «плохая» ссылка?
Видим, что пишет туда одна из внутренних функций под названием V8PerIsolateData::AddActiveScriptWrappable. Ставим брейкпойнт на изменение ячейки памяти, в которой нашли ссылку на хэш-таблицу. Эта таблица нужна для предотвращения удаления элементов, на которые нет больше ссылок из Javascript или дерева DOM, но которые связаны с какой-либо внешней активностью, которая, например, может генерировать события. Там происходит добавление создаваемых HTML-элементов некоторых типов, в том числе input, в единую хэш-таблицу active_script_wrappables_.
Однако, в нашем случае указатель на внутреннее хранилище данной таблицы всплывает при сканировании стека, и все элементы таблицы помечаются как живые. Сборщик мусора при нормальном обходе таблицы учитывает состояние содержащихся в ней элементов и либо помечает их как живые, либо не помечает, тогда они удаляются на следующем этапе сборки.
Но как значение из стека одной функции попало в стек другой?!
Напомним, что в исходном коде этой функции ничего полезного найдено не было, но это лишь значит, что пора спуститься на более низкий уровень и проверить работу компилятора. Вспоминаем про ScheduleGCIfNeeded. Дизассемблированный пролог функции ScheduleGCIfNeeded выглядит так:
0FCDD13A push ebp 0FCDD13B mov ebp,esp 0FCDD13D push edi 0FCDD13E push esi 0FCDD13F and esp,0FFFFFFF8h 0FCDD142 sub esp,0B8h 0FCDD148 mov eax,dword ptr [__security_cookie (13DD3888h)] 0FCDD14D mov esi,ecx 0FCDD14F xor eax,ebp 0FCDD151 mov dword ptr [esp+0B4h],eax
Видно, что функция двигает esp вниз на 0B8h, и это место не используется дальше. Но из-за этого сканер стека видит то, что было ранее записано другими функциями. И случайно именно в эту «дыру» попадает указатель на внутренности хэш-таблицы, оставленный функцией AddActiveScriptWrappable. Как оказалось, причиной появления «дыры» в данном случае стал отладочный макрос VLOG внутри функции, который выводит в лог дополнительную информацию.
Почему ухудшение производительности наблюдается только на тесте jQuery? Но почему в таблице active_script_wrappable_ оказались сотни тысяч элементов? Тест плодит элементы, которые почти сразу превращаются в мусор. Ответ на оба вопроса один — в данном конкретном тесте, на каждое изменение (вроде отметки в чекбоксе) весь UI пересоздается полностью. Если вы разрабатываете веб-сервисы, то стоит это учесть, чтобы не создавать лишнюю работу браузеру. Остальные тесты в Speedometer более благоразумны и не создают лишних элементов, поэтому для них ухудшения производительности не наблюдается.
Точного ответа нет, но, скорее всего, при обновлении изменилось взаимное расположение элементов в стеке, из-за чего указатель на хэш-таблицу стал случайно доступен сканеру. Но почему проблема возникла только сейчас, если макрос VLOG был раньше? Чтобы быстро закрыть «дыру» и восстановить производительность, мы удалили отладочный макрос VLOG. По сути, мы выиграли в лотерею. Также мы рассказали о нашем опыте другим разработчикам из Chromium. Для пользователей он бесполезен, а для собственных нужд диагностики мы всегда сможем включить его обратно. Ответ подтвердил наши опасения: это фундаментальная проблема консервативного сбора мусора в Blink, не имеющая системного решения.
Интересные ссылки
1. Если вам интересно узнать о других необычных буднях нашей группы, то напомним историю о чёрном прямоугольнике, которая привела к ускорению не только Яндекс.Браузера, но и всего проекта Chromium.
А ещё приглашаю послушать другие доклады на следующем мероприятии Яндекс.Изнутри 16 февраля, регистрация открыта, трансляция тоже будет. 2.