Profiler son application Node.js : analyse de la mémoire

Analyse de la mémoire

Deuxième article de cette série consacrée à l’analyse des performances des applications Node.js. Nous allons nous attaquer cette fois-ci à l’analyse de la mémoire. Pour rappel, cette série se compose de trois articles :

Note : J’essaye dans cet article d’être le plus exhaustif possible tout en essayant de garder l’article plaisant et simple à lire. C’est pourquoi certaines parties sont simplifiées et certains détails non essentiels sont omis (notamment sur l’organisation de la mémoire qui est un peu plus complexe que ça). Concernant le code présenté dans cet article, celui-ci est comme toujours simplifié pour se concentrer uniquement sur les concepts clés. Bonne lecture !

Organisation de la mémoire

Intéressons-nous à l’organisation de la mémoire de nos applications Node.js. Chaque programme en cours d’exécution occupe une certaine quantité de mémoire contenue dans la RAM. Cet espace mémoire est appelé la mémoire résidente (resident set size ou RSS). Celui-ci est découpé en deux segments la pile (stack) et le tas (heap).

Organisation de la mémoire (simplifiée)
Organisation de la mémoire (simplifiée)

La pile (Stack)

La pile ou stack permet de stocker les types primitifs (number, string, boolean, etc.) ainsi que les références aux objets.

Le tas (Heap)

Le tas ou heap quant à lui permet de stocker les objets (Function, Object, Array, etc.). Celui-ci est découpé en plusieurs parties :

  • code-space : C’est ici que le code compilé à la volée est stocké;
  • new-space : C’est dans cet espace que les objets nouvellement crées sont stockés. La durée de vie ces objets est généralement courte. Le nettoyage de cet espace est réalisé par le premier garbage collector (minor GC). cet espace est découpé en deux autres espaces (from-space et to-space). Mais on en reparlera plus en détail un peu plus tard;
  • old-space : Les objets stockés dans l’espace new-space ayant survécu à un certain nombre de cycles du premier garbage collector (deux cycles pour être exact) sont transférés dans cet espace. Le nettoyage de celui-ci est réalisé par le second garbage collector (major GC). cet espace est lui-même découpé en deux autres espaces à savoir :
    • pointer-space : Contient les objets contenant une référence vers d’autres objets;
    • data-space : Contient les objets constitués uniquement de types primitifs (number, string, boolean, etc.) c’est-à-dire ceux ne contenant aucune référence vers d’autres objets.

Garbage collector

Contrairement à d’autres langages comme le C ou C++, la mémoire allouée dynamiquement d’un programme Node.js est « automatiquement » libérée. Cette libération automatique est réalisée par un garbage collector ou ramasse-miette en français. Comme nous l’avons vu, Node.js utilise deux garbage collectors : minor GC (scavenge) et major GC (mark-sweep-compact).

Minor GC (scavenge)

Ce premier garbage collector, est uniquement exécuté dans l’espace new-space du tas (heap). Celui-ci est exécuté régulièrement sur une petite quantité de mémoire (entre 1 et 8 mégaoctets). Son fonctionnement est le suivant :

  • L’allocation d’un nouvel objet se fait dans l’espace to-space;
  • S’il n’y a plus d’espace disponible dans l’espace to-space le garbage collector s’exécute;
  • On permute les espaces to-space et from-space. L’espace from-space devient donc l’espace to-space;
  • Le garbage collector parcourt l’ensemble des objets présent dans l’espace from-space et pour chacun de ces objets, vérifie si celui-ci est toujours utilisé. Si c’est le cas, deux scénarios sont possibles :
    • Les objets sont déplacés dans l’espace to-space. Si ces objets ont des références vers d’autres objets, ceux-ci sont également déplacés;
    • Si les objets sont présents dans l’espace new-space depuis plus de deux cycles du garbage collector, ceux-ci (ainsi que les objets qu’ils référencent) sont placés dans l’espace old-space.
  • Les objets non utilisés sont désalloués;
