Поиск утечек памяти в Chrome 66 стал гораздо удобней. DevTools теперь могут проводить трассировку, делать снапшоты DOM-объектов из C++, отображать все доступные DOM-объекты из JavaScript вместе со ссылками на них. Появление этих возможностей стало следствием нового механизма трассировки C++ в сборщике мусора V8.
Напомню, что стабильный Chrome сейчас (20.03.2018) имеет версию 65, поэтому чтобы подивиться на фичу, придётся установить одну из нестабильных сборок (например, Beta имеет версию 66, а Dev и Canary — 67).
Основы
Утечки памяти в сборке мусора возникают, когда ненужный уже объект не собирается из-за неумышленно добавленных ссылок из других объектов. Утечки памяти в веб-страницах зачастую возникают при взаимодействии JS-объектов и DOM-элементов.
Давайте посмотрим на игрушечный пример, показывающий утечку, возникающую, когда программист забывает убрать обработчик события. Обработчик ссылается на объекты, и их больше уже нельзя удалить. В частности, протекает окно iframe.
// Main window:
const iframe = document.createElement('iframe');
iframe.src = 'iframe.html';
document.body.appendChild(iframe);
iframe.addEventListener('load', function() {
const local_variable = iframe.contentWindow;
function leakingListener() {
// Do something with `local_variable`.
if (local_variable) {}
}
document.body.addEventListener('my-debug-event', leakingListener);
document.body.removeChild(iframe);
// BUG: forgot to unregister `leakingListener`.
});
Что ещё хуже, утекший iframe поддерживает живыми все свои JS-объекты.
// iframe.html:
class Leak {};
window.global_variable = new Leak();
Чтобы найти причину утечки, важно осознать понятие подвешенного пути (retaining path). Подвешенный путь — это цепочка объектов, которые мешают сборке утекающего объекта. Цепочка начинается от какого-то корневого объекта, вроде глобального объекта основного окна. Цепь заканчивается утекающим объектом. Все промежуточные объекты имеют прямую ссылку на следующий объект в цепи. Например, подвешенный путь объекта Leak
и этом ифрейме выглядит следующим образом:
Заметьте, что подвешенный путь проходит сквозь границу между JavaScript и DOM (они отмечены, соответственно, зелёным и красным цветом) целых два раза. JS-объекты живут в куче V8, а объекты DOM являются C++-объектами в Chrome.
DevTools heap snapshot
Теперь мы можем изучить подвешенный путь любого выбранного объекта с помощью снапшота кучи. При этом будут сохранены в точности все объекты, находящиеся в куче V8. Совсем недавно там хранились только очень примерные данные о C++ DOM объектах. Например, Chrome 65 показывает неполный подвешенный путь для объекта Leak
из предыдущего игрушечного примера:
Только первая строка достаточно точна: объект Leak
действительно хранится в global_variable
в окне ифрейма. Все остальные строки пытаются аппроксимировать настоящий путь, и это делает отладку утечки памяти весьма сложной.
Начиная с Chrome 66, DevTools трассирует C++ DOM объекты, и точно захватывает объекты и ссылки между ними. Эта фича основана на новом мощном механизме трассировки C++ объектов, который создавался для кросс-компонентной сборки мусора. В резульатате, пути в DevTools стали правильными!
Руководство к действию
Файл для экспериментов: https://ulan.github.io/misc/leak.html
Под капотом: кросс-компонентная трассировка
DOM-объекты управляются с помощью Blink — движка рендеринга, используемого в Chrome, отвечающего за трансляцию DOM в реальный текст и картинки на экране. Blink и его внутреняя реализация DOM написаны на C++ — и это значит, что DOM нельзя напрямую отразить в JavaScript. Вместо этого, объекты в DOM как бы делятся на две части: доступную из JS объект-обёртку, и C++-объект, являющийся представлением узла из DOM. Эти объекты содержат прямые ссылки друг на друга. Определение времени жизни и области владения компонентов, которые пересекают границы нескольких систем, в данном случае — Blink и V8, — довольно сложная задача, поскольку в ней частвуют стороны, которым нужно предварительно договориться, какие компоненты всё ещё живы, а какие стоило бы утилизировать.
В Chrome 56 и более старых версиях (например, до марта 2017), Chrome использовал механизм, называемый группировкой объектов (object grouping). Объекты связываются в одну группу, если принадлежат одному и тому же документу. Группа, и все её объекты, держатся живыми до тех пор, пока существует хоть один живой объект на конце другого подвешенного пути. Это имеет смысл в контексте узлов DOM, которые всегда связаны с содержащими их документами, формируя так называемые DOM-деревья. Но эта абстракция теряет все реальные подвешенные пути, что раньше очень осложняло отладку. Как только объекты переставали подходить под вышеописанный сценарий (например, замыкания в JavaScript, используемые как обработчики событий), реализовывать этот подход становилось затруднительно и приводило к багам, в которых JS-обёртки собирались раньше времени, что в свою очередь вело к их замене на пустые JS-обёртки с полной потерей всех свойств.
Начиная с Chrome 57, этот подход заменён на "кросс-компонентную трассировку" — механизм, который определяет живость объектов, трассируя их от JavaScript вплоть до C++ реализации в DOM, и по тому же пути назад. На стороне C++ реализована инкрементальная трассировка, которая создаёт write barriers для того, чтобы не скатиться в stop-the-world. Кросс-компонентное тестирование не только улучшает латентность, но и лучше аппроксимирует живость объектов на границе компонентов, и чинит несколько часто случающихся сценариев, которые раньше приводили к утечкам. Кроме того, благодаря этому, DevTools получили возможность делать снапшоты, которые действительно отражают состояние DOM.
Минутка рекламы. Как вы, наверное, знаете, мы делаем конференции. Ближайшая конференция про JavaScript — HolyJS 2018 Piter, которая пройдет 19-20 мая 2018 года в Санкт-Петербурге. Можно туда прийти, послушать доклады (какие доклады там бывают — описано в программе конференции), вживую пообщаться с практикующими экспертами JavaScript и фронтенда, разработчиками разных моднейших технологий. Короче, заходите, мы вас ждём!