RxJS: Производительность и утечки памяти в большом приложении

Евгений Поздняков

Предыстория

Приложение

  • Около 1000+ компонент
  • Redux
  • RxJS используется повсеместно
  • Нагруженные страницы
  • Angular 4/5
  • NgRX 2/5

Предыстория

Память

до: 3.5гб

после: 600мб

Время загрузки

до: 1мин

после: 20сек

Рендеринг

до: 30-40сек

после: 1сек

Скорость ответа

до: 10сек

после: <1сек

Поговорим о

  • Реактивное программирование
  • RxJS и философия
  • Откуда утечки?
  • Боремся с утечками: помощь Google
  • Под капот к RxJS
  • Отписываемся с помощью декораторов
  • Что в других языках?
  • Как найти утечки?… И немного хаков

Реактивное программирование

Реактивное программирование

  • Каждое событие - элемент массива
  • Мир и события в нем - труба
  • Данные можно обрабатывать и фильтровать как элементы массива
  • Любая операция асинхронная
1 args
sync value array
async promise stream/pipe

Async vs Sync

RxJS по запчастям

  • Observable— это объект или функция, которая выдает последовательности данных во времени
  • Observer — это объект или функция, которая знает, как обрабатывать последовательности данных
  • Subscriber— это объект или функция, которая связывает Observable и Observer.

RxJS по запчастям

Просто messaging и pub-sub?

Модель Rx

Producer

Consumer

Data pipeline

Time

Declarative

Immutable

Pure, side effect -free

Map, reduce, iterator

Functional Programming

PROMISE VS OBSERVABLE?

Promise

  • Thenable
  • Разрешено, отклонено
  • Невозможно остановить, Невозможно продолжить
  • Один объект за раз

Observable

  • Обработка нескольких значений во времени
  • Легко остановить
  • Легко продолжить
  • Легко начать заново

Откуда утечки?

Немного примеров

Массивы как источник

source - массив или объект

from -  создает Observable-последовательность из array-like или iterable объекта

ofсоздает Observable-последовательность из переданных аргументов

const task_stream =
  // Стрим из всех данных в базе
  getTasks().
    // выбираем задания только для текущего пользователя
    filter((task) => task.user_id == user_id).
    // находим те, которые еще не завершились
    filter((task) => !task.completed).
    // берем только название
    map((task) => task.name)
Observable.from(source)
    .operatorOne()
    .operatorTwo()
    .oneMoreOperator()
    .aaanddMoreOperator()
    .filter(value => value)
    .subscribe(console.log);

События как источник

Observer

  • next() - вот тебе новое значение из потока;
  • error() - вот тебе ошибка, произошедшая в потоке;
  • complete() - поток завершен.

Все просто

Typeahead

return Observable.fromEvent(this.elemnt.nativeElement, 'keyup')
    .map((e:any) => e.target.value)
    .debounceTime(450)
    .concat()
    .distinctUntilChanged()
    .subscribe(item => this.keywordChange.emit(item));

Хорош ли поиск?

А происходит следующее
closed = false
Утечка памяти!

return Observable.fromEvent(this.elemnt.nativeElement, 'keyup')
.map((e:any) => e.target.value)
.debounceTime(450)
.concat()
.distinctUntilChanged()
.subscribe(item => this.keywordChange.emit(item));
private subscription: Subscription;

onInit(){
    this.subscription = this.listenAndSuggest();
}

listenAndSuggest(){
    return Observable
    .fromEvent(this.elemnt.nativeElement, 'keyup')
    .map((e:any) => e.target.value)
    .debounceTime(450)
    .concat()
    .distinctUntilChanged()
    .subscribe(item => this.keywordChange.emit(item));
}

Мы забыли отписаться от Cold Pipe

Немного об Observer

Мы забыли отписаться от Cold Pipe

В коде выше некому сделать Unsubscribe

Как решить проблему?

interface Observer<T> {
    closed?: boolean;
    next: (value: T) => void;
    error: (err: any) => void;
    complete: () => void;
}
unsubscribe(): void {
    let hasErrors = false;
    let errors: any[];
    
    if (this.closed) {
        return;
    }

    let { _parent, _ parents, _unsubscribe, _subscriptions } = (<any> this);

    this.closed = true;
    this._parent = null;
    this._parents = null;
    this._subscriptions = null;
}

Решение... Хорошее ли оно?

Мы забыли отписаться от Cold Pipe

Стрим нужен пока жива вся компонента, давайте отпишемся, когда компота будет больше не нужна

А что делать, если мы используем много Observable? Руками отписываться?

private subscription: Subscription;

onInit(){
    this.subscription = this.listenAndSuggest();
}

listenAndSuggest(){
    return Observable
    .fromEvent(this.elemnt.nativeElement, 'keyup')
    .map((e:any) => e.target.value)
    .debounceTime(450)
    .concat()
    .distinctUntilChanged()
    .subscribe(item => this.keywordChange.emit(item));
}

onDestroy(){
    this.subscription.unsubscribe();
}

Боремся с утечками
с помощью
Google

RxJS не так просто, как кажется. Что говорит Google?

Google советует использовать следующие методы:

  •  take(n): возвращает N значений перед тем, как остановить Observable.
  •  takeWhile(predicate): проверяет каждое новое значение с помощью предиката, если предикат возвращает false, Observable complete.
  •   first(): возвращает первое значение, после чего останавливает Observable.
  •   takeUntil(Observable): не закрывает pipe до тех пор, пока внутренний Observable не произведет значение

Потому что:

  • писать меньше кода
  • проще управлять кодом
  • меньшим количеством подписок нужно управлять

Под капот к RXJS

Ай-да в исходники

take ()

export function take<T> count: number): MonoTypeOperatorFunction<T> {
  return (source: Observable<T>) => {
    if (count === 0) {
      return empty();
    } else {
      return source.lift(new TakeOperator(count));
    }
  };
}
private nextOrComplete(value: T, predicateResult: boolean): void {
  const destination = this.destination;
  if (Boolean(predicateResult)) {
    destination.next(value);
  } else {
    destination.complete();
  }
}

Ай-да в исходники

take ()

Все хорошо

Утечка памяти

Observable.from([1,2,3])
    .filter(value => false)
    .take(0)
    .subscribe(console.log)
Observable.from([1,2,3])
    .filter(value => false)
    .take(1)
    .subscribe(console.log)

Айда в исходники

takeWhile ()

protected _next(value: T): void {
  const destination = this.destination;
  let result: boolean;
  try {
    result = this.predicate(value, this.index++);
  } catch (err) {
    destination.error(err);
    return;
  }
  this.nextOrComplete(value, result);
}
  private nextOrComplete(value: T, predicateResult: boolean): void {
    const destination = this.destination;
    if (Boolean(predicateResult)) {
      destination.next(value);
    } else {
      destination.complete();
    }
  }

Айда в исходники

takeWhile ()

в случае, если через pipe ничего не пролетит, после того, как isDestroyed станет true, подписка никогда не закроется

takeWhile закроет подписку только если предикат isDestroyed= true в момент, когда через pipe пролетит значение

isDestroyed = false;

listenAndSuggest(){
    return Observable
        .fromEvent(this.element.nativeElement, 'keyup')
        .takeWhile(() => this.isDestroyed)
        .subscribe(console.log);
}

onDestroy(){
    this.isDestroyed = true;
}

Утечка памяти

Айда в исходники

first()

Одно но

protected _complete(): void {
  const destination = this.destination;
  if (!this.hasCompleted && typeof this.defaultValue !== 'undefined') {
      destination.next(this.defaultValue);
      destination.complete();
  } else if (!this.hasCompleted) {
      destination.error(new EmptyError);
  }
}

If called with no arguments, 'first' emits the first value of the sourse Observable, then completes. If called with a 'predicate' function, 'first' emits the first value of the source that matches the specified condition.

Айда в исходники

takeUntil ()

Все честно, главное сделать emit

notifyNext(outerValue: T, innerValue: R,
           outerIndex: number, innerIndex: number,
           innerSub: InnerSubscriber<T, R>): void {
  this.complete();
}
  • takeUntil subscribes and begins mirroring the source Observable. It also
  •  monitors a second Observable, notifier that you provide. If the notifier
  •  emits a value, the output Observable stops mirroring the source Observable
  • and completes. If the notifier doesn't emit any value and completes
  •  then takeUntil will pass all values.

Место отписки тоже имеет значение

const timerOne = Rx.Observable.timer(1000, 4000);

const timerTwo = Rx.Observable.timer(2000, 4000)

const timerThree = Rx.Observable.timer(3000, 4000)

const combined = Rx.Observable
.timer(2, 1000)
.takeUntil(this.componentDestroy)
.merge(
    timerOne,
    timerTwo,
    timerThree,
)
.subscribe(console.log) --> 0 0 1 2 3 4 1 5 6 7 8 2 9 10 11 12 3 13 14 15 16

Утечка памяти

merge создает новый Observable, takeUntil закроет только все, что до merge

Место отписки тоже имеет значение

const timerOne = Rx.Observable.timer(1000, 4000);

const timerTwo = Rx.Observable.timer(2000, 4000)

const timerThree = Rx.Observable.timer(3000, 4000)

const combined = Rx.Observable
.timer(2, 1000)
.merge(
    timerOne,
    timerTwo,
    timerThree,
)
.takeUntil(this.componentDestroy)
.subscribe(console.log) --> 0 0 1 2 3 4 1 5 6 7 8 2 9 10 11 12 3 13 14 15 16

Все хорошо

merge закроет все подписки

Отписываемся с помощью декораторов

Способы отписаться и Decorators

Слишком много лишнего кода, еще некрасивые присваивания

export function unsubscribeManager( blackList = [] ) {

  return function ( constructor ) {
    const originalOnDestroy = constructor.prototype.ngOnDestroy;

    constructor.prototype.ngOnDestroy = function () {
      for ( let prop in this ) {
        const property = this[ prop ];
        if ( !blackList.includes(prop) ) {
          if ( property && ( typeof property.unsubscribe === "function" ) ) {
            property.unsubscribe();
          }
        }
      }
      original &&
      typeof originalOnDestroy === 'function' && 
      originalOnDestroy(this, arguments);
    };
  }

}

Даем шанс takeUntil()

import { Subject } from 'rxjs/Subject';

export function TakeUntilDestroy(constructor: any) {
  const originalNgOnDestroy = constructor.prototype.ngOnDestroy;

  constructor.prototype.componentDestroy = function () {
    this._takeUntilDestroy$ = this._takeUntilDestroy$ || new Subject();
    return this._takeUntilDestroy$.asObservable();
  };

  constructor.prototype.ngOnDestroy = function () {
    if (this._takeUntilDestroy$) {
      this._takeUntilDestroy$.next(true);
      this._takeUntilDestroy$.complete();
    }
    if (originalNgOnDestroy && typeof originalNgOnDestroy === 'function') {
      originalNgOnDestroy.apply(this, arguments);
    }
  };
}

Что в других языках?

DISPOSABLE BAG RxSwift

Сomposite disposable RxJava

Как найти утечки...
Немного Хаков

Как найти утечки в RxJS и Angular?

18к

DETACHED NODES и angular

  • Angular добавляет нужные ему метаданные в DOM
  • Angular пытается уменьшить пересоздание в DOM, поэтому часть DOM не удаляется, чтобы в следующий раз быстрее создать компоненту

Angular Router Reuse Strategy

  • Стратегия по умолчанию переиспользовать компоненты если текущий и новый rout одинаковые

id: 1

'/ route / 1'

'/ route / 2'

id: 1

'/ route / 1'

'/ route / 2'

id: 2

ANGULAR ROUTER REUSE STRATEGY

Небольшой Хак и ES6 WeakSet

Ищем место, где Angular добавляет все компоненты в дерево

Собираем мусор, чистим консоль. Запускаем сборщик мусора.

Время ответить на

ваши вопросы

Спасибо за Внимание!

RxJS: Improving Perfomance

By Yauheni Pozdnyakov

RxJS: Improving Perfomance

  • 234