Fonctionnement du minor garbage collector (scavenge)
Fonctionnement du minor garbage collector (scavenge)
Major GC (Mark-sweep-compact)

Ce second garbage collector, s’exécute dans l’espace old-space comme vu précédemment. Son principe est très simple et repose sur trois phases mark, sweep et compact.

Mark

Pour mieux comprendre la suite, on peut représenter la mémoire comme un graphe où chaque nœud est un objet et chaque arête, une référence vers un autre objet. La première phase consiste donc à parcourir ce graphe (en utilisant l’algorithme de parcours en profondeur) et marquer chaque nœud comme suit :

  • Blanc : Il s’agit de l’état initial signifiant que l’objet n’a pas été découvert par le garbage collector;
  • Gris : L’objet a été découvert, mais l’ensemble de ces voisins (les objets dont il possède des références) ne sont pas encore traités par le garbage collector;
  • Noir : L’objet ainsi que l’ensemble de ses voisins ont été traités par le garbage collector.
Sweep

Cette phase est très simple, puisqu’elle consiste simplement à libérer la mémoire des objets non marqués de la phase précédente, c’est-à-dire dire ceux en blanc.

Phase "mark and sweep"
Phase « mark and sweep »
Compact

Lors de la phase précédente, la mémoire se trouve fragmentée. Cette dernière étape consiste donc à optimiser la mémoire en réorganisant celle-ci.

Phase "compact"
Phase « compact »

Si vous souhaitez plus d’informations sur ces deux garbage collectors, n’hésitez pas à aller jeter un coup d’œil sur cette documentation : V8 Garbage Collector

Détection des fuites mémoires

Après avoir vu le fonctionnement de la mémoire, il est temps de s’attaquer à l’analyse de celle-ci et plus particulièrement à la détection des fuites mémoires.

Qu’est-ce qu’une fuite mémoire ?

Une fuite mémoire est simplement l’absence de libération de l’espace occupé par des objets censés ne plus être utilisés et qui a pour conséquence l’augmentation de l’occupation mémoire pouvant provoquer des soucis de performance.

Les causes des fuites mémoires

Comme nous l’avons vu, les garbage collectors libèrent de l’espace mémoire occupé par les objets qui ne sont plus utilisés. Une fuite mémoire apparaît donc quand des références aux objets censés être libérés existent encore. Les fuites mémoires sont toujours dues à une erreur de programmation de la part des développeurs. Nous allons voir une liste non exhaustive de cas provoquant des fuites mémoires.

les variables globales

Les variables globales ne sont jamais libérées par le garbage collector, car celles-ci sont toujours référencées par l’objet global qui n’est lui n’est jamais libéré.

Dans le code suivant, la variable result est une variable globale bien que celle-ci ait était défini dans la fonction randomIntegerBetween. Cela est dû au principe de l’hoisting :

La solution à ce problème est bien entendu d’utiliser les mots clés let ou const pour créer la variable dans le scope de la fonction :

Il est également conseiller d’utiliser le mode strict pour éviter ce type d’erreur :

Attention également à l’utilisation du mot clé this :

Les closures 

Petit rappel, une closure est une fonction interne à une autre fonction qui va pouvoir accéder aux variables de cette dernière, et ce même si celle-ci a été exécutée. 

Petit exemple :

Le moteur V8 est intelligent et ne garde en mémoire que les variables qui sont réellement utilisées dans la closure :

Mais il y a un cas qui peut créer une fuite mémoire :

Testons notre code :

Débuggons celui-ci dans Chrome. Ouvrons la page about:inspect :

Fuite mémoire provoquée par une closure
Fuite mémoire provoquée par une closure

On remarque que même si la variable unused n’est pas utilisée dans la closure inner, celle-ci est toujours présente en mémoire. 

Pensez donc à toujours faire attention lorsque vous utilisez des closures, ce cas particulier arrive bien plus souvent qu’on ne le pense et il est souvent difficile de l’apercevoir.

Bonus : Il est possible également de débugger directement depuis la console via la commande :

