javascript

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

  • четверг, 6 февраля 2025 г. в 00:00:06
https://habr.com/ru/articles/879502/

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

Многие, конечно, знают, что в 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. Не знаю, баг это или фича, но когда браузер снимает снапшот, он учитывает все открытые вкладки с текущим адресом. Это неочевидно, и из-за этого можно запутаться в таблице. Лучше перед снятием снапшота закрыть все остальные вкладки с нужным адресом.