Как находить и устранять утечки памяти на примере Яндекс.Почты

от автора

На первый поверхностный взгляд, слова JavaScript и «утечка памяти» рядом стоять не могут. Настоящих утечек памяти в JS, конечно, не может быть, потому что процесс сборки мусора происходит автоматически и не может контролироваться из нашего кода. Выделить память под объект и забыть освободить невозможно. Но могут быть ситуации, связанные с ошибками в логике работы приложения, которые приводят к утечкам памяти другого рода. Например, забиндили обработчик, в котором что-то делаем с методами общего объекта и забыли его анбиндить. Или же посылаем письмо с большим телом и не очищаем тело даже после отправки.

image

Мы в Яндекс.Почте, сложном и массовом проекте, накопили заметный опыт в поиске и устранении таких утечек, и хотим им поделиться.

Немного матчасти

Как мы знаем, в JS есть объекты и примитивы. Свойства объектов могут ссылаться на другие объекты, либо содержать примитивы. JSHeap — это просто граф из связанных объектов. Корнем этого графа обычно является глобальный объект (GC Roots). У узлов в этом графе есть два типа размеров: shallow size и retained size. Shallow size — чистое количество памяти, занимаемое объектом. Retained size — это общее количество памяти, которое освободится при сборке мусора, если удалить объект и все ссылки на него от корня графа. На shallow size нужно смотреть только для массивов или примитивов (у которых собственно retained size === shallow size). Путь, по которому объект может быть получен от корня графа, называется retaining path. Объекты, для которых нельзя получить retaining path, являются мусором и удаляются при следующей его сборке.

Инструменты

Тут ситуация еще хуже, чем с профилированием рендеринга. Нормальные инструменты есть только в браузерах на Хромиуме (Chrome Developer Tools) и в будущем IE 11. Я буду использовать Chrome Developer Tools.

Условия профилирования

