Comment provoquer une fuite de mémoire dans une application Angular?

La performance est la clé du succÚs d'une application Web. Par conséquent, les développeurs doivent savoir comment les fuites de mémoire se produisent et comment y faire face.

Cette connaissance est particuliÚrement importante lorsque l'application avec laquelle le développeur traite atteint une certaine taille. Si vous ne faites pas assez attention aux fuites de mémoire, alors tout peut se retrouver avec le développeur entrant dans «l'équipe pour éliminer les fuites de mémoire» (je devais faire partie d'une telle équipe). Des fuites de mémoire peuvent se produire pour diverses raisons. Cependant, je pense que lorsque vous utilisez Angular, vous pouvez rencontrer un modÚle qui correspond à la cause la plus courante des fuites de mémoire. Il existe un moyen de gérer ces fuites de mémoire. Et la meilleure chose, bien sûr, n'est pas de lutter contre les problÚmes, mais de les éviter.





Qu'est-ce que la gestion de la mémoire?


JavaScript utilise un systÚme de gestion automatique de la mémoire. Le cycle de vie de la mémoire se compose généralement de trois étapes:

  1. Allocation de la mémoire nécessaire.
  2. Travailler avec la mémoire allouée, effectuer des opérations de lecture et d'écriture.
  3. Libérer de la mémoire une fois qu'elle n'est plus nécessaire.

Sur MDN dit que la gestion automatique de la mémoire - c'est une source potentielle de confusion. Cela peut donner aux développeurs une fausse impression qu'ils n'ont pas à se soucier de la gestion de la mémoire.

Si vous ne vous souciez pas du tout de la gestion de la mémoire, cela signifie qu'aprÚs que votre application aura atteint une certaine taille, vous risquez de rencontrer une fuite de mémoire.

En gĂ©nĂ©ral, les fuites de mĂ©moire peuvent ĂȘtre considĂ©rĂ©es comme la mĂ©moire allouĂ©e Ă  l'application, dont elle n'a plus besoin, mais qui n'est pas libĂ©rĂ©e. En d'autres termes, ce sont des objets qui n'ont pas pu subir d'opĂ©rations de rĂ©cupĂ©ration de place.

Comment fonctionne la collecte des ordures?


Au cours de la procĂ©dure de collecte des ordures, ce qui est assez logique, tout ce qui peut ĂȘtre considĂ©rĂ© comme des «ordures» est nettoyĂ©. Le garbage collector nettoie la mĂ©moire dont l'application n'a plus besoin. Afin de dĂ©terminer les zones de mĂ©moire dont l'application a encore besoin, le garbage collector utilise l'algorithme «mark and sweep» (algorithme de balisage). Comme son nom l'indique, cet algorithme se compose de deux phases - la phase de marquage et la phase de balayage.

▍ Phase de drapeau


Les objets et leurs liens sont prĂ©sentĂ©s sous la forme d'un arbre. La racine de l'arbre est, dans la figure suivante, un nƓud root. En JavaScript, c'est un objet window. Chaque objet a un drapeau spĂ©cial. Appelons ce drapeau marked. Dans la phase de signalisation, tout d'abord, tous les indicateurs markedsont dĂ©finis sur une valeur false.


Au début, les drapeaux des objets marqués sont mis à faux,

puis l'arborescence d'objets est parcourue. Tous les drapeaux d'markedobjets accessibles Ă  partir du nƓudrootsont dĂ©finis surtrue. Et les drapeaux de ces objets qui ne peuvent pas ĂȘtre atteints, restent dans la valeurfalse.

Un objet est considĂ©rĂ© comme inaccessible s'il ne peut pas ĂȘtre atteint Ă  partir de l'objet racine.


Les objets accessibles sont marqués comme étant marqués = true, les objets inaccessibles comme marqués = false.

Par consĂ©quent, tous les indicateurs d'markedobjets inaccessibles restent dans la valeurfalse. La mĂ©moire n'a pas encore Ă©tĂ© libĂ©rĂ©e, mais, une fois la phase de marquage terminĂ©e, tout est prĂȘt pour la phase de nettoyage.

▍ Phase de nettoyage


La mémoire est effacée précisément à cette phase de l'algorithme. Ici, tous les objets inaccessibles (ceux dont l'indicateur markedreste dans la valeur false) sont détruits par le garbage collector.


