Wie kann ein Speicherverlust in einer Angular-Anwendung verursacht werden?

Leistung ist der Schlüssel zum Erfolg einer Webanwendung. Daher müssen Entwickler wissen, wie Speicherlecks auftreten und wie sie damit umgehen sollen.

Dieses Wissen ist besonders wichtig, wenn die Anwendung, mit der sich der Entwickler befasst, eine bestimmte Größe erreicht. Wenn Sie Speicherlecks nicht genügend Aufmerksamkeit schenken, kann es sein, dass der Entwickler in das „Team zur Beseitigung von Speicherlecks“ aufgenommen wird (ich musste Teil eines solchen Teams sein). Speicherlecks können aus verschiedenen Gründen auftreten. Ich glaube jedoch, dass bei der Verwendung von Angular möglicherweise ein Muster auftritt, das der häufigsten Ursache für Speicherverluste entspricht. Es gibt eine Möglichkeit, mit solchen Speicherlecks umzugehen. Und das Beste ist natürlich, Probleme nicht zu bekämpfen, sondern zu vermeiden.





Was ist Speicherverwaltung?


JavaScript verwendet ein automatisches Speicherverwaltungssystem. Der Speicherlebenszyklus besteht normalerweise aus drei Schritten:

  1. Zuweisung des erforderlichen Speichers.
  2. Arbeiten Sie mit zugewiesenem Speicher und führen Sie Lese- und Schreibvorgänge aus.
  3. Speicher freigeben, nachdem er nicht mehr benötigt wird.

Auf MDN sagt , dass die automatische Speicherverwaltung - ist es eine potentielle Quelle der Verwirrung. Dies kann Entwicklern das falsche Gefühl geben, dass sie sich nicht um die Speicherverwaltung kümmern müssen.

Wenn Sie sich überhaupt nicht um die Speicherverwaltung kümmern, bedeutet dies, dass nach dem Wachstum Ihrer Anwendung auf eine bestimmte Größe möglicherweise ein Speicherverlust auftritt.

Im Allgemeinen können Speicherverluste als der der Anwendung zugewiesener Speicher betrachtet werden, den sie nicht mehr benötigt, der jedoch nicht freigegeben wird. Mit anderen Worten, dies sind Objekte, bei denen keine Speicherbereinigungsvorgänge durchgeführt wurden.

Wie funktioniert die Speicherbereinigung?


Während der Garbage Collection-Prozedur, die ziemlich logisch ist, wird alles, was als "Garbage" betrachtet werden kann, gereinigt. Der Garbage Collector bereinigt den Speicher, den die Anwendung nicht mehr benötigt. Um herauszufinden, welche Speicherbereiche die Anwendung noch benötigt, verwendet der Garbage Collector den Algorithmus „Mark and Sweep“ (Tagging-Algorithmus). Wie der Name schon sagt, besteht dieser Algorithmus aus zwei Phasen - der Markierungsphase und der Sweep-Phase.

▍ Flaggenphase


Objekte und Links zu ihnen werden in Form eines Baumes dargestellt. Die Wurzel des Baums ist in der folgenden Abbildung ein Knoten root. In JavaScript ist dies ein Objekt window. Jedes Objekt hat eine spezielle Flagge. Nennen wir diese Flagge marked. In der Flagging-Phase werden zunächst alle Flags markedauf einen Wert gesetzt false.


Zu Beginn werden die Flags markierter Objekte auf false gesetzt.

Anschließend wird der Objektbaum durchlaufen. Alle Flags vonmarkedObjekten, die vom Knoten aus erreichbar sind,rootwerden auf gesetzttrue. Und die Flags der Objekte, die nicht erreicht werden können, bleiben im Wertfalse.

Ein Objekt gilt als nicht erreichbar, wenn es vom Stammobjekt aus nicht erreicht werden kann.


Erreichbare Objekte werden als markiert = wahr, nicht erreichbare Objekte als markiert = falsch markiert.

Dahermarkedbleibenalle Flagsnicht erreichbarer Objekte im Wertfalse. Der Speicher wurde noch nicht freigegeben, aber nach Abschluss der Markierungsphase ist alles für die Reinigungsphase bereit.

▍ Reinigungsphase


Der Speicher wird genau in dieser Phase des Algorithmus gelöscht. Hier werden alle nicht erreichbaren Objekte (deren Flag markedim Wert verbleibt false) vom Garbage Collector zerstört.


