Fuites mémoires en Javascript

Fuites mémoires en Javascript

22 mars 2022 0 Par Aschen

Les langages de haut niveau comme Javascript ont une gestion « managé » de la mémoire. C’est à dire que le moteur Javascript se charge d’allouer et de désallouer la mémoire automatiquement.

Pour le moteur Javascript v8 (Chrome et Node.js), c’est Ocorino qui se charge de la partie Garbage Collecting pour désallouer la mémoire.

Contrairement aux langages comme le C++ ou les développeurs sont entrainés aux problématiques de gestion de mémoire et constamment à l’affût des fuites, dans le monde du Javascript ce problème n’est que très peu pris en compte.

La plupart du temps, celle-ci passent inaperçues lorsque c’est dans du code frontend, étant donné que la mémoire est remise à zero entre chaque page.

Cependant, si vous faites du Node.js ou si vous développez une Single Page Application alors vous devez faire attention.

Fonctionnement basique d’un Garbage Collector

Je ne rentrerais pas en détails dans le fonctionnement d’un Garbage Collector (vous pouvez lire cet article de l’équipe de v8 sur Ocorino), il faut simplement comprendre que c’est un programme comme un autre qui va s’occuper de trouver les variables non utilisés pour libérer la mémoire.

Chaque variable est une référence vers une structure allouée dans la mémoire. Le Garbage Collector est capable de compter combien de variables font référence vers une structure mémoire, si il y en a 0 cela signifie que la structure en mémoire n’est plus utilisée dans l’application et peut être libérée.

function doSomething () {
  // Allocate a Map in the memory
  const person = new Map();
  // 1 reference to the Map

  const samePerson = person;
  // 2 references to the Map
}

doSomething();
// 0 reference to the Map, we can free the memory

Des fuites mémoires irrécupérables

Pour avoir une fuite mémoire, il faut donc conserver une référence sur une structure mémoire.

Cela est assez simple à réaliser en Javascript car de nombreux callback sont enregistrés pour être utilisés plus tard à un autre cycle de l’Event Loop.

Toute variable utilisé à l’intérieur de ces callbacks sera conservée car le callback conserve une référence sur ces dernières et l’Event Loop conserve une référence sur ce callback.

function startInterval (person) {
  setInterval(() => {    
    person.interval += 1;
  }, 1000);
}

function run () {
  const person = { interval: 0 };
  // 1 reference to the object (person variable)

  startInterval(person);
  // 2 references to the object 
  // (person variable and setInterval callback)
}

run();
// still 1 reference to the object (setInterval callback)
// the object will never be garbage collected

Ici la solution serait simplement de supprimer l’interval avec clearInterval afin que le callback soit aussi supprimé et avec lui la référence sur l’objet.

Cette erreur est également fréquente avec la méthode addEventListener que l’on trouve dans le navigateur.

Dans ces deux cas, la mémoire alloué ne sera jamais libérée et causera inévitablement un arrêt prématuré du programme sur une période plus ou moins longue.

Une utilisation trop gourmande de la mémoire

Ne pas faire suffisament attention aux différents scopes de son programmes et des références qu’ils vont conservés est aussi une erreur courante.

La mémoire pouvant être utilisé par le moteur Javascript n’est pas infinie (512 Mo par défaut dans Node.js) et elle doit être utilisée avec parcimonie, souvent en trouvant un équilibre entre utilisation mémoire et requêtes réseau.

async function preparePopup () {
  const documents = await fetchCollection();

  // documents is an array of object 
  // and it will be keeped into memory
  popup.addEventListener('click', () => {
    console.log(documents)
  });
}

Dans cet exemple, on va récupérer un tableau d’objet que l’on conserve en mémoire jusqu’à une hypothétique action d’un utilisateur. Cela peut représenter beaucoup de données, et potentiellement mené à un crash car aucun garde-fou n’est mis en place pour limiter l’utilisation de la mémoire.

Un autre exemple classique est celui du cache de données qui grossit indéfiniment:

import { Backend } from 'kuzzle';

const app = new Backend('memory-leak');

// memory cache using a Map
const users = new Map();

app.controller.register('users', {
  actions: {
    get: {
      handler: async req => {
        const userId = req.getString('userId');

        if (! users.has(userId)) {
          // fetch the user and add it to the cache
          const user = await app.sdk.security.getUser(userId);
          users.set(userId, user);
        }

        return users.get(userId);
      }
    }
  }
});

app.start();

Dans cet exemple (qui utilise Kuzzle), un cache d’utilisateur est mis en place pour éviter de faire deux fois la même requête. Ce cache va grossir indéfiniment jusqu’à causer l’arrêt du programme par manque de mémoire disponible.

Il est conseillé de choisir une politique de conservation de cache pour limiter l’occupation mémoire maximum.

En général, un cache LRU (Last Recently Used) est un bon compromis ou l’on garde un nombre fix d’élément en enlevant les éléments ayant été accèder il y a le plus longtemps.

Take away

C’est de la responsabilité de chaque développeur de garder en tête la consommation mémoire de son programme ainsi que les possibilités de fuites.

Les events listener, les timers, les closures et les caches sont de bons clients pour les fuites mémoires potentielles de votre application.

Dans un prochain article, nous verrons comment investiguer sur la source d’une fuite mémoire dans une application.


Photo d’entête: Panorama depuis le sommet de Ambuluwawa Trigonometrical Station, Kandy, Sri Lanka