Как стать автором
Поиск
Написать публикацию
Обновить

Как утекает память, если забыть отписаться от Observable

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров10K

...и как это обнаружить.

Многие, конечно, знают, что в Angular-сообществе принято трепетно следить за подписками на Observable, потому что это чревато утечками памяти. Но не все видели эти утечки в глаза и не встречались с их последствиями. Давайте смоделируем простую ситуацию по следам утечки, с которой недавно столкнулся я (первый раз).

Представим, что пользователи заявили, что после долгого использования нашего приложения, оно неожиданно вылетает и превращается в страницу “Опаньки”.

(хихикал с этой страницы ещё в далёком 2009)
(хихикал с этой страницы ещё в далёком 2009)

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

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

Моделируем ситуацию

Чтобы создать утечку, а затем её найти нам понадобится:

  • один Observable, живущий на всем протяжении работы приложения, например в рутовом сервисе. Именно от него мы и забудем отписаться;

  • компонент, который будет выполнять подписку и хранить копящиеся данные;

  • родительский компонент, который будет преключать компонент, упомянутый выше, чтобы его инстансы заполнили своими данными память.

Сервис

Создадим простой сервис, который запровайдим в root. Он будет работать от инжекта и до конца работы приложения. В нём будет просто Subject. Мы не будем ничего даже в него передавать.

import { Injectable } from "@angular/core";
import { Subject } from "rxjs";

@Injectable({ providedIn: "root" })
export class LifetimeService {
  readonly someObservable = new Subject<number>();
}

Компонент

Здесь нам нужно заинжектить описанный выше сервис, подписаться на его Subject и забыть от него отписаться. А также сохранить внутри какие-то данные. Быстрее всего будет создать какой-нибудь огромный файл JSON в папке public и загружать его при создании компонента (генерировать большой набор данных при создании компонента довольно долго). Красоты ради выведем в шаблон длину загруженного массива.

