9 Sep 2016

NodeJS et le Garbage Collector

Bonjour à tous,

Nous nous retrouvons autour d’un deuxième article autour de Node. Comme nous le disions dans un précédent article (https://blog.axawebcenter.fr/javascript/nodejs-est-il-monothreade/) si jamais vous surchargez l’ « event loop » avec :

  • des traitements trop CPUvore,
  • ou trop d’instructions,

… alors vous rencontrerez des problèmes de performance. Mais sachez également que des soucis peuvent se produire si vos traitements malmènent la mémoire…

Quoi ? Node a besoin de mémoire ?

Et oui il faut stocker (temporairement) le code à interpréter, les valeurs primaires et les objets complexes afin d’effectuer les traitements que vous exécutez.

Dans cet article nous allons voir pourquoi  Node consomme de la mémoire et comment il la gère ! De plus, nous allons proposer quelques pistes pour identifier les fuites de mémoire.

Mais comment gère-t-on la mémoire dans mon code javascript ?

En fait, le code javascript (écrit pour Node) ne peut pas manipuler directement la mémoire comme il pourrait être fait en C (malloc, calloc, etc.), exemple :

En effet, en aucun cas, le développeur n’aura à manipuler la mémoire. C’est en interne que Node va gérer la mémoire. Ainsi pendant l’exécution d’un processus Node, ce dernier va stocker dynamiquement les objets et libérer de la mémoire quand il en ressentira le besoin. Ce mécanisme de libération de mémoire se nomme le « Garbage Collector ». Le gros avantage d’une mémoire managée est que nous (les développeurs) n’avons pas à la gérer nous-même. Cela nous évite de nombreuses d’erreurs. Ce qu’il faut comprendre par-là, c’est que concrètement dans le code nous ne manipulons que des références vers des objets et que Node va lui-même organiser la mémoire nécessaire à notre application.

Et comment sait-il ce qu’il doit supprimer ?

Node va essayer de déterminer quelles sont les objets que le code n’utilise plus. Un  objet qui ne peut plus être atteint est éligible au garbage collector (id est qui n’a aucune référence qui pointe vers lui). Tous les objets sont par définition rattachés à un scope (https://developer.mozilla.org/fr/docs/Web/JavaScript/Reference/Fonctions)  en action ou un objet root accessible depuis n’importe où depuis le processus. Typiquement les objets encore atteignables sont référencés dans la call stack (exemple : toutes les variables locales et paramètres de la fonction en cours d’exécution) ou par la variable global (qui est attachée au processus V8).

garbagable

La stratégie utilisée par V8 est appelée « Tagged Pointer » : à la fin de chaque espace mémoire donné, nous allons réserver quelques bits pour indiquer si l’objet fait référence à un autre objet ou à un integer/string/etc… Comme la mémoire est rangée « à plat » cela permet au GC de se déplacer plus facilement au sein de la mémoire.

taggedPointer

Le mécanisme de « Garbage Collection » est très coûteux car il doit régulièrement inspecter chaque objet et identifier s’il est encore référencé. De plus, une fois des objets non utilisés identifiés il doit les supprimer. Afin de faire cela il doit interrompre l’exécution de l’applicatif et cela impacte les performances.

L’objectif du GC va être de libérer rapidement de la mémoire et pour ce faire il va devoir optimiser l’organisation et l’utilisation de cette dernière. Durant la partie nettoyage, il va essayer d’interrompre au minimum l’exécution du programme. Ces interruptions sont appelées des « STOP THE WORLD » car le code de l’application n’est plus exécuté durant ces pauses.

V8 (le moteur de Node : https://nodejs.org/api/v8.html ) utilise différents algorithmes de « garbage collector » afin de nettoyer la mémoire, nous en distinguons deux types :

  • le Scavenge : un garbage collector rapide, efficace mais incomplet. Il est exécuté beaucoup plus fréquemment que le Mark-Sweep,
  • le Mark-Sweep / le Mark-Compact : des garbages collector plus lents mais qui nettoient complètement les objets non utilisés. Nous les appelons des full GC.

Comment Node s’y prend pour savoir s’il doit jouer le GC Scavenge ou un des Full GC comme le Mark-Sweep / Mark-Compact ?

Node va d’abord découper la mémoire que l’OS lui attribue en différentes zones afin de structurer les données de nos applications :

  • New-space : La plupart des objets sont créés ici. Cette zone est très petite et les objets y sont analysés très fréquemment par le GC (garbage collector)
  • Old-pointer-space : Contient la plupart des objets qui ont des références vers d’autres objets. Les objets arrivent ici après avoir survécu au GC qui a tourné dans le new-space.
  • Old-data-space : Contient des objets qui contiennent seulement des données brutes (pas de pointeurs vers d’autres objets). Les Strings, Numbers, et les tableaux de nombres primaires sont déplacés ici après avoir survécu au New-space.
  • Large-object-space : Contient les objets trop volumineux qui dépassent les limites imposées par les autres espaces. Chaque objet possède sa propre zone de mémoire gérée via une mmap. Cette zone n’est jamais nettoyée.
  • Code-space : Contient le code à interpréter.
  • Cell-space, property-cell-space and map-space: Contient des cellules, des propriétés de cellules, des maps. Ces zones sont cappées en taille.

Chaque espace est composé d’un ensemble de “pages”.  Une “page” est un ensemble contigu de zones mémoires, alloué par le système d’exploitation avec mmap. Les pages sont toujours de 1 Mo alignés (sauf dans le cas du Large-object-space , où ils peuvent être plus grands).

Quand le GC est exécuté il va soit supprimer des objets et libérer de la mémoire, soit les déplacer dans la zone mémoire la plus adaptée à la rétention de vie demandée par le code de l’application (c’est là que vous intervenez). Bien entendu, à chaque fois qu’un objet change de zone mémoire, cela est transparent pour l’application puisque les références sont mises à jour par V8.

Quand le « new-space » est entièrement rempli, un GC scavenge est démandé. Pour les « full » GC, il faut attendre un certain pourcentage d’ « old-space » occupés pour que l’exécution soit demandée.

Ces deux algorithmes opèrent en deux phases :

  1. Une phase d’identification: le GC va parcourir tous les objets de la heap (id est un parcours d’arbre) et les marquer (tagged pointer) d’une « couleur » : blanc (non connu par le GC), gris (connu mais les objets qui le pointent ne le sont pas encore), noir (objet et ses références toutes identifiées). A la fin du parcours il ne reste que des objets blancs et noirs et tous les objets à supprimer sont blancs. Cette phase se termine ici. Afin de gagner en performance et limiter les moments de « Stop the world », il faut noter que la phase d’identification est déclenchée fréquemment, nous parlons d’Incremental marking.
  1. Une phase swapping ou de compactage: l’objectif ici et de gagner de l’espace mémoire et de le réorganiser. Lors d’un GC mark-sweep on ne va faire « que » supprimer les objets blancs. Alors que dans un GC mark-compact nous allons essayer de réorganiser la mémoire au maximum pour libérer réellement de la mémoire. Des objets vont être supprimés, d’autres déplacés, etc. le GC « mark sweep » peut être lancé plus fréquemment sans bloquer le processus car il travaille page mémoire par page mémoire et sait au moment de se lancer sur quels objets il peut travailler sans « gêner » l’application. Ce GC mark sweep peut même se faire en parallèle.

Comment savoir si j’ai une fuite mémoire ?

Et bien très simplement, en observant la mémoire que consomme votre application. Si ça ne fait qu’augmenter (et que cette consommation de mémoire n’est plus corrélée à l’activité des utilisateurs) alors il y a certainement un problème :

leakMemory

Ici nous pouvons constater que la consommation mémoire ne fait qu’augmenter. Aucun GC ne libère de l’espace. Au bout d’un moment l’ensemble de la mémoire sera utilisée et l’application crashera.

Si vous avez du mal à mettre en exergue une fuite mémoire, procéder à des tirs de performances peut aider à localiser le scénario (et donc la partie de l’application) qui pose un souci.

Sur notre projet des tirs de performance sont régulièrement faits afin de surveiller l’utilisation de la mémoire et des processeurs (des sondes sont posées sur les machines pour récupérer les données mémoire et CPU). Comme vous pouvez le voir sur les graphiques suivant les tirs de performances n’ont pas mis à mal l’application.

leakMemory2

leakMemory3

Comment traquer le code qui provoque les fuites mémoires ?

Pour cela il vous faut récupérer des données depuis votre node afin de les analyser. Des heap Dump (des images mémoires) peuvent être générés directement depuis node.

Ces heap dump peuvent être générés à intervalles réguliers avec un setInterval

Les données peuvent ensuite être chargées dans Chrome Dev Tools qui vous mettra en exergue l’ensemble des objets, fonctions, etc. contenu à un instant T dans votre heap.

leakMemory4

Exemple ?

Le code suivant génère des fuites mémoires :

Avec memwatch et heapdump, nous repérons la fuite et générons une image de la mémoire au moment où la fuite est détectée.

Une fois chargée dans Chrome Dev Tools, nous constatons un nombre important de closure, de tableaux, de promesses et de LeakClass :

leakMemory5

En dépliant (par exemple) les objets LeakClass, nous voyons d’où ça vient dans notre code, le chemin étant précisé :

leakMemory6

Conclusion 

Nous espérons qu’à travers la lecture de cet article vous aurez une meilleure perception de la gestion de mémoire en NodeJS. Bien sûr, je ne suis pas entré en détails dans le fonctionnement technique des GCs mais notre objectif était de vous expliquer que bénéficier d’une mémoire managée ne nous épargne pas tous les soucis mais que certains outils sont là pour nous aider à corriger nos erreurs.

Share