Опять же:

  • у вас не дожны быть запущены другие программы;
  • Хромиум должен быть запущен с дефолтными настройками (если используете какие-то экспериментальные возможности, сбросьте их в дефолт на странице chrome://flags);
  • открытой нужно оставить только одну вкладку с тестируемым сайтом (это ограничение связано с тем, что Хромиум может рендерить несколько вкладок в одном процессе, и, соответственно, в результатах профилирования будут лишние объекты);
  • не должны быть установлены плагины (или они должны быть выключены). Лучше всего профилировать в приватном режиме или запуская Хромиум с отдельным профилем через: <путь к Хромиуму> --user-data-dir=<путь к директории, где будет создан пустой профиль>.

Определяем кейсы, в которых могут быть утечки

Вообще, для этого можно попробовать пологировать информацию из window.performance.memory (только в Хромиум > 22) и посмотреть, какие действия совершали пользователи, у которых usedJSHeapSize (размер используемой памяти) приближается к totalJSHeapSize (общий размер JS-памяти выделенной на процесс), или же просто большое число в totalJSHeapSize. Но мне кажется, что эта штука показывает неверные числа, потому что после логирования у нас только 5% пользователей, после 7+ часов использования почты у которых, totalJSHeapSize приближается к 100 МБ.

У всех остальных эти значения либо не меняются со временем, либо равны нулю. Поэтому я решил поискать утечки вручную. Чтобы определить случаи, когда есть возможность утечки памяти, мы воспользуемся панелью timeline в Chrome Developer Tools, а конкретнее режимом Memory:

image

Открываем тестируемый сайт (в моем случае mail.yandex.ru) и начинаем запись (Cmd+E). Дальше несколько раз совершаем действие, которое, по нашему мнению, может привести к утечке памяти. Я просто нажимал на «Проверить» в тулбаре. Во время записи получаем нечто такое:

image

Тут важно обратить внимание на график в самой верхней панели и график со статистикой в панели «counters». На верхнем графике мы видим, как аллоцируется память и, собственно, размер JSHeap в разный момент времени. Вообще рост горочкой — это нормально, потому что еще не было сборки мусора. Она будет со временем, и тогда значение занимаемой памяти должно вернуться в норму (в нашем случае — 8,4 МБ). Можно не ждать GC и принудительно вызвать сборку мусора, если нажать на ведерко в левой нижней части. В панели «counters» есть три показателя:

  • Document Count — количество html документов (сюда входят и фреймы);
  • Dom Node Count — количество ДОМ нод;
  • Event Listener Count — количество обработчиков событий,

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

Находим причину утечки

Ок, мы нашли последовательность действий, которая приводит к утечке. Чтобы найти причину утечки воспользуемся панелью Profiles и пунктом Take Heap Snapshot в ней.

image

Нажав на кнопку Take Snapshot, можно получить снапшот JSHeap на текущий момент:

image

В левой колонке под названием снапшота (у нас Snapshot 1) указан общий размер памяти занимаемой живыми объектами — такими, у которых есть retaining path. Только живыми, потому что после каждого нажатия на эту кнопку первым делом вызывается сборщик мусора. В панели, занимающей основную часть правой части скриншота, можно просматривать сами объекты, размеры занимаемой ими памяти (абсолютные значения в байтах или же процент от общего размера снапшота) и другую полезную информацию. По умолчанию в этой панели объекты показываются в режиме Summary, где они сгруппированы по имени своего конструктора. Колонка Distance — количество ссылок до этого объекта от корня (GC Roots). Objects Count — количество объектов с этим конструктором.

Если название конструктора в скобках, то это внутренний тип объектов или примитив (за исключением array). В принципе, на большинство объектов с конструктором в скобках можно не обращать внимание ("(compiled code)", "(closure)", "(system)"), за исключением, пожалуй, "(string)" и "(array)" — и то только если у них большой shallow size. Представить содержимое снапшота можно и в других режимах:

  • Comparison — сравнивает два снапшота и показывает только те объекты, которые изменились.
  • Containment — показывает весь граф в виде дерева с глобальными объектами. Удобен для обнаружения неиспользуемых ДОМ нод (Detached DOM tree).
  • Dominators — Показывает доминаторы (объекты, которые есть в как можно большем числе retaining path’ей).

В режиме Summary для удобства можно фильтровать объекты по имени конструктора через строку поиска наверху. Если выбрать какой-то объект из группы, внизу можно увидеть retaining path в виде дерева:

image

Серым указаны айдишники объектов. Если объект подсвечен желтым, значит, где-то есть ссылка на него, которая удерживает его от сборки мусора. Если объект подсвечен красным — это ДОМ нода, которая была задетачена, но на нее осталась ссылка из JS. На многие объекты можно навести мышкой и появится дополнительная информация в желтом бабле (особенно это полезно для функций и ДОМ нод, потому что для них можно узнать тело функции и атрибуты ноды).

Техника трех снапшотов

У нас есть кейс в котором по таймлайну мы заметили утечку:

  • Зайти в инбокс.
  • Прокрутить немного скролл, чтобы появился фиксированный тулбар.
  • Несколько раз нажать на «Проверить».

Чтобы найти объекты, которые не удаляются, мы воспользуемся техникой трех снапшотов. Я буду это делать на девелоперской версии почты, чтобы иметь необфусцированные имена свойств и переменных.

  • Делаем первый снапшот перед действиями из кейса (он нужен для базовой линии). Иногда перед ним нужно также совершить некоторые разогревочные действия. Например, если у нас кейс связан со страницей написания письма, то нужно сначала зайти на неё (чтобы подгрузились и выполнились все необходимые модули).
  • Повторяем несколько раз действия из кейса (лучше повторять нечетное количество раз, чтобы можно было легче определить текущие объекты при анализе снапшота) и делаем второй снапшот.
  • Повторяем снова эти же действия столько же раз и делаем третий снапшот.
  • Дальше выбираем третий снапшот и в режиме Summary в селекте внизу выделяем «Objects allocated between Snapshots 1 and 2» (или 2 и 3 — как угодно).

image

Вот мы и получили все утекшие объекты. Отсортируем результаты по колонке Objects Count:

image

В топе после объектов с системными конструкторами и массивов кэшированных писем, видим какие-то подозрительные дивы, спаны, ссылки и инпуты. И их ровно 7 — именно столько раз я нажимал на кнопку «Проверить». Если раскрыть HTMLInputElement, то мы увидим, что все 7 объектов — это задетаченные инпуты с классом b-mail-dropdown__search__input, на которые просто остались ссылки JS:

image

На скриншоте не видно, но, чтобы узнать класс, я просто навел курсор на один из инпутов. По retaining path можно быстро понять, что это инпут из дропдауна (первый HTMLDivElement имеет класс b-mail-dropdown), на который в свою очередь есть ссылка в обработчике какого-то события (можно понять по стандартной jQuery структуре $ — cache — [..] — handle — elem — наш элемент). Есть еще проблема с лоадером в тулбаре — див с лоадером не очищается после сборки мусора.

Фиксим утечку

Чтобы это поправить, для начала найдем упоминания b-mail-dropdown__search__input в JS-коде нашего проекта. Находим в двух файлах «mail/blocks/folders-actions/folders-actions.js» и «blocks/labels-actions/labels-actions.js». Ага, именно те попапы с фильтрами папок и меток. В folders-actions есть обработчик у document на b-mail-dropdown-disabled, который внутри ссылается на jquery-объект дропдауна. Еще есть Jane.eventaction-move-status.change в обработчике которого вызвается метод _toggle, который внутри тоже ссылается на jquery-объект дропдауна. Для избавления от утечки, просто анбиндим эти обработчики в onhtmldestroy и зануляем ссылки на this.$searchInput и this.$dropdown:

Block.FoldersActions.prototype.onhtmldestroy = function() {      $(document).off('click.newFolderClick')         .off('b-mail-dropdown-disabled', this._dropdownHideHandler);     Jane.events.unbind('action-move-status.change', this._moveStatusHandler);      if (this.$searchInput) {         this.$searchInput.off();         this.$searchInput = null;     }      if (this.$dropdown) {         this.$dropdown.off();         this.$dropdown = null;     }  };

Делаем тоже самое в labels-actions. После проверки все задетаченные дивы, ссылки и инпуты из дропдауна пропадают. А с лоадером в тулбаре все немного по-другому. В «neo2/js/components/loader.js» есть такой код:

 var $toolbarSpinner = $('<div class="b-toolbar__spinner"/>'); var toolbarLoaderActive = false;      Jane.Loader.createToolbarLoader = function(actionOpts, toolbarNode) {         var $spinner = $toolbarSpinner.clone();         var $toolbarBtn = $(actionOpts.event && actionOpts.event.currentTar;         ...     }

Он находится в самовызываемой функции. Когда нам надо добавить лоадер в тулбар мы вызываем Jane.Loader.createToolbarLoader. Он клонирует $toolbarSpinner и вставляет клон в ДОМ. Как уже можно догадаться, сам $toolbarSpinner так и остается задетаченным и никогда не очищается. Это можно исправить, если вместо клонирования просто заново создавать элемент спиннера.

Подобным образом я устранил еще несколько утечек (в композе, парочку в трипейне при просмотре письма, одну в настройках). Еще я удалил Jane.Page.Log, который сохранял параметры при каждом ране или вызове экшена, но ничего с нимим потом не делал. И ограничил количество записей в объекте Logger xiva. Это все потому, что при отправке писем туда добавлялись объекты со всеми параметрами отправляемого письма (если письма с большим телом — это потенциальная утечка). Также я стал удалять body из параметров после отправки письма.

Record Heap Allocations

Чтобы упростить нахождение утечек, в Хромиум, начиная с 29 версии, введен дополнительный пункт в панели «Profiles» — «Record Heap Allocations». При нажатии на Start он начинает непрерывно раз в N секунд делать снапшот и сравнивать его с предыдущим, а в верхней панели столбиками показывать отношение очищенных объектов к живым. Живые — синие, очищенные — сервые. После остановки записи можно выбрать любой интервал времени и в нижней панели посмотреть на объекты в привычном виде (как после Take Snapshot).

image

Полезные ресурсы

ссылка на оригинал статьи http://habrahabr.ru/company/yandex/blog/195198/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *