[Перевод] Борьба с утечками памяти в веб-приложениях

Когда мы перешли от разработки веб-сайтов, страницы которых формируются на сервере, к созданию одностраничных веб-приложений, которые рендерятся на клиенте, мы приняли определённые правила игры. Одно из них — аккуратное обращение с ресурсами на устройстве пользователя. Это значит — не блокировать главный поток, не «раскручивать» вентилятор ноутбука, не сажать батарею телефона. Мы обменяли улучшение интерактивности веб-проектов, и то, что их поведение стало больше похоже на поведение обычных приложений, на новый класс проблем, которых не существовало в мире серверного рендеринга.

wv_cnlo46dgs8op0yzhybzgkpw8.jpeg
Одна из таких проблем — утечки памяти. Плохо сделанное одностраничное приложение может легко сожрать мегабайты или даже гигабайты памяти. Оно способно занимать всё больше и больше ресурсов даже тогда, когда тихо сидит на фоновой вкладке. Страница такого приложения, после захвата им непомерного количества ресурсов, может начать сильно «тормозить». Кроме того, браузер способен просто завершить работу вкладки и сказать пользователю: «Что-то пошло не так».

34be9dda87ca7f35d70a1a934816ce78.png


Что-то пошло не так

Конечно, сайты, которые рендерятся на сервере, тоже могут страдать от проблемы утечки памяти. Но тут речь идёт о серверной памяти. При этом весьма маловероятно то, что такие приложения вызовут утечку памяти на клиенте, так как браузер очищает память после каждого перехода пользователя между страницами.

Тема утечек памяти не очень хорошо освещена в публикациях по веб-разработке. И, несмотря на это, я почти уверен в том, что большинство нетривиальных одностраничных приложений страдает от утечек памяти — если только команды, которые ими занимаются, не имеют надёжных инструментов для обнаружения и исправления этой проблемы. Дело тут в том, что в JavaScript крайне просто случайно выделить некий объём памяти, а потом просто забыть эту память освободить.

Автор статьи, перевод которой мы сегодня публикуем, собирается поделиться с читателями своим опытом по борьбе с утечками памяти в веб-приложениях, а также хочет привести примеры их эффективного обнаружения.

Почему об этом так мало пишут?


Для начала хочу поговорить о том, почему об утечках памяти так мало пишут. Полагаю, тут можно обнаружить несколько причин:

  • Недостаток жалоб от пользователей: большинство пользователей не занято плотным наблюдением за диспетчером задач во время просмотра веб-страниц. Обычно разработчик не сталкивается с жалобами пользователей до тех пор, пока утечка памяти не окажется настолько серьёзной, что вызовет невозможность работы или замедление приложения.
  • Недостаточное количество данных: команда разработчиков Chrome не снабжает веб-программистов данными о том, сколько памяти используют реальные сайты. Сами разработчики сайтов тоже редко измеряют этот показатель своих проектов.
  • Нехватка инструментов: существующие инструменты всё ещё не позволяют легко выявлять или исправлять утечки памяти.
  • Недостаточный уровень внимания к проблеме утечек памяти: браузеры хорошо показывают себя в деле «убийства» слишком жадных до памяти вкладок. К тому же, пользователи склонны винить в проблемах, вызываемых утечками памяти, браузеры, а не веб-сайты.


Анатомия утечки памяти


Современные библиотеки и фреймворки для разработки веб-приложений, такие, как React, Vue и Svelte, используют компонентную модель приложения. В рамках этой модели самый распространённый способ вызова утечки памяти выглядит примерно так:

window.addEventListener('message', this.onMessage.bind(this));


Вот и всё. Это — всё, что нужно для того, чтобы «оснастить» проект утечкой памяти. Для этого достаточно вызвать метод addEventListener какого-нибудь глобального объекта (вроде window, или , или ещё чего-то подобного), а потом, в ходе отмонтирования компонента, забыть удалить прослушиватель события с помощью метода removeEventListener.

Но последствия подобного ещё хуже, так как происходит утечка целого компонента. Это происходит из-за того, что метод this.onMessage привязан к this. Вместе с этим компонентом происходит и утечка его дочерних компонентов. Весьма вероятно и то, что «утекут» и все узлы DOM, связанные с этим компонентом. Ситуация, в результате, может очень быстро выйти из-под контроля, приведя к очень плохим последствиям.

Вот как решить эту проблему:

// Фаза монтирования компонента
this.onMessage = this.onMessage.bind(this);
window.addEventListener('message', this.onMessage);
 
// Фаза отмонтирования компонента
window.removeEventListener('message', this.onMessage);


