История одной проблемы со Speedometer, или Как Chromium управляет памятью

в 9:18, , рубрики: c++, chromium, Google Chrome, Блог компании Яндекс, браузеры, Разработка веб-сайтов, яндекс

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

Меня зовут Андрей Логвинов, я работаю в группе разработки рендеринг-движка Яндекс.Браузера в Нижнем Новгороде. Сегодня я расскажу читателям Хабра о том, как устроено управление памятью в проекте Chromium на примере одной загадочной проблемы, которая приводила к падению производительности в тесте Speedometer. Этот пост основан на моём докладе с мероприятия Яндекс.Изнутри.

История одной проблемы со Speedometer, или Как Chromium управляет памятью - 1


Однажды на нашем дашборде производительности мы увидели ухудшение скорости работы теста Speedometer. Этот тест измеряет совокупную производительность браузера на приближенном к реальности приложении — списке дел, где тест добавляет пункты в список и затем вычёркивает их. На результаты теста влияет как производительность JS-движка V8, так и скорость отрисовки страниц в движке Blink. Тест Speedometer состоит из нескольких подтестов, где тестовое приложение написано с использованием одного из популярных JS-фреймворков, например jQuery или ReactJS. Общий результат теста определяется как среднее для результатов по всем фреймворкам, но тест позволяет посмотреть производительность по каждому фреймворку в отдельности. Стоит отметить, что тест не ставит своей целью оценить производительность фреймворков, они используются только для того, чтобы сделать тест менее синтетическим и более приближенным к реальным веб-приложениям. Детализация по подтестам показала, что ухудшение наблюдается только для версии тестового приложения, созданного с использованием jQuery. А это уже интересно, согласитесь.

Расследование таких ситуаций начинается достаточно стандартно — мы определяем, какой именно коммит в код привёл к проблеме. Для этого у нас хранятся сборки Яндекс.Браузера на каждый (!) коммит за последние несколько лет (собирать заново было бы непрактично, так как сборка занимает несколько часов). Пространства на серверах это занимает немало, но обычно помогает быстро найти источник проблемы. Но в этот раз быстро не получилось. Оказалось, что ухудшение результатов теста совпало с коммитом, интегрирующим очередную версию Хромиума. Результат не обнадеживающий, потому что новая версия Хромиума приносит огромное количество изменений разом.

Поскольку информации, указывающей на конкретное изменение, мы не получили, пришлось заняться исследованием проблемы по существу. Для этого мы при помощи Developer Tools сняли трейсы теста. Заметили странную особенность — «рваные» интервалы исполнения Javascript-функций теста.

image

Снимаем более технический трейс при помощи about:tracing и видим, что это сбор мусора (GC) в Blink.

image

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

image

Но если вставить в тест явный вызов GC, то мы видим совсем другую картину — память держится в районе нуля и не утекает. Значит, утечек памяти у нас нет, а проблема связана с особенностями работы сборщика. Продолжаем копать. Запускаем отладчик и видим, что сборщик мусора обошел около 500 тысяч объектов! Такое количество объектов не могло не повлиять на производительность. Но откуда они взялись?

И здесь нам понадобится небольшой флешбек про устройство сборщика мусора в Blink. Он удаляет мертвые объекты, но не перемещает живые, что позволяет оперировать «голыми» указателями в локальных переменных в коде C++. Этот паттерн активно используется в Blink. Но он имеет и свою цену — при сборе мусора приходится сканировать стек потока, и если там обнаруживается что-то похожее на указатель на объект из кучи (heap), то считать объект и всё, на что он ссылается прямо или косвенно, живыми. Это приводит к тому, что некоторые фактически недоступные и поэтому «мертвые» объекты идентифицируются как живые. Поэтому такая форма сбора мусора ещё называется консервативной.

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

Что же может быть такого в стеке, что удерживает 500 тысяч объектов? Ставим точку остановки в функцию добавления объектов — в числе прочего видим там подозрительное:

blink::TraceTrait<blink::HeapHashTableBacking<WTF::HashTable<blink::WeakMember…

Ссылка на хэш-таблицу — вероятный подозреваемый! Проверяем гипотезу, пропуская добавление этой ссылки. Проблема исчезла. Отлично, мы стали ещё на шаг ближе к разгадке.

Вспоминаем другую особенность сборщика мусора в Blink: если он видит указатель на внутренности хэш-таблицы, то считает это признаком продолжающейся итерации по таблице, а значит, считает все ссылки этой таблицы полезными и продолжает их обходить. В нашем случае вхолостую. Но какая же функция является источником этой ссылки?

Продвигаемся на несколько кадров стека выше, берем текущую позицию сканера, смотрим, в кадр стека какой функции она попадает. Это функция с названием ScheduleGCIfNeeded. Казалось бы, вот он виновник, но… смотрим на исходный код функции и видим, что никаких хэш-таблиц там и в помине нет. Тем более что это уже часть собственно сборщика мусора, и ссылаться на объекты из кучи Blink ей просто незачем. Откуда же тогда взялась эта «плохая» ссылка?

Ставим брейкпойнт на изменение ячейки памяти, в которой нашли ссылку на хэш-таблицу. Видим, что пишет туда одна из внутренних функций под названием V8PerIsolateData::AddActiveScriptWrappable. Там происходит добавление создаваемых HTML-элементов некоторых типов, в том числе input, в единую хэш-таблицу active_script_wrappables_. Эта таблица нужна для предотвращения удаления элементов, на которые нет больше ссылок из Javascript или дерева DOM, но которые связаны с какой-либо внешней активностью, которая, например, может генерировать события.

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

Но как значение из стека одной функции попало в стек другой?!

Вспоминаем про 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 внутри функции, который выводит в лог дополнительную информацию.

Но почему в таблице active_script_wrappable_ оказались сотни тысяч элементов? Почему ухудшение производительности наблюдается только на тесте jQuery? Ответ на оба вопроса один — в данном конкретном тесте, на каждое изменение (вроде отметки в чекбоксе) весь UI пересоздается полностью. Тест плодит элементы, которые почти сразу превращаются в мусор. Остальные тесты в Speedometer более благоразумны и не создают лишних элементов, поэтому для них ухудшения производительности не наблюдается. Если вы разрабатываете веб-сервисы, то стоит это учесть, чтобы не создавать лишнюю работу браузеру.

Но почему проблема возникла только сейчас, если макрос VLOG был раньше? Точного ответа нет, но, скорее всего, при обновлении изменилось взаимное расположение элементов в стеке, из-за чего указатель на хэш-таблицу стал случайно доступен сканеру. По сути, мы выиграли в лотерею. Чтобы быстро закрыть «дыру» и восстановить производительность, мы удалили отладочный макрос VLOG. Для пользователей он бесполезен, а для собственных нужд диагностики мы всегда сможем включить его обратно. Также мы рассказали о нашем опыте другим разработчикам из Chromium. Ответ подтвердил наши опасения: это фундаментальная проблема консервативного сбора мусора в Blink, не имеющая системного решения.

Интересные ссылки

1. Если вам интересно узнать о других необычных буднях нашей группы, то напомним историю о чёрном прямоугольнике, которая привела к ускорению не только Яндекс.Браузера, но и всего проекта Chromium.

2. А ещё приглашаю послушать другие доклады на следующем мероприятии Яндекс.Изнутри 16 февраля, регистрация открыта, трансляция тоже будет.

Автор: Андрей Логвинов

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js