Utilisation de la console pour debug
Utilisation de la console pour debug

Maintenant, comment éviter ce genre de problème ? Pour commencer, éviter de déclarer des variables non utilisées faisant référence à un type non primitif, comme Object, Array, Function, etc. (merci captain obvious…).

Une autre astuce consiste à assigner aux variables la valeur null à la fin de la fonction pour « supprimer » la référence vers les objets. Ces derniers pourront ainsi être recyclées par le garbage collector :

Les promesses

Nous allons voir un cas de fuite mémoire provoqué par l’utilisation des promesses. Il arrive parfois (quand le code est mal conçu) qu’une promesse ne soit jamais résolue et reste dans l’état pending. L’exécution du code est donc suspendue à la résolution de la promesse qui n’arrivera jamais. Toutes les variables appartenant au scope de cette exécution en attente, ne seront donc pas libérées par le garbage collector, puisque celles-ci sont toujours référencées. Voici un exemple de code illustrant ce problème :

Dans cet exemple, toutes les variables déclarées avant l’attente de la promesse (via l’instruction await) persistent en mémoire. En plus d’une fuite mémoire, nous avons une fuite concernant les descripteurs de fichiers.  Les descripteurs de fichiers sont des identifiants pour les fichiers, les dossiers, les sockets réseau, etc. Leur nombre est limité par le système d’exploitation et comme pour la mémoire, ceux-ci doivent être libérés. Dans notre exemple, nous avons une connexion réseau qui ne sera pas fermée du fait de l’attente de la promesse.

Une solution à ce problème consiste à pouvoir stopper la promesse au bout d’un certain délai. Pour cela, nous allons utiliser la méthode Promise.race qui renvoie la première promesse terminée (résolue ou rejetée) :

D’autres exemples

Nous avons vu quelques cas qui provoquent des fuites mémoires, mais il en existe bien d’autres. Par exemple :

  • Une mauvaise utilisation de la méthode pipe des streams qui provoque une mauvaise fermeture de l’un d’eux. Il est d’ailleurs recommandé d’utiliser la méthode pipeline;
  • Une mauvaise stratégie de cache, comme une absence de nettoyage de celui-ci qui fait gonfler l’utilisation de la mémoire;
  • Les gestionnaires d’événements (EventEmitter) qui ne sont pas correctement supprimés (via removeListener) ou qui s’exécutent plusieurs fois (via on au lieu de once);
  • Les timers (setInterval ou setTimeout) qui ne sont pas correctement supprimés lorsqu’il est nécessaire (respectivement via clearInterval ou clearTimeout);
  • De multiples références à un objet peuvent également provoquer des fuites mémoires, car comme nous l’avons vu, le garbage collector désalloue les données quand il n’y a plus de référence à celles-ci;

Comment détecter les fuites mémoires ?

Nous allons maintenant voir comment détecter les fuites mémoires. Prenons un exemple très simple afin de voir comment utiliser les outils. On parlera un peu plus tard d’un cas concret qu’il m’est arrivé il y a quelque temps. L’exemple que l’on va prendre est le suivant :

Ce code permet de récupérer des images de façon aléatoire et de sauvegarder celle-ci dans un cache en mémoire afin d’éviter d’aller rechercher celle-ci sur le disque dur.

process.memoryUsage()

Node.js permet d’obtenir des informations sur la consommation mémoire via le module process et sa méthode memoryUsage :

Cette méthode renvoie un objet avec comme propriété :

  • rss : le montant total de la mémoire occupé par l’application;
  • heapTotal : Le montant total de la mémoire du tas alloué pour les objets;
  • heapUsed : Le montant de la mémoire du tas occupé par les objets;
  • external :  Le montant de la mémoire utilisé par les objets C++;
  • arrayBuffers : La mémoire allouée pour les ArrayBuffer, SharedArrayBuffer et Buffer. Ce montant est inclus dans celui de external.

Ces valeurs sont exprimées en octets.

