...и как это обнаружить.
Многие, конечно, знают, что в Angular-сообществе принято трепетно следить за подписками на Observable, потому что это чревато утечками памяти. Но не все видели эти утечки в глаза и не встречались с их последствиями. Давайте смоделируем простую ситуацию по следам утечки, с которой недавно столкнулся я (первый раз).
Представим, что пользователи заявили, что после долгого использования нашего приложения, оно неожиданно вылетает и превращается в страницу “Опаньки”.
![(хихикал с этой страницы ещё в далёком 2009) (хихикал с этой страницы ещё в далёком 2009)](https://habrastorage.org/getpro/habr/upload_files/f9e/2e7/d79/f9e2e7d79cccbde8e5607df866ce2ccd.jpg)
Юзер флоу вкратце примерно такой: пользователи часто переходят между страницами, каждая из которых загружает какие-либо данные.
Первое предложение, зная вышеизложенное: эти данные накапливаются и остаются даже после ухода со страницы (то есть после уничтожения компонента страницы). В итоге образуется утечка, из-за которой вкладка в какой-то момент не выдерживает и вылетает.
Моделируем ситуацию
Чтобы создать утечку, а затем её найти нам понадобится:
один 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](https://habrastorage.org/getpro/habr/upload_files/bf1/a10/a2e/bf1a10a2e7c303694d8fd1072f3b50b1.gif)
В Chrome Dev Tools есть замечательный инструмент: Memory. Он позволяет сделать снапшот текущего состояния памяти вкладки и проанализировать его в очень удобном виде. Откроем наше приложение, сделаем снапшот и поверхностно разберём, что к чему. Нужно выбрать “Heap snapshot”, а затем нажать “Take snapshot”.
![Снимок экрана 2025-02-01 в 23.01.11.png](https://habrastorage.org/getpro/habr/upload_files/a18/8cd/8db/a188cd8db6418f2961ab4fb1f61466c0.png)
Браузер начнёт процесс снятия снапшота. В случае с нашим приложением это должно произойти довольно быстро. После этого получим такую картинку:
![Снимок экрана 2025-02-01 в 23.06.18.png](https://habrastorage.org/getpro/habr/upload_files/8e3/d5c/e47/8e3d5ce47fa081b68762d471d5b090df.png)
Из раздела слева можно понять, что в полученном снапшоте браузер насчитал 5 мегабайт данных. Многовато для такого приложения, но не забываем, что сейчас оно работает в dev-режиме через ng serve
, так что удивляемся, но не от всей души.
Более интересное происходит в центре: здесь под заголовком Constructor перечислены все классы, инстансы которых находятся в памяти, а также количество этих инстансов. То есть, если вы создали строку, она попадёт в группу (string)
; если создали объект какого-нибудь класса — SomeClass, например, — он попадёт в группу под названием SomeClass
.
Если раскрыть группу, мы увидим список всех инстансов этого конструктора. Вот, например, все строки (их, как можно заметить, около 17-и тысяч):
![Снимок экрана 2025-02-01 в 23.18.25.png](https://habrastorage.org/getpro/habr/upload_files/57b/684/16a/57b68416a957a36482b22db5dfdecfa5.png)
А вот группа _AppComponent
и её единственный в своём роде инстанс:
![Снимок экрана 2025-02-01 в 23.19.46.png](https://habrastorage.org/getpro/habr/upload_files/4fa/ef8/688/4faef8688a3feadb58e4463c3386bb0c.png)
Давайте выберем любой инстанс — к примеру, тот самый AppComponent — и обратим внимание на раздел Retainers внизу. Это уже самое интересное:
![Снимок экрана 2025-02-01 в 23.24.16.png](https://habrastorage.org/getpro/habr/upload_files/eb7/5f3/c74/eb75f3c74a6416eb33e67ebb5566766e.png)
Здесь в виде дерева указываются все возможные пути от выбранного объекта до так называемых корней.
Корни Garbage Collector (GC) в JS — это объекты, которые считаются "живыми" и недоступными для сборки мусора. Это, в основом, глобальные переменные (а ещё локальные переменные в текущем стеке вызовов и активные функции, если GC отрабатывает во время выполнения таска). Если объект достижим из корня, он не будет удалён. Самый очевидный и известный корневой объект для браузера — это глобальный объект, доступный через window
.
Есть ещё столбец с неким значением Distance. Дистанция в контексте GC — это мера того, насколько далеко объект находится от корня.
Если мы присвоим какой-либо объект к любому полю объекта window
, который является корнем, то наш объект будет доступен через него таким образом:
window.someInstance
// ^ ^
// | | distance = 2
// | distance = 1
Этот объект не будет удаляться сборщиком мусора. Дистанция до корня у него будет равна 2. Убедимся, создав и присвоив объект через консоль, а затем сделав новый снапшот:
![Снимок экрана 2025-02-01 в 23.37.31.png](https://habrastorage.org/getpro/habr/upload_files/3f7/38d/5d2/3f738d5d2704b488a42e37be80eefa72.png)
Дерево в разделе Retainers даже раскрывать не пришлось, наш объект находится прямо у корня.
Попробуем теперь поискать вложенный объект. Создадим внутри window.someInstance
ещё один объект и снова сделаем снапшот памяти.
window.someInstance.someAnotherInstance = new SomeClass();
Смотрим:
![Снимок экрана 2025-02-01 в 23.49.00.png](https://habrastorage.org/getpro/habr/upload_files/574/ddb/600/574ddb6006a94d05b642705e5c2b5036.png)
Видим, что у нашего класса уже два инстанса. Один с дистанцией, равной 2 (мы его уже рассматривали), второй с дистанцией 3. Внизу выстроилось дерево с путём от этого объекта до корня. Видим, что браузер любезно расписал нам, что в каком поле хранится. А если навести указатель на одну из строк, он отобразит в тултипе этот объект.
А что, если у объекта несколько путей до корня? А давайте посмотрим. Сошлёмся на последний созданный объект через другое поле:
window.someInstance.someAnotherWayToInstance =
window.someInstance.someAnotherInstance;
И сделаем снапшот:
![Снимок экрана 2025-02-01 в 23.54.28.png](https://habrastorage.org/getpro/habr/upload_files/beb/54f/4c9/beb54f4c920be265644e89a56d8e3162.png)
Отлично видно, что браузер указал нам оба пути до window
: через поле someAnotherInstance
и someAnotherWayToInstance
.
Вроде разобрались.
Ищем утечку
Как по-хорошему должно происходить хранение того самого большого загруженного массива? Как только мы отрисуем компонент, он загрузит массив и присвоит его полю bigData
. Как только компонент задестроится, данные в bigData
тоже должны из памяти исчезнуть.
От компонента не должно остаться ни следа: память должна выглядеть так, будто мы его никогда и не отрисовывали. Получается, что состояние приложения в момент, когда оно только загрузилось и в состояние в момент, когда мы включили, а затем выключили компонент DataCarrierComponent, должны быть очень похожими — там не должно появиться никаких новых данных.
Представим, что мы не знаем, где конкретно причина утечки, и наша задача — её найти. Было бы очень удобно сравнить состояния памяти до совершения действия и после. Глядя на разницу между ними, мы сможем быстро сказать, каких данных быть не должно, и найти того, кто их держит.
Во вкладке Memory есть и такое! Мы можем сравнить два снапшота между собой, и делается это довольно просто:
делаем снапшот состояния до совершения действия (в нашем случае — как только приложение откроется);
совершаем действие, приводящее к утечке (в нашем случае — включаем и выключаем компонент DataCarrierComponent);
делаем снапшот состояния после совершения действия;
открываем второй снапшот и выбираем режим Comparison (Сравнение); проверяем, что сравнение идёт с первым снапшотом.
![Снимок экрана 2025-02-02 в 14.14.54.png](https://habrastorage.org/getpro/habr/upload_files/401/585/8b6/4015858b6ab6020f0975df18a81038e5.png)
Итак, перед нами предстала таблица, очень похожая на таблицу Summary, которую мы рассматривали ранее. Единственное, что поменялось — теперь в ней отображаются только различия между снапшотами. А ещё появились новые столбцы, позволяющие понять сколько инстансов появилось (# New) или удалилось (# Deleted).
![Снимок экрана 2025-02-02 в 14.18.39.png](https://habrastorage.org/getpro/habr/upload_files/760/07a/6ae/76007a6aebdf6da2f733a8af8b350621.png)
Сразу бросается в глаза — появилось очень много новых строк и объектов одинаковой структуры (первая и вторая строка). Это и есть те самые загруженные объекты, а также строки к ним относящиеся.
Давайте выберем любой объект из второй группы, чтобы браузер построил нам путь от этого объекта до корня — так мы узнаем, где же этот объект застрял.
![Снимок экрана 2025-02-02 в 14.20.47.png](https://habrastorage.org/getpro/habr/upload_files/aa4/db5/51d/aa4db551de54d0e81210e71a6202ad9c.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](https://habrastorage.org/getpro/habr/upload_files/160/6e9/368/1606e936856017dd8721d39650054e8c.png)
Перейдём по второй ссылке, и сразу увидим, где забыли отписаться.
![Снимок экрана 2025-02-02 в 14.51.32.png](https://habrastorage.org/getpro/habr/upload_files/10c/a47/666/10ca476668bafb1b9b1cfe3729398f5c.png)
Доделываем отписку и повторяем сравнение заново:
![Снимок экрана 2025-02-02 в 14.55.02.png](https://habrastorage.org/getpro/habr/upload_files/99d/e00/1e8/99de001e8cbb6496333b42261e16f214.png)
В снапшотах, конечно, есть разница, но всё это касается системных объектов и объектов фреймворка. Утечка устранена.
У меня всё. Кстати, у меня ещё есть телеграм-канал.
P.S. Не знаю, баг это или фича, но когда браузер снимает снапшот, он учитывает все открытые вкладки с текущим адресом. Это неочевидно, и из-за этого можно запутаться в таблице. Лучше перед снятием снапшота закрыть все остальные вкладки с нужным адресом.
Комментарии (9)
nin-jin
04.02.2025 21:29Меня очень заинтересовал ваш проект и я решил повторить его на чём-то менее попсовом, например на $mol, сохранив основную функциональность - утечки.
Итак, вот сервис:
export class $my_leak_service extends $mol_object { @ $mol_mem static data( next = 0 ) { return next } }
Вот, использующий его компонент:
$my_leak_carrier $mol_chip title \Data carrier (data length: {size})
export class $my_leak_carrier extends $.$my_leak_carrier { @ $mol_mem data_from_service() { return this.$.$my_leak_service.data() } @ $mol_mem data() { return this.$.$mol_fetch.json( 'https://mol.hyoo.ru/web.deps.json' ) as any } @ $mol_mem size() { return this.data().files.length } @ $mol_mem title() { return super.title().replaceAll( '{size}', this.size().toString() ) } }
Ну и обвязка-переключатель:
$my_leak $mol_expander title \Toggle data carrier content / <= Carrier $my_leak_carrier
А теперь смертельный номер - запускаем профилирование памяти:
Ага, мы загрузили полтора метра данных, но где же наш сервис? А, точно, мы же к нему даже не обратились - вот он и не поднялся. Исправляемся:
export class $my_leak_carrier extends $.$my_leak_carrier { // ... @ $mol_mem size() { return this.data().files.length + this.data_from_service() } // ... }
Ладно, сервис подняли, но вот чёрт, при закрытии компонента память зачем-то освобождается:
Ладно, план Б, переносим загрузку и хранение данных в сам сервис, в статический, чтоб его, синглтон:
export class $my_leak_service extends $mol_object { @ $mol_mem static data() { return this.$.$mol_fetch.json( 'https://mol.hyoo.ru/web.deps.json' ) as any } }
export class $my_leak_carrier extends $.$my_leak_carrier { @ $mol_mem data() { return this.$.$my_leak_service.data() } @ $mol_mem size() { return this.data().files.length } @ $mol_mem title() { return super.title().replaceAll( '{size}', this.size().toString() ) } }
Ну теперь-то точно всё утечёт:
Да что ж это за напасть-то такая? Казалось бы, стандартная для enterprize фича, а на $mol её не реализовать. Жаль, а такой перспективный, казалось, фреймворк...
Сейчас ещё и минусов отхвачу за то, что не осилил...
s1im
04.02.2025 21:29А к чему вы устроили это цирковое представление? В вашем примере вы просто ссылаетесь на асинхронный метод
$mol_fetch.json()
, это далеко не то же самое, как работают Observables в библиотеке rxjs. Если это какая-то насмешка над Ангуляром, то смею вас заверить, что его компоненты тоже никакого понятия о rxjs не имеют и никакого встроенного механизма автоматических отписок (которые могут быть еще и не желательны в отдельных случаях) не имеют. Не нравится rxjs - никто не запрещает использовать любую другую библиотеку или дефолтный fetch. А логика вида "подписался на что-то -- отпишись" справедлива для всего, при работе с асинхронным кодом.nin-jin
04.02.2025 21:29Метод
$mol_fetch.json()
, разумеется никакой не асинхронный. Да и никаких ручных подписок/отписок, как видите, в коде нет. Волшебство, не иначе.
Marazmen
04.02.2025 21:29К тому, что вся статья это цирк. Давайте рассудим логично:
1) описана реактиваная парадигма js.
2) главный принцип реактивного програамирования: а) подписка б) наблюдение в) отписка. И никак иначе.
3) статья основана на нарушении основных правил парадигмы.
Весь остальной бред высосан из пальца безмозглых программистов, которые нарушают самые фундоментальные правила, а потом удивляются - а "херли не работает?', "а кто это сделал?", "а куда утекает память?"
Тьфу....
s1im
04.02.2025 21:29Так я может не хочу отписываться каждый раз при destroy моего компонент, может мне нужно подписаться в какой-то момент и больше не отписываться. Если я пишу приложение на условном Angular - то это как правило SPA, оно может жить месяцами в открытом окне/табе без обновления страницы. И все это время подписка может жить, и это expected behaviour
RenatSh
https://en.wikipedia.org/wiki/Lapsed_listener_problem