Arborescence d'objets aprÚs la récupération de place. Tous les objets dont l'indicateur marqué est défini sur false sont détruits par le garbage collector. Le

garbage collection est effectuĂ© pĂ©riodiquement pendant l'exĂ©cution du programme JavaScript. Au cours de cette procĂ©dure, la mĂ©moire est libĂ©rĂ©e et peut ĂȘtre libĂ©rĂ©e.

Peut-ĂȘtre la question suivante se pose ici: "Si le garbage collector supprime tous les objets marquĂ©s comme inaccessibles - comment crĂ©er une fuite de mĂ©moire?".

Le point ici est que l'objet ne sera pas traitĂ© par le garbage collector si l'application n'en a pas besoin, mais vous pouvez toujours l'atteindre Ă  partir du nƓud racine de l'arborescence d'objets.

L'algorithme ne peut pas savoir si l'application utilisera une partie de la mémoire à laquelle elle peut accéder ou non. Seul un programmeur possÚde une telle connaissance.

Fuites de mémoire angulaire


Le plus souvent, des fuites de mĂ©moire se produisent au fil du temps lorsqu'un composant est rendu Ă  plusieurs reprises. Par exemple - via le routage ou suite Ă  l'utilisation de la directive *ngIf. Disons, dans une situation oĂč certains utilisateurs avancĂ©s travaillent avec l'application toute la journĂ©e sans mettre Ă  jour la page de l'application dans le navigateur.

Afin de reproduire ce scénario, nous allons créer une construction de deux composants. Ce seront les composants AppComponentet SubComponent.

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

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

Le modÚle de composant AppComponentutilise le composant app-sub. La chose la plus intéressante ici est que notre composant utilise une fonction setIntervalqui commute l'indicateur hidetoutes les 50 ms. Il en résulte qu'un composant est restitué toutes les 50 ms app-sub. Autrement dit, la création de nouvelles instances de la classe est effectuée SubComponent. Ce code imite le comportement d'un utilisateur qui travaille toute la journée avec une application Web sans actualiser une page dans un navigateur.

Nous avons, en SubComponent, mis en Ɠuvre diffĂ©rents scĂ©narios, dans l'utilisation desquels, au fil du temps, des changements dans la quantitĂ© de mĂ©moire utilisĂ©e par l'application commencent Ă  apparaĂźtre. Notez que le composantAppComponentreste toujours le mĂȘme. Dans chaque scĂ©nario, nous dĂ©couvrirons si nous avons affaire Ă  une fuite de mĂ©moire en analysant la consommation de mĂ©moire du processus du navigateur.

Si la consommation de mémoire du processus augmente avec le temps, cela signifie que nous sommes confrontés à une fuite de mémoire. Si un processus utilise une quantité de mémoire plus ou moins constante, cela signifie soit qu'il n'y a pas de fuite de mémoire, soit que la fuite, bien que présente, ne se manifeste pas de maniÚre assez évidente.

▍ ScĂ©nario # 1: Ă©norme pour la boucle


Notre premier scĂ©nario est reprĂ©sentĂ© par une boucle qui s'exĂ©cute 100 000 fois. Dans la boucle, des valeurs alĂ©atoires sont ajoutĂ©es au tableau. N'oublions pas que le composant est restituĂ© toutes les 50 ms. Jetez un Ɠil au code et rĂ©flĂ©chissez si nous avons crĂ©Ă© une fuite de mĂ©moire ou non.

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

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

Bien que ce code ne doive pas ĂȘtre envoyĂ© en production, il ne crĂ©e pas de fuite de mĂ©moire. À savoir, la consommation de mĂ©moire ne dĂ©passe pas la plage limitĂ©e Ă  une valeur de 15 Mo. Par consĂ©quent, il n'y a aucune fuite de mĂ©moire. Ci-dessous, nous expliquerons pourquoi il en est ainsi.