Ситуации, в которых чаще всего возникают утечки памяти


Опыт подсказывает мне, что утечки памяти чаще всего случаются при использовании следующих API:

  1. Метод addEventListener. Это — то место, где утечки памяти встречаются чаще всего. Для решения проблемы достаточно в нужный момент вызывать removeEventListener.
  2. Функции setTimeout и setInterval. Если создан таймер, который регулярно срабатывает через некий временной интервал (например, каждые 30 секунд), это значит, что после того, как он будет уже не нужен, его необходимо остановить, воспользовавшись командой clearTimeout или clearInterval. Таймер, созданный с помощью setTimeout, может «утечь» в том случае, если его настроили так, чтобы он работал как setInterval-таймер. То есть, если в переданном setTimeout коллбэке планируют следующий вызов того же коллбэка.
  3. API IntersectionObserver, ResizeObserver, MutationObserver и прочие подобные. Эти средства, достаточно новые, очень удобны. Но их использование вполне может привести к утечкам памяти. Если создать один из объектов-наблюдателей в компоненте и прикрепить его к некоему глобальному элементу, это значит, что после того, как соответствующий объект окажется ненужным, необходимо очистить ресурсы, воспользовавшись методом disconnect соответствующего объекта. Обратите внимание на то, что если к узлу DOM применяется процедура сборки мусора, то подобной обработке будут подвергнуты и связанные с ним прослушиватели событий, и объекты-наблюдатели. Поэтому обычно нужно заботиться лишь о тех объекта-наблюдателях, которые прикреплены к глобальным элементам. Например — к , к document, к вездесущим header и footer, и к прочим подобным объектам.
  4. Promise-объекты, наблюдаемые объекты, генераторы событий и другие сущности того же рода. В любой модели, использующей прослушиватели неких событий, могут происходить утечки памяти — в тех случаях, когда подписки на события забывают очищать. Промис, например, может «утечь» в том случае, если он никогда не разрешается и не отклоняется. В такой ситуации «утекают» и связанные с ним .then()-коллбэки.
  5. Хранилища, представленные глобальными объектами. При использовании для управления состоянием приложения чего-то вроде Redux, хранилище состояния представлено глобальным объектом. В результате, если обходиться с таким хранилищем небрежно, из него не будут удаляться ненужные данные, в результате чего его размер будет постоянно увеличиваться.
  6. Бесконечный рост DOM. Если на странице реализован бесконечный скроллинг без использования виртуализации, это значит, что количество узлов DOM этой страницы способно безгранично увеличиваться.


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

Идентификация утечек памяти


Теперь мы перешли к решению непростой задачи по идентификации утечек памяти. Начну с того, что я не считаю, что какой-либо из существующих инструментов очень хорошо для этого подходит. Я пробовал инструменты анализа памяти Firefox, пробовал средства из Edge и IE. Испытывал даже Windows Performance Analyzer. Но лучшими из подобных инструментов всё ещё остаются Инструменты разработчика Chrome. Правда, и в этих инструментах есть множество «острых углов», о которых стоит знать.

Среди инструментов, которые даёт разработчику Chrome, нас больше всего интересует профилировщик Heap snapshot с вкладки Memory, который позволяет создавать снимки кучи. В Chrome есть и другие средства для анализа памяти, но я не смог извлечь из них особую пользу в деле выявления утечек памяти.

e7de4263abd59eecce420c4bcbfc55ea.png


Инструмент Heap snapshot позволяет делать снимки памяти главного потока, веб-воркеров или элементов iframe

Если окно инструментов Chrome имеет вид, подобный тому, который показан на предыдущем рисунке, то при нажатии на кнопку Take snapshot происходит захват информации обо всех объектах, находящихся в памяти выбранной виртуальной машины JavaScript исследуемой страницы. Сюда входят объекты, ссылки на которые есть в window, объекты, на которые ссылаются коллбэки, использованные при вызове setInterval, и так далее. Снимок памяти можно воспринимать как «застывшее мгновение» работы исследуемой сущности, представляющее сведения обо всей памяти, используемой этой сущностью.

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

92d7764c7fbe5d561032c3bc092e1541.png


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

Правда, Heap snapshot — далеко не идеальный инструмент. У него есть некоторые ограничения, о которых стоит знать:

  1. Даже если щёлкнуть по небольшой кнопке на панели Memory, запускающей сборку мусора (Collect garbage), то, чтобы быть уверенным в том, что память и правда очищена, может понадобиться сделать несколько последовательных снимков. Мне обычно хватает трёх снимков. Тут стоит ориентироваться на общий размер каждого снимка — он, в итоге, должен стабилизироваться.
  2. Если у вас имеются веб-воркеры, сервис-воркеры, элементы iframe, общие воркеры и прочие подобные механизмы, тогда учитывайте то, что занимаемая ими память не отображается при создании снимка кучи. Дело в том, что они работают в собственных виртуальных машинах JavaScript. Если нужно — можно делать снимки и их памяти, но тут стоит внимательно следить за тем, что именно вы анализируете.
  3. Иногда инструмент для создания снимков памяти очень медленно работает или попросту «вылетает». В таких случаях достаточно закрыть соответствующую вкладку браузера и начать весь процесс заново.


В этот момент, если ваше приложение является достаточно сложным, вы, возможно, при сравнении снимков, заметите множество «утекающих» объектов. Здесь ситуация несколько осложняется, так как то, что можно принять за утечку памяти, не всегда является таковой. Многое из того, что вызывает подозрения, представляет собой всего лишь обычные процессы работы с объектами. Память, занимаемая некоторыми объектами, очищается для размещения в этой памяти других объектов, что-то сбрасывается в кэш, причём так, что соответствующая память очищается не сразу, и так далее.

Пробиваемся сквозь информационный шум


Я обнаружил, что лучший способ пробиться сквозь информационный шум — это многократное повторение действий, которые, как предполагается, вызывают утечку памяти. Например, вместо того, чтобы лишь один раз открыть и закрыть модальное окно после захвата первого снимка, это можно сделать 7 раз. Почему 7? Да хотя бы потому, что 7 — это заметное простое число. Затем надо сделать второй снимок и, сравнив его с первым, выяснить, «утёк» ли некий объект 7 раз (или 14 раз, или 21 раз).

2a7ac2243dc82f0e7887b23098e7c893.png


Сравнение снимков кучи. Обратите внимание на то, что мы сравниваем снимок №3 со снимком №6. Дело в том, что я сделал три снимка подряд для того, чтобы Chrome провёл бы больше сеансов сборки мусора. Кроме того, обратите внимание на то, что некоторые объекты «утекли» по 7 раз

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

Сейчас у вас может возникнуть вопрос о том, почему вы должны обращать особое внимание на количество объектов, а не на общий объём памяти. Тут можно сказать, что мы интуитивно стремимся к тому, чтобы уменьшить объём «утекающей» памяти. В этой связи можно подумать о том, что следовало бы следить за общим объёмом используемой памяти. Но такой подход, по одной важной причине, подходит нам не особенно хорошо.

Если нечто «утекает», то происходит это из-за того, что (пересказывая Джо Армстронга) вам нужен банан, но вы, в итоге, получаете банан, гориллу, которая его держит, и ещё, в придачу, все джунгли. Если ориентироваться на общий объём памяти, то это будет то же самое, что «измерять» джунгли, а не интересующий нас банан.

7e8af8847ab7e8689e1638ac218582d6.jpg


Горилла ест банан

Теперь вернёмся к вышеприведённому примеру с addEventListener. Источник утечки — это прослушиватель события, который ссылается на функцию. А эта функция, в свою очередь, ссылается на компонент, который, возможно, хранит ссылки на кучу всякого добра вроде массивов, строк и объектов.

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

В результате, если сортировать записи по количеству «утёкших» объектов, то можно будет заметить 7 прослушивателей событий. И, возможно, 7 компонентов, и 14 подкомпонентов, и, может, ещё нечто подобное. Это число 7 должно выделяться из общей картины, так как это, всё же, довольно заметное и необычное число. При этом неважно то, сколько именно раз будет повторено подозрительное действие. При исследовании снимков, если подозрения оправданы, будет зафиксировано именно столько «утёкших» объектов. Именно так можно быстро обнаружить источник утечки памяти.

Анализ дерева ссылок


Инструмент для создания снимков памяти даёт возможность просматривать «цепочки ссылок», которые помогают узнать о том, на какие объекты ссылаются другие объекты. Это — то, что позволяет приложению функционировать. Анализируя подобные «цепочки» или «деревья» ссылок, можно выяснить то, где именно была выделена память под «утекающий» объект.

7bf77a07dd2d15e479f77c2279b9942d.png


Цепочка ссылок позволяет выяснить то, какой объект ссылается на «утекающий» объект. Читая эти цепочки, нужно учитывать то, что объекты, расположенные в них ниже, ссылаются на объекты, расположенные выше

В вышеприведённом примере имеется переменная, называемая someObject, ссылка на которую имеется в замыкании (context), на которое ссылается прослушиватель событий. Если щёлкнуть по ссылке, ведущей к исходному коду, будет выведен довольно-таки понятный текст программы:

class SomeObject () { /* ... */ }
 
const someObject = new SomeObject();
const onMessage = () => { /* ... */ };
window.addEventListener('message', onMessage);


Если сопоставить этот код с предыдущим рисунком, то окажется, что context с рисунка — это замыкание onMessage, которое ссылается на someObject. Это искусственный пример. Настоящие утечки памяти могут быть гораздо менее очевидными.

Стоит отметить, что инструмент для создания снимков кучи имеет некоторые ограничения:

  1. Если сохраняют файл снимка, а потом снова его загружают, теряются ссылки на файлы с кодом. То есть, например, загрузив снимок, нельзя будет узнать о том, что код замыкания прослушивателя событий находится в строке 22 файла foo.js. Так как эта информация крайне важна, сохранять файлы снимков кучи, или, например, их кому-то передавать, дело почти бесполезное.
  2. Если в коде используется объект WeakMap, то Chrome покажет соответствующие ссылки даже в том случае, если они не имеют никакого значения. Память, занятая такими объектами, будет освобождена сразу после того, как будет очищена память, занятая другими объектами. Поэтому сведения о WeakMap — это всего лишь информационный шум.
  3. Chrome классифицирует объекты, опираясь на их прототипы. В результате, чем больше в коде именованных классов и функций, и чем меньше анонимных объектов, тем легче будет выяснить то, что именно вызывает утечку памяти. В качестве примера давайте представим ситуацию, в которой причина утечки идентифицируется как object, а не как EventListener. Так как object — чрезвычайно общее определение некоей сущности, маловероятно то, что удастся увидеть, как «утекли» в точности 7 таких сущностей.


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

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

Автоматизация анализа утечек памяти


Я хочу начать этот раздел с того, что мне не удалось найти хорошего подхода к автоматизации обнаружения утечек памяти. В Chrome есть собственный API performance.memory, но, из соображений приватности, он не позволяет собирать достаточно детальные данные. В результате этот API не получится использовать в продакшне для выявления утечек. Рабочая группа W3C Web Performance обсуждала раньше инструменты для работы с памятью, но её членам ещё предстоит договориться о новом стандарте, предназначенном для замены этого API.

В тестовых окружениях можно повысить детальность данных, выдаваемых performance.memory, используя флаг Chrome --enable-precise-memory-info. Снимки кучи ещё можно создавать, обращаясь к собственной команде Chromedriver: takeHeapSnapshot. У этой команды те же ограничения, которые мы уже обсуждали. Вероятно, если вы будете пользоваться этой командой, то, по вышеописанным причинам, имеет смысл вызывать её три раза, после чего брать лишь то, что получено в результате её последнего вызова.

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

В инструментах разработчика Chrome, кроме того, можно использовать собственный API getEventListeners для того, чтобы выяснить, какие именно прослушиватели событий прикреплены к конкретному элементу. Эта команда, правда, доступна лишь из панели инструментов разработчика.

Хочу добавить, что Матиас Байненс сообщил мне об ещё одном полезном API инструментов Chrome. Это — queryObjects. С его помощью можно получить сведения обо всех объектах, созданных с использованием некоего конструктора. Вот хороший материал на эту тему, посвящённый автоматизации поиска утечек памяти в Puppeteer.

Итоги


Сфера поиска и исправления утечек памяти в веб-приложениях всё ещё находится на заре своего развития. Здесь я рассказал о некоторых методиках, которые, в моём случае, хорошо себя показали. Но следует признать, что применение этих методик всё ещё сопряжено с определёнными трудностями и с большими затратами времени.

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

JavaScript — это язык, который обеспечивает безопасную работу с памятью. Поэтому есть некая ирония в том, как легко в веб-приложениях случаются утечки памяти. Правда, это отчасти так из-за особенностей устройства пользовательских интерфейсов. Нужно прослушивать множество событий: события мыши, прокрутки, клавиатуры. Применение всех этих паттернов легко может привести к появлению утечек памяти. Но, стремясь к тому, чтобы наши веб-приложения экономно расходовали бы память, мы можем повысить их производительность, уберечь их от «падений». Кроме того, мы тем самым демонстрируем уважительное отношение к ограничениям ресурсов устройств пользователей.

Уважаемые читатели! Встречались ли вы с утечками памяти в своих веб-проектах?

1ba550d25e8846ce8805de564da6aa63.png

© Habrahabr.ru