Objektbaum nach Speicherbereinigung. Alle Objekte, deren markiertes Flag auf false gesetzt ist, werden vom Garbage Collector zerstört. Die

Garbage Collection wird regelmäßig ausgeführt, während das JavaScript-Programm ausgeführt wird. Während dieses Vorgangs wird Speicher freigegeben, der freigegeben werden kann.

Möglicherweise stellt sich hier die folgende Frage: "Wenn der Garbage Collector alle als nicht erreichbar gekennzeichneten Objekte entfernt - wie kann ein Speicherverlust verursacht werden?".

Der Punkt hier ist, dass das Objekt nicht vom Garbage Collector verarbeitet wird, wenn die Anwendung es nicht benötigt, Sie es aber dennoch vom Stammknoten des Objektbaums aus erreichen können.

Der Algorithmus kann nicht wissen, ob die Anwendung einen Speicher verwendet, auf den sie zugreifen kann oder nicht. Nur ein Programmierer hat solche Kenntnisse.

Winkelspeicherlecks


In den meisten Fällen treten Speicherverluste im Laufe der Zeit auf, wenn eine Komponente wiederholt neu gerendert wird. Zum Beispiel - durch Routing oder als Ergebnis der Verwendung der Direktive *ngIf. Angenommen, in einer Situation, in der ein fortgeschrittener Benutzer den ganzen Tag mit der Anwendung arbeitet, ohne die Anwendungsseite im Browser zu aktualisieren.

Um dieses Szenario zu reproduzieren, erstellen wir eine Konstruktion aus zwei Komponenten. Dies werden die Komponenten AppComponentund sein SubComponent.

@Component({
  selector: 'app-root',
  template: `<app-sub *ngIf="hide"></app-sub>`
})
export class AppComponent {
  hide = false;

  constructor() {
    setInterval(() => this.hide = !this.hide, 50);
  }
}

Die Komponentenvorlage AppComponentverwendet die Komponente app-sub. Das Interessanteste dabei ist, dass unsere Komponente eine Funktion verwendet setInterval, die hidealle 50 ms das Flag wechselt . Dies führt dazu, dass eine Komponente alle 50 ms neu gerendert wird app-sub. Das heißt, die Erstellung neuer Instanzen der Klasse wird durchgeführt SubComponent. Dieser Code ahmt das Verhalten eines Benutzers nach, der den ganzen Tag mit einer Webanwendung arbeitet, ohne eine Seite in einem Browser zu aktualisieren.

Wir SubComponenthaben verschiedene Szenarien implementiert, bei deren Verwendung im Laufe der Zeit Änderungen in der von der Anwendung verwendeten Speichermenge auftreten. Beachten Sie, dass die KomponenteAppComponentbleibt immer gleich In jedem Szenario werden wir herausfinden, ob es sich um einen Speicherverlust handelt, indem wir den Speicherverbrauch des Browserprozesses analysieren.

Wenn der Speicherverbrauch des Prozesses mit der Zeit zunimmt, bedeutet dies, dass wir mit einem Speicherverlust konfrontiert sind. Wenn ein Prozess eine mehr oder weniger konstante Speichermenge verwendet, bedeutet dies entweder, dass kein Speicherverlust vorliegt oder dass sich der Verlust, obwohl vorhanden, nicht auf ziemlich offensichtliche Weise manifestiert.

▍ Szenario 1: große for-Schleife


Unser erstes Szenario wird durch eine Schleife dargestellt, die 100.000 Mal ausgeführt wird. In der Schleife werden dem Array zufällige Werte hinzugefügt. Vergessen wir nicht, dass die Komponente alle 50 ms neu gerendert wird. Schauen Sie sich den Code an und überlegen Sie, ob wir einen Speicherverlust verursacht haben oder nicht.

@Component({
  selector:'app-sub',
  // ...
})
export class SubComponent {
  arr = [];

  constructor() {
    for (let i = 0; i < 100000; ++i) {
      this.arr.push(Math.random());
    }
  }
}

Obwohl ein solcher Code nicht an die Produktion gesendet werden sollte, entsteht kein Speicherverlust. Der Speicherverbrauch überschreitet nämlich nicht den auf einen Wert von 15 MB begrenzten Bereich. Infolgedessen tritt kein Speicherverlust auf. Im Folgenden werden wir darüber sprechen, warum dies so ist.

▍ Szenario 2: BehaviorSubject-Abonnement


