- PVSM.RU - https://www.pvsm.ru -
Когда мы перешли от разработки веб-сайтов, страницы которых формируются на сервере, к созданию одностраничных веб-приложений, которые рендерятся на клиенте, мы приняли определённые правила игры. Одно из них — аккуратное обращение с ресурсами на устройстве пользователя. Это значит — не блокировать главный поток, не «раскручивать» вентилятор ноутбука, не сажать батарею телефона. Мы обменяли улучшение интерактивности веб-проектов, и то, что их поведение стало больше похоже на поведение обычных приложений, на новый класс проблем, которых не существовало в мире серверного рендеринга.
[1]
Одна из таких проблем — утечки памяти. Плохо сделанное одностраничное приложение может легко сожрать мегабайты или даже гигабайты памяти. Оно способно занимать всё больше и больше ресурсов даже тогда, когда тихо сидит на фоновой вкладке. Страница такого приложения, после захвата им непомерного количества ресурсов, может начать сильно «тормозить». Кроме того, браузер способен просто завершить работу вкладки и сказать пользователю: «Что-то пошло не так».
Что-то пошло не так
Конечно, сайты, которые рендерятся на сервере, тоже могут страдать от проблемы утечки памяти. Но тут речь идёт о серверной памяти. При этом весьма маловероятно то, что такие приложения вызовут утечку памяти на клиенте, так как браузер очищает память после каждого перехода пользователя между страницами.
Тема утечек памяти не очень хорошо освещена в публикациях по веб-разработке. И, несмотря на это, я почти уверен в том, что большинство нетривиальных одностраничных приложений страдает от утечек памяти — если только команды, которые ими занимаются, не имеют надёжных инструментов для обнаружения и исправления этой проблемы. Дело тут в том, что в JavaScript крайне просто случайно выделить некий объём памяти, а потом просто забыть эту память освободить.
Автор статьи, перевод которой мы сегодня публикуем, собирается поделиться с читателями своим опытом по борьбе с утечками памяти в веб-приложениях, а также хочет привести примеры их эффективного обнаружения.
Для начала хочу поговорить о том, почему об утечках памяти так мало пишут. Полагаю, тут можно обнаружить несколько причин:
Современные библиотеки и фреймворки для разработки веб-приложений, такие, как React, Vue и Svelte, используют компонентную модель приложения. В рамках этой модели самый распространённый способ вызова утечки памяти выглядит примерно так:
window.addEventListener('message', this.onMessage.bind(this));
Вот и всё. Это — всё, что нужно для того, чтобы «оснастить» проект утечкой памяти. Для этого достаточно вызвать метод addEventListener [3] какого-нибудь глобального объекта (вроде window
, или <body>
, или ещё чего-то подобного), а потом, в ходе отмонтирования компонента, забыть удалить прослушиватель события с помощью метода removeEventListener [4].
Но последствия подобного ещё хуже, так как происходит утечка целого компонента. Это происходит из-за того, что метод this.onMessage
привязан к this
. Вместе с этим компонентом происходит и утечка его дочерних компонентов. Весьма вероятно и то, что «утекут» и все узлы DOM, связанные с этим компонентом. Ситуация, в результате, может очень быстро выйти из-под контроля, приведя к очень плохим последствиям.
Вот как решить эту проблему:
// Фаза монтирования компонента
this.onMessage = this.onMessage.bind(this);
window.addEventListener('message', this.onMessage);
// Фаза отмонтирования компонента
window.removeEventListener('message', this.onMessage);
Опыт подсказывает мне, что утечки памяти чаще всего случаются при использовании следующих API:
addEventListener
. Это — то место, где утечки памяти встречаются чаще всего. Для решения проблемы достаточно в нужный момент вызывать removeEventListener
.setTimeout
, может «утечь» в том случае, если его настроили так, чтобы он работал как setInterval
-таймер. То есть, если в переданном setTimeout
коллбэке планируют следующий вызов того же коллбэка.disconnect
соответствующего объекта. Обратите внимание на то, что если к узлу DOM применяется процедура сборки мусора, то подобной обработке будут подвергнуты и связанные с ним прослушиватели событий, и объекты-наблюдатели. Поэтому обычно нужно заботиться лишь о тех объекта-наблюдателях, которые прикреплены к глобальным элементам. Например — к <body>
, к document
, к вездесущим header
и footer
, и к прочим подобным объектам..then()
-коллбэки.Выше мы рассмотрели ситуации, в которых утечки памяти случаются чаще всего, но, конечно, существуют и многие другие случаи, вызывающие интересующую нас проблему.
Теперь мы перешли к решению непростой задачи по идентификации утечек памяти. Начну с того, что я не считаю, что какой-либо из существующих инструментов очень хорошо для этого подходит. Я пробовал инструменты анализа памяти Firefox, пробовал средства из Edge и IE. Испытывал даже Windows Performance Analyzer. Но лучшими из подобных инструментов всё ещё остаются Инструменты разработчика Chrome. Правда, и в этих инструментах есть множество «острых углов», о которых стоит знать.
Среди инструментов, которые даёт разработчику Chrome, нас больше всего интересует профилировщик Heap snapshot
с вкладки Memory
, который позволяет создавать снимки кучи. В Chrome есть и другие средства для анализа памяти, но я не смог извлечь из них особую пользу в деле выявления утечек памяти.
Инструмент Heap snapshot позволяет делать снимки памяти главного потока, веб-воркеров или элементов iframe
Если окно инструментов Chrome имеет вид, подобный тому, который показан на предыдущем рисунке, то при нажатии на кнопку Take snapshot
происходит захват информации обо всех объектах, находящихся в памяти выбранной виртуальной машины JavaScript исследуемой страницы. Сюда входят объекты, ссылки на которые есть в window
, объекты, на которые ссылаются коллбэки, использованные при вызове setInterval
, и так далее. Снимок памяти можно воспринимать как «застывшее мгновение» работы исследуемой сущности, представляющее сведения обо всей памяти, используемой этой сущностью.
После того, как сделан снимок, мы приходим к следующему шагу поиска утечек. Он заключается в воспроизведении сценария, в котором, как полагает разработчик, может появиться утечка памяти. Например — это открытие и закрытие некоего модального окна. После того, как подобное окно закрыто, ожидается, что объём выделенной памяти вернётся к уровню, который имелся до открытия окна. Поэтому делают ещё один снимок, а затем сравнивают его со снимком, сделанным ранее. Собственно говоря, сравнение снимков — это важнейшая интересующая нас возможность средства Heap snapshot
.
Делаем первый снимок, далее — выполняем действия, которые могут вызвать утечку памяти, а потом делаем ещё один снимок. Если утечки нет — размеры выделенной памяти будут равны
Правда, Heap snapshot
— далеко не идеальный инструмент. У него есть некоторые ограничения, о которых стоит знать:
Memory
, запускающей сборку мусора (Collect garbage
), то, чтобы быть уверенным в том, что память и правда очищена, может понадобиться сделать несколько последовательных снимков. Мне обычно хватает трёх снимков. Тут стоит ориентироваться на общий размер каждого снимка — он, в итоге, должен стабилизироваться.iframe
, общие воркеры и прочие подобные механизмы, тогда учитывайте то, что занимаемая ими память не отображается при создании снимка кучи. Дело в том, что они работают в собственных виртуальных машинах JavaScript. Если нужно — можно делать снимки и их памяти, но тут стоит внимательно следить за тем, что именно вы анализируете.В этот момент, если ваше приложение является достаточно сложным, вы, возможно, при сравнении снимков, заметите множество «утекающих» объектов. Здесь ситуация несколько осложняется, так как то, что можно принять за утечку памяти, не всегда является таковой. Многое из того, что вызывает подозрения, представляет собой всего лишь обычные процессы работы с объектами. Память, занимаемая некоторыми объектами, очищается для размещения в этой памяти других объектов, что-то сбрасывается в кэш, причём так, что соответствующая память очищается не сразу, и так далее.
Я обнаружил, что лучший способ пробиться сквозь информационный шум — это многократное повторение действий, которые, как предполагается, вызывают утечку памяти. Например, вместо того, чтобы лишь один раз открыть и закрыть модальное окно после захвата первого снимка, это можно сделать 7 раз. Почему 7? Да хотя бы потому, что 7 — это заметное простое число. Затем надо сделать второй снимок и, сравнив его с первым, выяснить, «утёк» ли некий объект 7 раз (или 14 раз, или 21 раз).
Сравнение снимков кучи. Обратите внимание на то, что мы сравниваем снимок №3 со снимком №6. Дело в том, что я сделал три снимка подряд для того, чтобы Chrome провёл бы больше сеансов сборки мусора. Кроме того, обратите внимание на то, что некоторые объекты «утекли» по 7 раз
Ещё один полезный приём заключается в том, чтобы, в самом начале исследования, до создания первого снимка, один раз выполнить процедуру, в ходе которой, как ожидается, возникает утечка памяти. Особенно это рекомендуется в том случае, если в проекте применяется разделение кода. В подобном случае весьма вероятно то, что при первом выполнении подозрительного действия будет произведена загрузка необходимых JavaScript-модулей, что отразится на объёме выделенной памяти.
Сейчас у вас может возникнуть вопрос о том, почему вы должны обращать особое внимание на количество объектов, а не на общий объём памяти. Тут можно сказать, что мы интуитивно стремимся к тому, чтобы уменьшить объём «утекающей» памяти. В этой связи можно подумать о том, что следовало бы следить за общим объёмом используемой памяти. Но такой подход, по одной важной причине, подходит нам не особенно хорошо.
Если нечто «утекает», то происходит это из-за того, что (пересказывая Джо Армстронга [17]) вам нужен банан, но вы, в итоге, получаете банан, гориллу, которая его держит, и ещё, в придачу, все джунгли. Если ориентироваться на общий объём памяти, то это будет то же самое, что «измерять» джунгли, а не интересующий нас банан.
Горилла ест банан
Теперь вернёмся к вышеприведённому примеру с addEventListener
. Источник утечки — это прослушиватель события, который ссылается на функцию. А эта функция, в свою очередь, ссылается на компонент, который, возможно, хранит ссылки на кучу всякого добра вроде массивов, строк и объектов.
Если анализировать разницу между снимками, сортируя сущности по объёму занимаемой ими памяти, это позволит увидеть множество массивов, строк, объектов, большинство из которых, скорее всего, не имеют отношения к утечке. А нам ведь надо найти тот самый прослушиватель событий, с которого всё началось. Он же, в сравнении с тем, на что он ссылается, занимает очень мало памяти. Для того чтобы исправить утечку, нужно найти банан, а не джунгли.
В результате, если сортировать записи по количеству «утёкших» объектов, то можно будет заметить 7 прослушивателей событий. И, возможно, 7 компонентов, и 14 подкомпонентов, и, может, ещё нечто подобное. Это число 7 должно выделяться из общей картины, так как это, всё же, довольно заметное и необычное число. При этом неважно то, сколько именно раз будет повторено подозрительное действие. При исследовании снимков, если подозрения оправданы, будет зафиксировано именно столько «утёкших» объектов. Именно так можно быстро обнаружить источник утечки памяти.
Инструмент для создания снимков памяти даёт возможность просматривать «цепочки ссылок», которые помогают узнать о том, на какие объекты ссылаются другие объекты. Это — то, что позволяет приложению функционировать. Анализируя подобные «цепочки» или «деревья» ссылок, можно выяснить то, где именно была выделена память под «утекающий» объект.
Цепочка ссылок позволяет выяснить то, какой объект ссылается на «утекающий» объект. Читая эти цепочки, нужно учитывать то, что объекты, расположенные в них ниже, ссылаются на объекты, расположенные выше
В вышеприведённом примере имеется переменная, называемая someObject
, ссылка на которую имеется в замыкании (context
), на которое ссылается прослушиватель событий. Если щёлкнуть по ссылке, ведущей к исходному коду, будет выведен довольно-таки понятный текст программы:
class SomeObject () { /* ... */ }
const someObject = new SomeObject();
const onMessage = () => { /* ... */ };
window.addEventListener('message', onMessage);
Если сопоставить этот код с предыдущим рисунком, то окажется, что context
с рисунка — это замыкание onMessage
, которое ссылается на someObject
. Это искусственный пример [18]. Настоящие утечки памяти могут быть гораздо менее очевидными.
Стоит отметить, что инструмент для создания снимков кучи имеет некоторые ограничения:
foo.js
. Так как эта информация крайне важна, сохранять файлы снимков кучи, или, например, их кому-то передавать, дело почти бесполезное.WeakMap
, то Chrome покажет соответствующие ссылки даже в том случае, если они не имеют никакого значения. Память, занятая такими объектами, будет освобождена сразу после того, как будет очищена память, занятая другими объектами. Поэтому сведения о WeakMap
— это всего лишь информационный шум.object
, а не как EventListener
. Так как object
— чрезвычайно общее определение некоей сущности, маловероятно то, что удастся увидеть, как «утекли» в точности 7 таких сущностей.Это — описание моей базовой стратегии по идентификации утечек памяти. Я успешно использовал эту методику для выявления десятков утечек.
Правда, надо сказать, что это руководство по поиску утечек памяти освещает лишь малую часть того, что происходит в реальности. Это лишь начало работы. Помимо этого нужно уметь обращаться с установкой точек останова, с логированием, с тестированием исправлений на предмет выяснения того, решают ли они проблему. И, к сожалению, всё это, по сути, выливается в серьёзные затраты времени.
Я хочу начать этот раздел с того, что мне не удалось найти хорошего подхода к автоматизации обнаружения утечек памяти. В Chrome есть собственный API performance.memory [19], но, из соображений приватности, он не позволяет [20] собирать достаточно детальные данные. В результате этот API не получится использовать в продакшне для выявления утечек. Рабочая группа W3C Web Performance обсуждала раньше инструменты [21] для работы с памятью, но её членам ещё предстоит договориться о новом стандарте, предназначенном для замены этого API.
В тестовых окружениях можно повысить детальность данных, выдаваемых performance.memory
, используя флаг Chrome --enable-precise-memory-info [22]. Снимки кучи ещё можно создавать, обращаясь к собственной команде Chromedriver :takeHeapSnapshot [23]. У этой команды те же ограничения, которые мы уже обсуждали. Вероятно, если вы будете пользоваться этой командой, то, по вышеописанным причинам, имеет смысл вызывать её три раза, после чего брать лишь то, что получено в результате её последнего вызова.
Так как прослушиватели событий — это самый распространённый источник утечек памяти, я расскажу ещё об одной используемой мной методике поиска утечек. Она заключается в создании обезьяньих патчей для API addEventListener
и removeEventListener
и в подсчёте ссылок для проверки того, что их количество возвращается к нулю. Вот [24] пример того, как это делается.
В инструментах разработчика Chrome, кроме того, можно использовать собственный API getEventListeners [25] для того, чтобы выяснить, какие именно прослушиватели событий прикреплены к конкретному элементу. Эта команда, правда, доступна лишь из панели инструментов разработчика.
Хочу добавить, что Матиас Байненс сообщил мне об ещё одном полезном API инструментов Chrome. Это — queryObjects [26]. С его помощью можно получить сведения обо всех объектах, созданных с использованием некоего конструктора. Вот [27] хороший материал на эту тему, посвящённый автоматизации поиска утечек памяти в Puppeteer.
Сфера поиска и исправления утечек памяти в веб-приложениях всё ещё находится на заре своего развития. Здесь я рассказал о некоторых методиках, которые, в моём случае, хорошо себя показали. Но следует признать, что применение этих методик всё ещё сопряжено с определёнными трудностями и с большими затратами времени.
Как и в случае с любыми проблемами, касающимися производительности, как говорится, щепотка загодя стоит пуда после. Возможно, кто-то сочтёт полезным подготовить соответствующие синтетические тесты, а не заниматься анализом утечки после того, как она уже произошла. А если речь идёт не об одной утечке, а о нескольких, то анализ проблемы может превратиться в нечто, подобное чистке лука: после того, как исправят одну проблему, обнаруживается другая, а потом этот процесс повторяется (и всё это время, как от лука, слёзы на глазах). Код-ревью тоже могут помочь выявить распространённые паттерны утечек. Но это — в том случае, если знать — куда смотреть.
JavaScript — это язык, который обеспечивает безопасную работу с памятью. Поэтому есть некая ирония в том, как легко в веб-приложениях случаются утечки памяти. Правда, это отчасти так из-за особенностей устройства пользовательских интерфейсов. Нужно прослушивать множество событий: события мыши, прокрутки, клавиатуры. Применение всех этих паттернов легко может привести к появлению утечек памяти. Но, стремясь к тому, чтобы наши веб-приложения экономно расходовали бы память, мы можем повысить их производительность, уберечь их от «падений». Кроме того, мы тем самым демонстрируем уважительное отношение к ограничениям ресурсов устройств пользователей.
Уважаемые читатели! Встречались ли вы с утечками памяти в своих веб-проектах?
Автор: ru_vds
Источник [28]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/348332
Ссылки в тексте:
[1] Image: https://habr.com/ru/company/ruvds/blog/490622/
[2] браузеры: https://www.google.com/search?hl=en&q=chrome%20memory%20hog
[3] addEventListener: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
[4] removeEventListener: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
[5] setTimeout: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout
[6] setInterval: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval
[7] clearTimeout: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/clearTimeout
[8] clearInterval: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/clearInterval
[9] IntersectionObserver: https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
[10] ResizeObserver: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
[11] MutationObserver: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
[12] Promise-объекты: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
[13] наблюдаемые объекты: https://rxjs.dev/guide/observable
[14] генераторы событий: https://nodejs.org/api/events.html#events_class_eventemitter
[15] Redux: https://redux.js.org/
[16] виртуализации: https://github.com/WICG/virtual-scroller#readme
[17] Джо Армстронга: https://www.johndcook.com/blog/2011/07/19/you-wanted-banana/
[18] искусственный пример: https://github.com/nolanlawson/pinafore/commit/de6ca2d85334ad5f657ddd0f335750b60afab895
[19] performance.memory: https://webplatform.github.io/docs/apis/timing/properties/memory/
[20] не позволяет: https://bugs.webkit.org/show_bug.cgi?id=80444
[21] инструменты: https://docs.google.com/document/d/1tFCEOMOUg4zmqeHNg1Xo11Xpdm7Bmxl5y98_ESLCLgM/edit
[22] --enable-precise-memory-info: https://github.com/paulirish/memory-stats.js/blob/master/README.md
[23] :takeHeapSnapshot: https://webdriver.io/docs/api/chromium.html#takeheapsnapshot
[24] Вот: https://github.com/nolanlawson/pinafore/blob/2edbd4746dfb5a7c894cb8861cf315c800a16393/tests/spyDomListeners.js
[25] getEventListeners: https://developers.google.com/web/tools/chrome-devtools/console/utilities#geteventlisteners
[26] queryObjects: https://developers.google.com/web/updates/2017/08/devtools-release-notes#query-objects
[27] Вот: https://media-codings.com/articles/automatically-detect-memory-leaks-with-puppeteer
[28] Источник: https://habr.com/ru/post/490622/?utm_source=habrahabr&utm_medium=rss&utm_campaign=490622
Нажмите здесь для печати.