@Component({
  selector: 'app-data-carrier',
  template: `Data carrier (data length: {{ bigData?.length }})`,
  styles: [':host { border: 1px solid black; padding: 4px }'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DataCarrierComponent {
  readonly lifetimeService = inject(LifetimeService);
  readonly cd = inject(ChangeDetectorRef);
  readonly httpClient = inject(HttpClient);

  bigData?: object[];
  subjectValue?: number;

  constructor() {
    // здесь загружаем наш большой JSON
    this.httpClient.get<object[]>('/mock-data.json').pipe(takeUntilDestroyed()).subscribe((data) => {
      this.bigData = data;
      this.cd.markForCheck();
    });

    // здесь будет утечка:
    this.lifetimeService.someObservable.subscribe((value) => {
      this.subjectValue = value;
      this.cd.markForCheck();
    });
  }
}

Обратите внимание, что мы в подписке сослались на this, чтобы присвоить значения и воспользоваться ChangeDetectorRef.

Первая подписка из HttpClient утечки не вызовет, как минимум потому что этот Observable завершается сразу же, как только завершится http-запрос. Сейчас она нас сильно не интересует. Но это всё равно не значит, что от неё не нужно отписываться — мало ли на какой Observable она переключится в процессе.

Что нас будет интересовать, так это вторая подписка — именно она вызовет утечку, так как в ней мы подписываемся на Observable, живущий на протяжении всей работы приложения и забываем отписаться.

Родительский компонент

Здесь всё просто: нам нужно использовать описанный выше компонент — отображать его по тогглу. В роли тоггла будет выступать обычная кнопка.

import { Component } from '@angular/core';
import { DataCarrierComponent } from './data-carrier.component';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
  imports: [DataCarrierComponent],
})
export class AppComponent {
  openDataCarrier = false;
}

И шаблон:

<button (click)="openDataCarrier = !openDataCarrier">
  Toggle data carrier
</button>

@if (openDataCarrier) {
  <app-data-carrier />
}

Выглядит это всё незамысловато:

Запись экрана 2025-02-01 в 22.48.41.gif

В Chrome Dev Tools есть замечательный инструмент: Memory. Он позволяет сделать снапшот текущего состояния памяти вкладки и проанализировать его в очень удобном виде. Откроем наше приложение, сделаем снапшот и поверхностно разберём, что к чему. Нужно выбрать “Heap snapshot”, а затем нажать “Take snapshot”.

Снимок экрана 2025-02-01 в 23.01.11.png

Браузер начнёт процесс снятия снапшота. В случае с нашим приложением это должно произойти довольно быстро. После этого получим такую картинку:

Снимок экрана 2025-02-01 в 23.06.18.png

Из раздела слева можно понять, что в полученном снапшоте браузер насчитал 5 мегабайт данных. Многовато для такого приложения, но не забываем, что сейчас оно работает в dev-режиме через ng serve, так что удивляемся, но не от всей души.

Более интересное происходит в центре: здесь под заголовком Constructor перечислены все классы, инстансы которых находятся в памяти, а также количество этих инстансов. То есть, если вы создали строку, она попадёт в группу (string); если создали объект какого-нибудь класса — SomeClass, например, — он попадёт в группу под названием SomeClass.

Если раскрыть группу, мы увидим список всех инстансов этого конструктора. Вот, например, все строки (их, как можно заметить, около 17-и тысяч):

Снимок экрана 2025-02-01 в 23.18.25.png

А вот группа _AppComponent и её единственный в своём роде инстанс:

Снимок экрана 2025-02-01 в 23.19.46.png

Давайте выберем любой инстанс — к примеру, тот самый AppComponent — и обратим внимание на раздел Retainers внизу. Это уже самое интересное:

Снимок экрана 2025-02-01 в 23.24.16.png

Здесь в виде дерева указываются все возможные пути от выбранного объекта до так называемых корней.

Корни Garbage Collector (GC) в JS — это объекты, которые считаются "живыми" и недоступными для сборки мусора. Это, в основом, глобальные переменные (а ещё локальные переменные в текущем стеке вызовов и активные функции, если GC отрабатывает во время выполнения таска). Если объект достижим из корня, он не будет удалён. Самый очевидный и известный корневой объект для браузера — это глобальный объект, доступный через window.

Есть ещё столбец с неким значением Distance. Дистанция в контексте GC — это мера того, насколько далеко объект находится от корня.

Если мы присвоим какой-либо объект к любому полю объекта window, который является корнем, то наш объект будет доступен через него таким образом:

   window.someInstance
// ^      ^
// |      | distance = 2
// | distance = 1

Этот объект не будет удаляться сборщиком мусора. Дистанция до корня у него будет равна 2. Убедимся, создав и присвоив объект через консоль, а затем сделав новый снапшот:

Снимок экрана 2025-02-01 в 23.37.31.png

Дерево в разделе Retainers даже раскрывать не пришлось, наш объект находится прямо у корня.

Попробуем теперь поискать вложенный объект. Создадим внутри window.someInstance ещё один объект и снова сделаем снапшот памяти.

window.someInstance.someAnotherInstance = new SomeClass();

Смотрим:

Снимок экрана 2025-02-01 в 23.49.00.png

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

А что, если у объекта несколько путей до корня? А давайте посмотрим. Сошлёмся на последний созданный объект через другое поле:

window.someInstance.someAnotherWayToInstance = 
  window.someInstance.someAnotherInstance;

И сделаем снапшот:

Снимок экрана 2025-02-01 в 23.54.28.png

Отлично видно, что браузер указал нам оба пути до window: через поле someAnotherInstance и someAnotherWayToInstance.

Вроде разобрались.

Ищем утечку

Как по-хорошему должно происходить хранение того самого большого загруженного массива? Как только мы отрисуем компонент, он загрузит массив и присвоит его полю bigData. Как только компонент задестроится, данные в bigData тоже должны из памяти исчезнуть.

От компонента не должно остаться ни следа: память должна выглядеть так, будто мы его никогда и не отрисовывали. Получается, что состояние приложения в момент, когда оно только загрузилось и в состояние в момент, когда мы включили, а затем выключили компонент DataCarrierComponent, должны быть очень похожими — там не должно появиться никаких новых данных.

Представим, что мы не знаем, где конкретно причина утечки, и наша задача — её найти. Было бы очень удобно сравнить состояния памяти до совершения действия и после. Глядя на разницу между ними, мы сможем быстро сказать, каких данных быть не должно, и найти того, кто их держит.

Во вкладке Memory есть и такое! Мы можем сравнить два снапшота между собой, и делается это довольно просто:

  • делаем снапшот состояния до совершения действия (в нашем случае — как только приложение откроется);

  • совершаем действие, приводящее к утечке (в нашем случае — включаем и выключаем компонент DataCarrierComponent);

  • делаем снапшот состояния после совершения действия;

  • открываем второй снапшот и выбираем режим Comparison (Сравнение); проверяем, что сравнение идёт с первым снапшотом.

Снимок экрана 2025-02-02 в 14.14.54.png

Итак, перед нами предстала таблица, очень похожая на таблицу Summary, которую мы рассматривали ранее. Единственное, что поменялось — теперь в ней отображаются только различия между снапшотами. А ещё появились новые столбцы, позволяющие понять сколько инстансов появилось (# New) или удалилось (# Deleted).

Снимок экрана 2025-02-02 в 14.18.39.png

Сразу бросается в глаза — появилось очень много новых строк и объектов одинаковой структуры (первая и вторая строка). Это и есть те самые загруженные объекты, а также строки к ним относящиеся.

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

Снимок экрана 2025-02-02 в 14.20.47.png

Что мы здесь видим? В самом низу у нас есть некая мапа под названием TRACKED_LVIEWS. Это объект, через который Angular следит за актуальными кусками представления (они называются LView). Сам этот объект доступен через window, и это нормально, он живёт на протяжении всей работы Angular приложения. Если проследить путь от него до нашего объекта, то нам встретятся такие узлы:

  • table in Map — один из объектов LView;

  • [9] — 9 элемент массива LView (да, LView это на самом деле массив), он ссылается на инжектор;

  • parentInjector, records, 359, value — это набор полей, продираясь через которые мы можем достать из ижектора наш рутовый сервис LifetimeService;

  • someObservable — так называется поле с Subject, от которого мы забыли отписаться;

  • observers — это список подписчиков Subject-а;

  • [0], destination next — это путь до функции-обработчика next, которую мы написали, когда подписывались.

А вот тут приостановимся: помните, мы обратили внимание на то, что в подписке мы сослались на this? Так вот, таким образом мы заставили браузер запомнить контекст функции, и теперь он доступен через системное поле context. Если бы мы в этой функции не сослались на this, а просто воспользовались чем-нибудь глобальным (например, console.log), браузер бы не стал запоминать значение this.

this.lifetimeService.someObservable.subscribe((value) => {
  this.subjectValue = value; // <-- тут браузер понимает, что ему придётся запомнить this
  this.cd.markForCheck();
});

А на что у нас в этой функции ссылается this? Можно догадаться, но браузер написал нам это тоже — это DataCarrierComponent. Как видим, в памяти он остался до сих пор: жив, здоров, хотя мы думали, что его задестроили. Тот самый массив bigData тоже на месте.

Вкратце можно сказать так: сервис LifetimeService ссылается на Subject, который в свою очередь ссылается на всех своих подписчиков, одним (да и единственным) из которых является наша функция подписки, которая сама ссылается на this, равный компоненту DataCarrierComponent, который уже ссылается на bigData. Получается, что путь от корня до bigData можно построить, а значит Garbage Collector его трогать не будет, поэтому он и остаётся.

То, что у Subject-а остался какой-то подписчик уже говорит о проблеме того, что где-то мы забыли отписаться.

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

Снимок экрана 2025-02-02 в 14.48.01.png

Перейдём по второй ссылке, и сразу увидим, где забыли отписаться.

Снимок экрана 2025-02-02 в 14.51.32.png

Доделываем отписку и повторяем сравнение заново:

Снимок экрана 2025-02-02 в 14.55.02.png

В снапшотах, конечно, есть разница, но всё это касается системных объектов и объектов фреймворка. Утечка устранена.


У меня всё. Кстати, у меня ещё есть телеграм-канал.

P.S. Не знаю, баг это или фича, но когда браузер снимает снапшот, он учитывает все открытые вкладки с текущим адресом. Это неочевидно, и из-за этого можно запутаться в таблице. Лучше перед снятием снапшота закрыть все остальные вкладки с нужным адресом.

Теги:
Хабы:
Всего голосов 17: ↑15 и ↓2+18
Комментарии10

Публикации

Ближайшие события