In diesem Szenario abonnieren wir BehaviorSubjecteine Konstante und weisen ihr einen Wert zu. Gibt es einen Speicherverlust in diesem Code? Vergessen Sie nach wie vor nicht, dass die Komponente alle 50 ms gerendert wird.

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  subject = new BehaviorSubject(42);
  
  constructor() {
    this.subject.subscribe(value => {
        const foo = value;
    });
  }
}

Hier gibt es wie im vorherigen Beispiel keinen Speicherverlust.

▍ Szenario 3: Zuweisen eines Werts zu einem Klassenfeld innerhalb eines Abonnements


Hier wird fast derselbe Code wie im vorherigen Beispiel dargestellt. Der Hauptunterschied besteht darin, dass der Wert nicht einer Konstanten, sondern einem Klassenfeld zugewiesen wird. Und denken Sie jetzt, dass der Code undicht ist?

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  subject = new BehaviorSubject(42);
  randomValue = 0;
  
  constructor() {
    this.subject.subscribe(value => {
        this.randomValue = value;
    });
  }
}

Wenn Sie glauben, dass hier kein Leck vorhanden ist, haben Sie absolut Recht.

In Szenario 1 gibt es kein Abonnement. In den Szenarien Nr. 2 und 3 haben wir den Stream des beobachteten Objekts abonniert, der in unserer Komponente initialisiert wurde. Es fühlt sich so an, als wären wir sicher, wenn wir Komponentenflüsse abonnieren.

Aber was ist, wenn wir unserem Programm Service hinzufügen?

Szenarien, die den Dienst verwenden


In den folgenden Szenarien werden wir die obigen Beispiele überarbeiten, aber dieses Mal werden wir den vom Dienst bereitgestellten Stream abonnieren DummyService. Hier ist der Servicecode.

@Injectable({
  providedIn: 'root'
})
export class DummyService {

   some$ = new BehaviorSubject<number>(42);
}

Vor uns liegt ein einfacher Service. Dies ist nur ein Dienst, der stream ( some$) in Form eines öffentlichen Klassenfelds bereitstellt .

▍ Szenario 4: Abonnieren eines Streams und Zuweisen eines Werts zu einer lokalen Konstante


Wir werden hier das gleiche Schema neu erstellen, das bereits zuvor beschrieben wurde. Diesmal abonnieren wir jedoch den Stream some$von DummyServiceund nicht das Feld der Komponente.

Gibt es ein Speicherleck? Denken Sie bei der Beantwortung dieser Frage erneut daran, dass die Komponente häufig verwendet AppComponentund gerendert wird.

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  
  constructor(private dummyService: DummyService) {
    this.dummyService.some$.subscribe(value => {
        const foo = value;
    });
  }
}

Und jetzt haben wir endlich ein Speicherleck erzeugt. Aber das ist ein kleines Leck. Mit "kleines Leck" meine ich eines, das im Laufe der Zeit zu einem langsamen Anstieg des verbrauchten Speichers führt. Dieser Anstieg ist kaum spürbar, aber eine flüchtige Überprüfung des Heap-Snapshots ergab, dass viele nicht gelöschte Instanzen vorhanden sind Subscriber.

▍ Szenario 5: Abonnieren eines Dienstes und Zuweisen eines Werts zu einem Klassenfeld


Hier abonnieren wir noch einmal dummyService. Diesmal weisen wir den resultierenden Wert dem Klassenfeld zu und nicht einer lokalen Konstante.

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  randomValue = 0;
  
  constructor(private dummyService: DummyService) {
    this.dummyService.some$.subscribe(value => {
        this.randomValue = value;
    });
  }
}

Und hier haben wir endlich einen signifikanten Speicherverlust verursacht. Der Speicherverbrauch überschritt innerhalb einer Minute schnell 1 GB. Sprechen wir darüber, warum das so ist.

▍Wann ist ein Speicherverlust aufgetreten?


Möglicherweise haben Sie bemerkt, dass wir in den ersten drei Szenarien keinen Speicherverlust verursachen konnten. Diese drei Szenarien haben etwas gemeinsam: Alle Links sind lokal für die Komponente.

Wenn wir ein beobachtbares Objekt abonnieren, wird eine Liste von Abonnenten gespeichert. Unser Rückruf befindet sich ebenfalls in dieser Liste, und der Rückruf kann sich auf unsere Komponente beziehen.


Kein Speicherverlust

