Производительность — это ключ к успеху веб-приложения. Поэтому разработчикам нужно знать о том, как возникают утечки памяти, и о том, как с ними бороться.
Эти знания особенно важны в том случае, когда приложение, которым занимается разработчик, достигает определённого размера. Если уделять утечкам памяти недостаточно внимания, то всё может закончиться тем, что разработчик, в итоге, попадёт в «команду по устранению утечек памяти» (мне доводилось входить в состав такой команды).
Утечки памяти могут возникать по разным причинам. Однако я полагаю, что при использовании Angular можно столкнуться с паттерном, который соответствует самой распространённой причине возникновения утечек памяти. Существует и способ борьбы с такими утечками памяти. А лучше всего, конечно, не бороться с проблемами, а избегать их.
Что такое управление памятью?
В JavaScript применяется система автоматического управления памятью. Жизненный цикл памяти обычно состоит из трёх шагов:
- Выделение необходимой памяти.
- Работа с выделенной памятью, выполнение операций чтения и записи.
- Освобождение памяти после того, как она больше не нужна.
На MDN говорится о том, что автоматическое управление памятью — это потенциальный источник путаницы. Это может дать разработчикам ложное ощущение того, что им не нужно заботиться об управлении памятью.
Если вы совершенно не заботитесь об управлении памятью, это значит, что после того, как ваше приложение дорастёт до определённого размера, вы вполне можете столкнуться с утечкой памяти.
В целом, утечки памяти можно представить как выделенную приложению память, которая больше ему не нужна, но и не освобождена. Другими словами, это — объекты, которые не удалось подвергнуть операции сборки мусора.
Как работает сборка мусора?
В ходе процедуры сборки мусора, что вполне логично, выполняется уборка всего того, что можно счесть «мусором». Сборщик мусора очищает память, которая больше не нужна приложению. Для того чтобы выяснить, какие участки памяти ещё нужны приложению, сборщик мусора использует алгоритм «mark and sweep» (алгоритм пометок). Как следует из названия, этот алгоритм состоит из двух фаз — фазы пометки (mark) и фазы очистки (sweep).
▍Фаза пометки
Объекты и ссылки на них представлены в виде дерева. Корень дерева — это, на следующем рисунке, узел root
. В JavaScript это — объект window
. У каждого объекта есть особый флаг. Назовём этот флаг marked
. На фазе пометки, в первую очередь, все флаги marked
устанавливаются в значение false
.
В начале флаги объектов marked устанавливаются в false
Затем осуществляется обход дерева объектов. Все флаги marked
объектов, достижимых из узла root
, устанавливаются в true
. А флаги тех объектов, достичь которых не удаётся, так и остаются в значении false
.
Объект считается недостижимым в том случае, если до него нельзя добраться из корневого объекта.
Достижимые объекты помечены как marked=true, недостижимые — как marked=false
В результате все флаги marked
недостижимых объектов остаются в значении false
. Память пока не освобождается, но, после завершения фазы пометки всё оказывается готовым для фазы очистки.
▍Фаза очистки
Память очищается именно на данной фазе работы алгоритма. Здесь все недостижимые объекты (те, флаг marked
которых остался в значении false
) уничтожаются сборщиком мусора.
Дерево объектов после сборки мусора. Все объекты, флаг marked которых остался в значении false, уничтожены сборщиком мусора
Сборка мусора периодически выполняется в процессе работы JavaScript-программы. В ходе выполнения этой процедуры осуществляется освобождение памяти, которая может быть освобождена.
Возможно, тут у вас возникает следующий вопрос: «Если сборщик мусора убирает все объекты, помеченные как недостижимые — как создать утечку памяти?».
Дело тут в том, что объект не будет обработан сборщиком мусора в том случае, если он не нужен приложению, но при этом до него всё ещё можно добраться из корневого узла дерева объектов.
Алгоритм не может знать о том, будет ли приложение пользоваться неким фрагментом памяти, к которому оно может обратиться, или не будет. Такие знания есть только у программиста.
Утечки памяти в Angular
Чаще всего утечки памяти возникают с течением времени, тогда, когда выполняется многократный повторный рендеринг компонента. Например — посредством маршрутизации, или как результат использования директивы *ngIf
. Скажем, в ситуации, когда некий продвинутый пользователь работает с приложением целый день, не обновляя страницу приложения в браузере.
Для того чтобы воспроизвести этот сценарий, создадим конструкцию из двух компонентов. Это будут компоненты AppComponent
и SubComponent
.
@Component({
selector: 'app-root',
template: `<app-sub *ngIf="hide"></app-sub>`
})
export class AppComponent {
hide = false;
constructor() {
setInterval(() => this.hide = !this.hide, 50);
}
}
В шаблоне компонента AppComponent
используется компонент app-sub
. Самое интересное здесь то, что в нашем компоненте используется функция setInterval
, которая переключает флаг hide
каждые 50 мс. Это приводит к тому, что каждые 50 мс выполняется повторный рендеринг компонента app-sub
. То есть — выполняется создание новых экземпляров класса SubComponent
. Этот код имитирует поведение пользователя, который весь день работает с веб-приложением, не обновляя страницу в браузере.
Мы, в SubComponent
, реализовали разные сценарии, при использовании которых, со временем, начинают проявляться изменения в объёме памяти, используемой приложением. Обратите внимание на то, что компонент AppComponent
всегда остаётся одним и тем же. В каждом сценарии мы выясним, является ли то, с чем мы имеем дело, утечкой памяти, анализируя потребление памяти процессом браузера.
Если потребление памяти процессом со временем растёт — это значит, что мы столкнулись с утечкой памяти. Если процесс использует более или менее постоянный объём памяти, это значит либо то, что утечки памяти нет, либо то, что утечка, хотя и присутствует, не проявляется достаточно очевидным образом.
▍Сценарий №1: огромный цикл for
Наш первый сценарий представлен циклом, который выполняется 100000 раз. В цикле осуществляется добавление случайных значений в массив. Не будем забывать о том, что компонент повторно рендерится каждые 50 мс. Взглянем на код и подумаем о том, создали мы утечку памяти или нет.
@Component({
selector:'app-sub',
// ...
})
export class SubComponent {
arr = [];
constructor() {
for (let i = 0; i < 100000; ++i) {
this.arr.push(Math.random());
}
}
}
Хотя такой код и не стоит отправлять в продакшн, утечку памяти он не создаёт. А именно, потребление памяти не выходит за рамки диапазона, ограниченного значением в 15 Мб. В результате — утечки памяти тут нет. Ниже мы поговорим о том, почему это так.
▍Сценарий №2: подписка на BehaviorSubject
В этом сценарии мы подписываемся на BehaviorSubject
и назначаем значение константе. Есть ли в этом коде утечка памяти? Как и прежде, не забываем о том, что компонент рендерится каждые 50 мс.
@Component({
selector:'app-sub',
// ...
})
export class SubComponent {
subject = new BehaviorSubject(42);
constructor() {
this.subject.subscribe(value => {
const foo = value;
});
}
}
Здесь, как и в предыдущем примере, утечки памяти нет.
▍Сценарий №3: назначение значения полю класса внутри подписки
Тут представлен практически такой же код, как и в предыдущем примере. Основная разница заключается в том, что значение назначается не константе, а полю класса. А теперь, как думаете, есть в коде утечка?
@Component({
selector:'app-sub',
// ...
})
export class SubComponent {
subject = new BehaviorSubject(42);
randomValue = 0;
constructor() {
this.subject.subscribe(value => {
this.randomValue = value;
});
}
}
Если вы полагаете, что утечки тут нет — вы совершенно правы.
В сценарии №1 нет подписки. В сценариях №2 и 3 мы подписались на поток наблюдаемого объекта, инициализированного в нашем компоненте. Возникает такое ощущение, что мы находимся в безопасности, подписываясь на потоки компонента.
А что если мы добавим в нашу схему сервис?
Сценарии, в которых используется сервис
В следующих сценариях мы собираемся пересмотреть вышеприведённые примеры, но в этот раз мы будем подписываться на поток, предоставляемый сервисом DummyService
. Вот код сервиса.
@Injectable({
providedIn: 'root'
})
export class DummyService {
some$ = new BehaviorSubject<number>(42);
}
Перед нами — простой сервис. Это — всего лишь сервис, который предоставляет поток (some$
) в форме общедоступного поля класса.
▍Сценарий №4: подписка на поток и присвоение значения локальной константе
Воссоздадим тут ту же схему, которую уже описывали ранее. Но в этот раз подпишемся на поток some$
из DummyService
, а не на поле компонента.
Есть ли тут утечка памяти? Опять же, отвечая на этот вопрос, помните о том, что компонент используется в AppComponent
и рендерится много раз.
@Component({
selector:'app-sub',
// ...
})
export class SubComponent {
constructor(private dummyService: DummyService) {
this.dummyService.some$.subscribe(value => {
const foo = value;
});
}
}
А вот теперь мы наконец-то создали утечку памяти. Но это — маленькая утечка. Под «маленькой утечкой» я понимаю такую, которая, с течением времени, приводит к медленному увеличению объёма потребляемой памяти. Это увеличение едва заметно, но беглый осмотр снепшота кучи показал наличие там множества неудалённых экземпляров Subscriber
.
▍Сценарий №5: подписка на сервис и назначение значения полю класса
Здесь мы снова подписываемся на dummyService
. Но в это раз мы назначаем полученное значение полю класса, а не локальной константе.
@Component({
selector:'app-sub',
// ...
})
export class SubComponent {
randomValue = 0;
constructor(private dummyService: DummyService) {
this.dummyService.some$.subscribe(value => {
this.randomValue = value;
});
}
}
А вот тут мы, наконец, создали значительную утечку памяти. Потребление памяти быстро, в течение минуты, превысило 1 Гб. Поговорим о том, почему это так.
▍Когда появилась утечка памяти?
Возможно, вы обратили внимание на то, что в первых трёх сценариях утечку памяти нам создать не удалось. У этих трёх сценариев есть кое-что общее: все ссылки являются локальными по отношению к компоненту.
Когда мы подписываемся на наблюдаемый объект, он хранит список подписчиков. В этом списке есть и наш коллбэк, а коллбэк может ссылаться на наш компонент.
Отсутствие утечки памяти
Когда компонент уничтожается, то есть, когда Angular больше не имеет ссылки на него, а значит — до компонента нельзя добраться из корневого узла, до наблюдаемого объекта и его списка подписчиков тоже нельзя добраться из корневого узла. В результате весь объект компонента подвергается сборке мусора.
До тех пор, пока мы подписаны на наблюдаемый объект, ссылки на который есть лишь в пределах компонента, никаких проблем не возникает. А вот когда в игру вступает сервис, ситуация меняется.
Утечка памяти
Как только мы подписались на наблюдаемый объект, предоставляемый сервисом или другим классом, мы создали утечку памяти. Это происходит из-за наблюдаемого объекта, из-за его списка подписчиков. Из-за этого коллбэк, а значит, и компонент, оказываются доступными из корневого узла, хотя у Angular и нет прямой ссылки на компонент. В результате сборщик мусора не трогает соответствующий объект.
Уточню: такими конструкциями пользоваться можно, но работать с ними нужно правильно, а не так, как мы.
Правильная работа с подписками
Для того чтобы избежать утечки памяти, важно правильно отписаться от наблюдаемого объекта, сделав это тогда, когда подписка больше не нужна. Например — при уничтожении компонента. Отписаться от наблюдаемого объекта можно разными способами.
Опыт консультирования владельцев крупных корпоративных проектов указывает на то, что в этой ситуации лучше всего использовать сущность destroy$
, создаваемую командой new Subject<void>()
, в комбинации с оператором takeUntil
.
@Component({
selector:'app-sub',
// ...
})
export class SubComponent implements OnDestroy {
private destroy$: Subject<void> = new Subject<void>();
randomNumber = 0;
constructor(private dummyService: DummyService) {
dummyService.some$.pipe(
takeUntil(this.destroy$)
).subscribe(value => this.randomNumber = value);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
Здесь мы отписываемся от подписки с помощью destroy$
и оператора takeUntil
после уничтожения компонента.
Мы реализовали в компоненте хук жизненного цикла ngOnDestroy
. Каждый раз, когда компонент уничтожается, мы вызываем у destroy$
методы next
и complete
.
Вызов complete
очень важен из-за того, что этот вызов очищает подписку от destroy$
.
Затем мы используем оператор takeUntil
и передадим ему наш поток destroy$
. Это гарантирует очистку подписки (то есть — то, что мы отписались от подписки) после уничтожения компонента.
Как не забыть об очистке подписок?
Очень легко забыть добавить в компонент destroy$
, а также забыть вызвать next
и complete
в хуке жизненного цикла ngOnDestroy
. Даже несмотря на то, что я обучал этому команды, работающие над проектами, я сам часто об этом забывал.
К счастью, существует замечательное правило линтера, входящее в состав набора правил, которое позволяет обеспечить правильное отписывание от подписок. Установить набор правил можно так:
npm install @angular-extensions/lint-rules --save-dev
Затем его надо подключить в tslint.json
:
{
"extends": [
"tslint:recommended",
"@angular-extensions/lint-rules"
]
}
Я настоятельно рекомендую вам пользоваться этим набором правил в своих проектах. Это позволит вам сэкономить много часов отладки в поиске источников утечек памяти.
Итоги
В Angular очень легко создать ситуацию, приводящую к утечкам памяти. Даже небольшие изменения кода в местах, которые, вроде бы, не должны иметь отношения к утечкам памяти, могут привести к серьёзным неблагоприятным последствиям.
Лучший способ избежать утечек памяти — это правильно управлять подписками. К сожалению, операция очистки подписок требует от разработчика большой аккуратности. Об этом легко забыть. Поэтому рекомендуется применять правила @angular-extensions/lint-rules
, которые помогают организовать правильную работу с подписками.
Вот репозиторий с кодом, положенным в основу этого материала.
Сталкивались ли вы с утечками памяти в Angular-приложениях?
Автор: ru_vds