▍ ScĂ©nario 2: abonnement Ă  BehaviorSubject


Dans ce scénario, nous nous abonnons BehaviorSubjectet attribuons une valeur à une constante. Y a-t-il une fuite de mémoire dans ce code? Comme précédemment, n'oubliez pas que le composant est rendu toutes les 50 ms.

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

Ici, comme dans l'exemple précédent, il n'y a pas de fuite de mémoire.

▍ ScĂ©nario 3: attribuer une valeur Ă  un champ de classe dans un abonnement


Ici, presque le mĂȘme code est prĂ©sentĂ© comme dans l'exemple prĂ©cĂ©dent. La principale diffĂ©rence est que la valeur n'est pas attribuĂ©e Ă  une constante, mais Ă  un champ de classe. Et maintenant, pensez-vous qu'il y a une fuite dans le code?

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

Si vous croyez qu'il n'y a pas de fuite ici - vous avez absolument raison.

Dans le scénario # 1, il n'y a pas d'abonnement. Dans les scénarios n ° 2 et 3, nous avons souscrit au flux de l'objet observé initialisé dans notre composante. Il semble que nous soyons en sécurité en souscrivant aux flux de composants.

Mais que se passe-t-il si nous ajoutons un service Ă  notre programme?

Scénarios utilisant le service


Dans les scénarios suivants, nous allons réviser les exemples ci-dessus, mais cette fois, nous nous abonnerons au flux fourni par le service DummyService. Voici le code de service.

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

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

Devant nous est un service simple. Il s'agit simplement d'un service qui fournit stream ( some$) sous la forme d'un champ de classe publique.

▍ ScĂ©nario 4: abonnement Ă  un flux et attribution d'une valeur Ă  une constante locale


Nous recrĂ©erons ici le mĂȘme schĂ©ma que celui dĂ©jĂ  dĂ©crit prĂ©cĂ©demment. Mais cette fois, nous nous abonnons au flux some$de DummyService, et non au champ du composant.

Y a-t-il une fuite de mémoire? Encore une fois, lorsque vous répondez à cette question, n'oubliez pas que le composant est utilisé AppComponentet rendu plusieurs fois.

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

Et maintenant, nous avons finalement créé une fuite de mémoire. Mais c'est une petite fuite. Par "petite fuite", j'entends celle qui, avec le temps, entraßne une lente augmentation de la quantité de mémoire consommée. Cette augmentation est à peine perceptible, mais une inspection rapide de l'instantané du tas a montré la présence de nombreuses instances non supprimées Subscriber.

▍ ScĂ©nario 5: abonnement Ă  un service et attribution d'une valeur Ă  un champ de classe


Ici, nous nous abonnons à nouveau à dummyService. Mais cette fois, nous attribuons la valeur résultante au champ de classe, et non une constante locale.

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

Et ici, nous avons finalement créé une fuite de mémoire importante. La consommation de mémoire rapidement, en moins d'une minute, a dépassé 1 Go. Parlons pourquoi il en est ainsi.

HenQuand une fuite de mémoire s'est-elle produite?


Vous avez peut-ĂȘtre remarquĂ© que dans les trois premiers scĂ©narios, nous n'avons pas pu crĂ©er de fuite de mĂ©moire. Ces trois scĂ©narios ont quelque chose en commun: tous les liens sont locaux vers le composant.

Lorsque nous nous abonnons à un objet observable, il stocke une liste d'abonnés. Notre rappel est également sur cette liste, et le rappel peut se référer à notre composant.


Aucune fuite de mémoire

Lorsqu'un composant est dĂ©truit, c'est-Ă -dire lorsque Angular n'a plus de lien vers lui, ce qui signifie que le composant ne peut pas ĂȘtre atteint depuis le nƓud racine, l'objet observĂ© et sa liste d'abonnĂ©s ne peuvent pas non plus ĂȘtre atteints depuis le nƓud racine. Par consĂ©quent, l'objet composant entier est rĂ©cupĂ©rĂ©.