Wenn eine Komponente zerstört wird, dh wenn Angular keine Verbindung mehr zu ihr hat, was bedeutet, dass die Komponente nicht vom Wurzelknoten aus erreichbar ist, können das beobachtete Objekt und seine Liste der Abonnenten auch nicht vom Wurzelknoten aus erreicht werden. Infolgedessen wird das gesamte Komponentenobjekt durch Müll gesammelt.

Solange wir ein beobachtbares Objekt abonniert haben, zu dem nur Links innerhalb der Komponente gehören, treten keine Probleme auf. Aber wenn der Dienst ins Spiel kommt, ändert sich die Situation.


Speicherverlust

Sobald wir ein beobachtbares Objekt abonniert haben, das von einem Dienst oder einer anderen Klasse bereitgestellt wurde, haben wir einen Speicherverlust erstellt. Dies liegt an dem beobachteten Objekt aufgrund seiner Abonnentenliste. Aus diesem Grund kann auf den Rückruf und damit auf die Komponente vom Stammknoten aus zugegriffen werden, obwohl Angular keinen direkten Verweis auf die Komponente hat. Infolgedessen berührt der Garbage Collector das entsprechende Objekt nicht.

Ich werde klarstellen: Sie können solche Konstruktionen verwenden, aber Sie müssen korrekt damit arbeiten und nicht wie wir.

Ordnungsgemäße Abonnementarbeiten


Um Speicherverluste zu vermeiden, ist es wichtig, das beobachtete Objekt korrekt abzubestellen, wenn das Abonnement nicht mehr benötigt wird. Zum Beispiel, wenn eine Komponente zerstört wird. Es gibt viele Möglichkeiten, sich von einem beobachteten Objekt abzumelden.

Die Erfahrung mit der Beratung von Eigentümern großer Unternehmensprojekte zeigt, dass es in dieser Situation am besten ist, die destroy$vom Team erstellte Entität new Subject<void>()in Kombination mit dem Betreiber zu verwenden 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();
  }
}

Hier kündigen wir das Abonnement mit dem destroy$Operator und takeUntilnach der Zerstörung der Komponente.

Wir haben einen Lifecycle-Hook in die Komponente implementiert ngOnDestroy. Jedes Mal, wenn eine Komponente zerstört wird, rufen wir destroy$Methoden nextund auf complete.

Der Anruf ist completesehr wichtig, da durch diesen Anruf das Abonnement gelöscht wird destroy$.

Dann benutzen wir den Operator takeUntilund übergeben ihm unseren Stream destroy$. Dadurch wird sichergestellt, dass das Abonnement gelöscht wird (dh dass wir das Abonnement abbestellt haben), nachdem die Komponente zerstört wurde.

Wie kann man daran denken, Abonnements zu löschen?


Es ist leicht zu vergessen, die Komponente hinzuzufügen destroy$und den Aufruf nextsowie den completeHook-Lebenszyklus zu vergessen ngOnDestroy. Obwohl ich dies Teams beigebracht habe, die an Projekten arbeiten, habe ich es selbst oft vergessen.

Glücklicherweise gibt es eine wunderbare Linter-Regel, die Teil einer Reihe von Regeln ist , mit denen Sie sicherstellen können, dass Abonnements ordnungsgemäß abgemeldet werden. Sie können einen Regelsatz wie folgt festlegen:

npm install @angular-extensions/lint-rules --save-dev

Dann muss es verbunden sein mit tslint.json:

{
  "extends": [
    "tslint:recommended",
    "@angular-extensions/lint-rules"
  ]
}

Ich empfehle Ihnen dringend, diese Regeln in Ihren Projekten zu verwenden. Dies erspart Ihnen viele Stunden beim Debuggen beim Auffinden von Speicherleckquellen.

Zusammenfassung


In Angular ist es sehr einfach, eine Situation zu erstellen, die zu Speicherverlusten führt. Selbst kleine Codeänderungen an Stellen, die anscheinend nicht mit Speicherlecks zusammenhängen sollten, können schwerwiegende nachteilige Folgen haben.

Der beste Weg, um Speicherlecks zu vermeiden, besteht darin, Ihre Abonnements korrekt zu verwalten. Leider erfordert der Betrieb von Reinigungsabonnements vom Entwickler eine hohe Genauigkeit. Das ist leicht zu vergessen. Daher wird empfohlen, Regeln anzuwenden, mit @angular-extensions/lint-rulesdenen Sie die richtige Arbeit mit Ihren Abonnements organisieren können.

Hier ist das Repository mit dem Code, der diesem Material zugrunde liegt.

Sind in Angular-Anwendungen Speicherlecks aufgetreten?


All Articles