Как утекает память, если забыть отписаться от Observable
- четверг, 6 февраля 2025 г. в 00:00:06
Многие, конечно, знают, что в Angular-сообществе принято трепетно следить за подписками на Observable, потому что это чревато утечками памяти. Но не все видели эти утечки в глаза и не встречались с их последствиями. Давайте смоделируем простую ситуацию по следам утечки, с которой недавно столкнулся я (первый раз).
Представим, что пользователи заявили, что после долгого использования нашего приложения, оно неожиданно вылетает и превращается в страницу “Опаньки”.
Юзер флоу вкратце примерно такой: пользователи часто переходят между страницами, каждая из которых загружает какие-либо данные.
Первое предложение, зная вышеизложенное: эти данные накапливаются и остаются даже после ухода со страницы (то есть после уничтожения компонента страницы). В итоге образуется утечка, из-за которой вкладка в какой-то момент не выдерживает и вылетает.
Чтобы создать утечку, а затем её найти нам понадобится:
один 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 />
}
Выглядит это всё незамысловато:
В Chrome Dev Tools есть замечательный инструмент: Memory. Он позволяет сделать снапшот текущего состояния памяти вкладки и проанализировать его в очень удобном виде. Откроем наше приложение, сделаем снапшот и поверхностно разберём, что к чему. Нужно выбрать “Heap snapshot”, а затем нажать “Take snapshot”.
Браузер начнёт процесс снятия снапшота. В случае с нашим приложением это должно произойти довольно быстро. После этого получим такую картинку:
Из раздела слева можно понять, что в полученном снапшоте браузер насчитал 5 мегабайт данных. Многовато для такого приложения, но не забываем, что сейчас оно работает в dev-режиме через ng serve
, так что удивляемся, но не от всей души.
Более интересное происходит в центре: здесь под заголовком Constructor перечислены все классы, инстансы которых находятся в памяти, а также количество этих инстансов. То есть, если вы создали строку, она попадёт в группу (string)
; если создали объект какого-нибудь класса — SomeClass, например, — он попадёт в группу под названием SomeClass
.
Если раскрыть группу, мы увидим список всех инстансов этого конструктора. Вот, например, все строки (их, как можно заметить, около 17-и тысяч):
А вот группа _AppComponent
и её единственный в своём роде инстанс:
Давайте выберем любой инстанс — к примеру, тот самый AppComponent — и обратим внимание на раздел Retainers внизу. Это уже самое интересное:
Здесь в виде дерева указываются все возможные пути от выбранного объекта до так называемых корней.
Корни Garbage Collector (GC) в JS — это объекты, которые считаются "живыми" и недоступными для сборки мусора. Это, в основом, глобальные переменные (а ещё локальные переменные в текущем стеке вызовов и активные функции, если GC отрабатывает во время выполнения таска). Если объект достижим из корня, он не будет удалён. Самый очевидный и известный корневой объект для браузера — это глобальный объект, доступный через window
.
Есть ещё столбец с неким значением Distance. Дистанция в контексте GC — это мера того, насколько далеко объект находится от корня.
Если мы присвоим какой-либо объект к любому полю объекта window
, который является корнем, то наш объект будет доступен через него таким образом:
window.someInstance
// ^ ^
// | | distance = 2
// | distance = 1
Этот объект не будет удаляться сборщиком мусора. Дистанция до корня у него будет равна 2. Убедимся, создав и присвоив объект через консоль, а затем сделав новый снапшот:
Дерево в разделе Retainers даже раскрывать не пришлось, наш объект находится прямо у корня.
Попробуем теперь поискать вложенный объект. Создадим внутри window.someInstance
ещё один объект и снова сделаем снапшот памяти.
window.someInstance.someAnotherInstance = new SomeClass();
Смотрим:
Видим, что у нашего класса уже два инстанса. Один с дистанцией, равной 2 (мы его уже рассматривали), второй с дистанцией 3. Внизу выстроилось дерево с путём от этого объекта до корня. Видим, что браузер любезно расписал нам, что в каком поле хранится. А если навести указатель на одну из строк, он отобразит в тултипе этот объект.
А что, если у объекта несколько путей до корня? А давайте посмотрим. Сошлёмся на последний созданный объект через другое поле:
window.someInstance.someAnotherWayToInstance =
window.someInstance.someAnotherInstance;
И сделаем снапшот:
Отлично видно, что браузер указал нам оба пути до window
: через поле someAnotherInstance
и someAnotherWayToInstance
.
Вроде разобрались.
Как по-хорошему должно происходить хранение того самого большого загруженного массива? Как только мы отрисуем компонент, он загрузит массив и присвоит его полю bigData
. Как только компонент задестроится, данные в bigData
тоже должны из памяти исчезнуть.
От компонента не должно остаться ни следа: память должна выглядеть так, будто мы его никогда и не отрисовывали. Получается, что состояние приложения в момент, когда оно только загрузилось и в состояние в момент, когда мы включили, а затем выключили компонент DataCarrierComponent, должны быть очень похожими — там не должно появиться никаких новых данных.
Представим, что мы не знаем, где конкретно причина утечки, и наша задача — её найти. Было бы очень удобно сравнить состояния памяти до совершения действия и после. Глядя на разницу между ними, мы сможем быстро сказать, каких данных быть не должно, и найти того, кто их держит.
Во вкладке Memory есть и такое! Мы можем сравнить два снапшота между собой, и делается это довольно просто:
делаем снапшот состояния до совершения действия (в нашем случае — как только приложение откроется);
совершаем действие, приводящее к утечке (в нашем случае — включаем и выключаем компонент DataCarrierComponent);
делаем снапшот состояния после совершения действия;
открываем второй снапшот и выбираем режим Comparison (Сравнение); проверяем, что сравнение идёт с первым снапшотом.
Итак, перед нами предстала таблица, очень похожая на таблицу Summary, которую мы рассматривали ранее. Единственное, что поменялось — теперь в ней отображаются только различия между снапшотами. А ещё появились новые столбцы, позволяющие понять сколько инстансов появилось (# New) или удалилось (# Deleted).
Сразу бросается в глаза — появилось очень много новых строк и объектов одинаковой структуры (первая и вторая строка). Это и есть те самые загруженные объекты, а также строки к ним относящиеся.
Давайте выберем любой объект из второй группы, чтобы браузер построил нам путь от этого объекта до корня — так мы узнаем, где же этот объект застрял.
Что мы здесь видим? В самом низу у нас есть некая мапа под названием 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-а остался какой-то подписчик уже говорит о проблеме того, что где-то мы забыли отписаться.
Помимо вышеперечисленного, браузер даёт нам сразу перейти к функции, которая хранит свой контекст, а также к полю, которое хранит данные.
Перейдём по второй ссылке, и сразу увидим, где забыли отписаться.
Доделываем отписку и повторяем сравнение заново:
В снапшотах, конечно, есть разница, но всё это касается системных объектов и объектов фреймворка. Утечка устранена.
У меня всё. Кстати, у меня ещё есть телеграм-канал.
P.S. Не знаю, баг это или фича, но когда браузер снимает снапшот, он учитывает все открытые вкладки с текущим адресом. Это неочевидно, и из-за этого можно запутаться в таблице. Лучше перед снятием снапшота закрыть все остальные вкладки с нужным адресом.