Il existe également les méthodes getHeapSpaceStatistics et getHeapStatistics du module v8.

Clinic doctor

Nous avons vu dans le précédent article l’outil clinic flame. Aujourd’hui, nous allons nous intéresser à l’outil clinic doctor. Pour cela installons clinic :

Pour utiliser clinic doctor, il suffit de lancer la commande suivante :

Les options qui nous intéressent sont les suivantes :

Je vous invite à lire la documentation pour voir les autres options.

Analysons donc notre route avec la commande suivante :

Une fois l’exécution terminée, une page web s’ouvre :

Rapport clinic doctor de notre route /images/random
Rapport clinic doctor de notre route /images/random

doctor clinic donne plusieurs graphiques intéressants :

  • CPU usage : l’utilisation du CPU exprimée en pourcentage;
  • Memory usage : l’utilisation de la mémoire exprimée en mégaoctets. Les trois courbes représentent respectivement la mémoire résidente (RSS), le total de mémoire du tas allouée (Total Heap Allocated) et la mémoire du tas utilisée (Heap Used).
  • Event loop delay : La latence de la boucle d’événements exprimée en millisecondes;
  • Active handles : Les descripteurs de fichiers ouverts (sockets, fichiers, etc.).

Le graphique qui nous intéresse est celui qui concerne l’utilisation de la mémoire. On remarque que la mémoire résidente (RSS) augmente au fur et à mesure ce qui est un signe de fuite mémoire.

Pour ceux que ça intéresse, vous pouvez retrouver ce rapport ici.

Chrome devTools

Nous avons découvert avec clinic doctor une fuite mémoire, maintenant il faut trouver la source de celle-ci. Pour cela nous allons utiliser Chrome devTools, prendre deux snapshots de notre mémoire et les comparer. Pour commencer, lançons notre application avec l’option --inspect:

Ouvrons ensuite chrome et ouvrons la page web about:inspect

Cliquons sur inspect et prenons notre premier snapshot Memory -> Heap snapshot ->Take snapshot

La vue suivante s’ouvre avec notre premier snapshot :

Lançons ensuite la commande autocannon suivante :

Puis prenons un second snapshot. Pour cela il suffit de cliquer sur Profiles au-dessus du premier snapshot puis Heap snapshot ->Take snapshot

Nous pouvons dès maintenant comparer les deux snapshots :

Comparaison des deux snapshots
Comparaison des deux snapshots

Nous pouvons voir le delta entre les deux snapshots (Delta et SizeDelta). Nous remarquons qu’il y a un nombre important de nouveaux objets de type ArrayBuffer. Si nous déroulons la ligne correspondante, nous pouvons accéder à l’ensemble de ces nouveaux objets et voir où ceux-ci ont été créés.

Dans notre cas, il s’agit de la variable cache contenu dans notre fichier index.js. Les objets ArrayBuffer correspondent donc aux buffers de nos images.

Résoudre le problème

Le problème est assez simple à résoudre, nous stockons toutes les images récupérées, via notre route, dans la mémoire. Il nous faut donc stocker un nombre limité d’images et avoir une gestion de cache plus efficace. Une solution très simple est d’utiliser un cache LRU (Least-Recently-Used) qui permet de garder en cache uniquement les images récemment utilisées.

Ça tombe bien, un module Node.js existe sur npm, installons celui-ci :

Modifions ensuite notre code :

Lançons ensuite clinic doctor :

On obtient le rapport suivant :

Rapport clinic doctor en utilisant le cache LRU
Rapport clinic doctor en utilisant le cache LRU

Nous avons divisé la consommation mémoire par 8 et l’utilisation du cache LRU nous permet d’avoir plus de contrôle sur la consommation de la mémoire. 

Analyser en production

Voyons comment il est possible de récupérer un dump de la mémoire en production. Node fournit, via le module v8, depuis la version 11.13.0, une méthode getHeapSnapshot permettant de générer un dump de la mémoire. Ajoutons donc une route permettant de générer et télécharger ce fichier de dump :