Tant que nous sommes abonnés à un objet observable, dont les liens ne sont qu'à l'intérieur du composant, aucun problÚme ne se pose. Mais lorsque le service entre en jeu, la situation change.


Fuite de mémoire

DĂšs que nous nous sommes abonnĂ©s Ă  un objet observable fourni par un service ou une autre classe, nous avons crĂ©Ă© une fuite de mĂ©moire. Cela est dĂ» Ă  l'objet observĂ©, en raison de sa liste d'abonnĂ©s. Pour cette raison, le rappel, et donc le composant, sont accessibles Ă  partir du nƓud racine, bien qu'Angular n'ait pas de rĂ©fĂ©rence directe au composant. Par consĂ©quent, le garbage collector ne touche pas l'objet correspondant.

Je vais clarifier: vous pouvez utiliser de telles constructions, mais vous devez travailler avec elles correctement, et pas comme nous le faisons.

Bon travail d'abonnement


Afin d'éviter une fuite de mémoire, il est important de se désinscrire correctement de l'objet observé, en le faisant lorsque l'abonnement n'est plus nécessaire. Par exemple, lorsqu'un composant est détruit. Il existe de nombreuses façons de se désinscrire d'un objet observé.

L'expérience de conseiller les propriétaires de grands projets d'entreprise indique que dans cette situation, il est préférable d'utiliser l'entité destroy$créée par l'équipe new Subject<void>()en combinaison avec l'opérateur 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();
  }
}

Ici, nous nous désinscrivons de l'abonnement en utilisant l' destroy$opérateur et takeUntilaprÚs la destruction du composant.

Nous avons implémenté un hook de cycle de vie dans le composant ngOnDestroy. Chaque fois qu'un composant est détruit, nous appelons les destroy$méthodes nextet complete.

L'appel est completetrĂšs important car cet appel efface l'abonnement destroy$.

Ensuite, nous utilisons l'opérateur takeUntilet lui transmettons notre flux destroy$. Cela garantit que l'abonnement est effacé (c'est-à-dire que nous nous sommes désabonnés de l'abonnement) aprÚs la destruction du composant.

Comment ne pas oublier d'effacer les abonnements?


Il est facile d'oublier d'ajouter le composant destroy$et d'oublier d'appeler next, ainsi que completedans le cycle de vie du crochet ngOnDestroy. MĂȘme si j'ai enseignĂ© cela aux Ă©quipes travaillant sur des projets, je l'ai souvent oubliĂ© moi-mĂȘme.

Heureusement, il existe une merveilleuse rÚgle de linter, qui fait partie d'un ensemble de rÚgles qui vous permet d'assurer une bonne désinscription des abonnements. Vous pouvez définir un ensemble de rÚgles comme celui-ci:

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

Ensuite, il doit ĂȘtre connectĂ© Ă  tslint.json:

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

Je vous recommande fortement d'utiliser cet ensemble de rÚgles dans vos projets. Cela vous fera économiser de nombreuses heures de débogage pour trouver les sources de fuites de mémoire.

Sommaire


Dans Angular, il est trĂšs facile de crĂ©er une situation entraĂźnant des fuites de mĂ©moire. MĂȘme de petits changements de code dans des endroits qui, apparemment, ne devraient pas ĂȘtre liĂ©s Ă  des fuites de mĂ©moire, peuvent entraĂźner de graves consĂ©quences nĂ©fastes.

La meilleure façon d'éviter les fuites de mémoire est de gérer correctement vos abonnements. Malheureusement, l'opération de nettoyage des abonnements nécessite une grande précision de la part du développeur. C'est facile à oublier. Par conséquent, il est recommandé d'appliquer des rÚgles @angular-extensions/lint-rulesqui vous aident à organiser le bon travail avec vos abonnements.

Voici le référentiel avec le code sous-jacent à ce matériel.

Avez-vous rencontré des fuites de mémoire dans des applications angulaires?


All Articles