Pour les versions de Node inférieure à la 11.13.0, vous pouvez utiliser le module heapdump :

Et utiliser le code suivant :

Voilà ! il ne reste plus qu’à ouvrir le fichier généré dans chrome devTools :

Chargement d'un fichier "heap dump"
Chargement d’un fichier « heap dump »

Cas concret (ou comment raconter sa vie)

Avant de terminer, j’aimerais vous parler d’un problème qu’il m’est arrivé il y a quelque temps. Je travaillais sur une architecture microservices permettant à chacun des services d’enregistrer des tâches à effectuer plus tard dans un autre service servant en quelque sorte de service de rappel.

Le tout communiquait ensemble via une file de messages. Bref je ferais sûrement un article sur l’architecture microservices un jour pour expliquer tout ça.

Ce service de rappel ne disposait pas de base de données et les tâches étaient directement enregistrées en mémoire. Je devais rajouter pour ce service de rappel, une API permettant de récupérer les tâches mais également leur attacher des informations complémentaires. Sauf que je ne pouvais pas modifier le code existant concernant la gestion des tâches (principe ouvert/fermé tout ça tout ça).

Architecture microservices avec un service de rappel
Architecture microservices avec un service de rappel

Donc c’est tout, je décide d’utiliser l’objet Map et d’ajouter les informations complémentaires des tâches dans celle-ci. Le code ressemblait plus ou moins à ca :

Ça fonctionnait bien, j’étais content et cela ne m’avait pas pris beaucoup temps. Sauf que, la consommation mémoire ne faisait que d’augmenter et ce que je n’avais pas prévu (bouh le nul), c’est que les tâches, une fois celles-ci terminées, étaient supprimées. Mais la Map, que j’avais ajoutée, contenant les informations complémentaires avait toujours une référence vers les tâches supprimées puisque celles-ci servaient de clé.

Donc en plus d’avoir les informations complémentaires qui n’était pas supprimées en même temps que les tâches, ces dernières n’étaient également pas nettoyées par le garbage collector. Et pour couronner le tout, je ne pouvais pas modifier le code source existant concernant la suppression des tâches et je n’avais aucun moyen d’être mis au courant (via un event par exemple). Mais alors j’ai fait comment ? C’est simple, j’ai utilisé l’objet WeakMap :

La WeakMap permet d’utiliser des objets comme clé tout comme la Map. La seule différence est que la clé est une référence « faible » vers l’objet en question. Ce qui signifie que s’il n’existe plus aucune référence vers cet objet excepté celle servant de clé, le garbage collector est autorisé à nettoyer cet objet.

Dans mon cas, si la tâche est supprimée ailleurs dans le code (et que plus aucune variable ni fait référence), les informations complémentaires de celle-ci contenues dans ma WeakMap ainsi que la tâche en question pourront être nettoyées par le garbage collector.

Pour finir…

Nous venons de voir comment fonctionnait la mémoire dans une application Node.js et comment il était possible d’identifier les fuites mémoires.

Tout comme l’analyse des performances CPU, le sujet est très vaste. J’ai essayé d’être le plus exhaustive possible dans cet article et j’espère que celui-ci vous a plu.

Si vous connaissez d’autres outils ou méthodes d’analyse, dites-le-moi dans les commentaires, par email via la page contact ou sur Twitter.

On se retrouve la prochaine fois pour le dernier article de cette série et on se penchera cette fois-ci à l’analyse des traitements asynchrones. 


Annonces partenaire

Je suis lead developer dans une boîte spécialisée dans l'univers du streaming/gaming, et en parallèle, je m'éclate en tant que freelance. Passionné par l'écosystème JavaScript, je suis un inconditionnel de Node.js depuis 2011. J'adore échanger sur les nouvelles tendances et partager mon expérience avec les autres développeurs. Si vous avez envie de papoter, n'hésitez pas à me retrouver sur Twitter, m'envoyer un petit email ou même laisser un commentaire.

1 commentaire

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.