Mémoire et performance JS/TS - 08 - FinalizationRegistry : savoir quand le GC passe

FinalizationRegistry permet d'exécuter un callback quand un objet est ramasse par le garbage collector. API, cas d'usage et limites.

  1. 01 Mémoire et performance JS/TS - 00 - Pourquoi la mémoire compte meme avec un garbage collector
  2. 02 Mémoire et performance JS/TS - 01 - Stack vs Heap
  3. 03 Mémoire et performance JS/TS - 02 - Le cycle de vie de la mémoire
  4. 04 Mémoire et performance JS/TS - 03 - Le garbage collector
  5. 05 Mémoire et performance JS/TS - 04 - V8 en profondeur
  6. 06 Mémoire et performance JS/TS - 05 - Les 6 fuites mémoire classiques
  7. 07 Mémoire et performance JS/TS - 06 - Closures et mémoire
  8. 08 Mémoire et performance JS/TS - 07 - WeakRef, WeakMap et WeakSet
  9. 09 Mémoire et performance JS/TS - 08 - FinalizationRegistry : savoir quand le GC passe
  10. 10 Mémoire et performance JS/TS - 09 - DevTools Memory : investiguer dans Chrome
  11. 11 Mémoire et performance JS/TS - 10 - Profiling mémoire en Node.js
  12. 12 Mémoire et performance JS/TS - 11 - Détecter et corriger les fuites mémoire
  13. 13 Mémoire et performance JS/TS - 12 - ArrayBuffer et TypedArrays
  14. 14 Mémoire et performance JS/TS - 13 - Workers et mémoire partagee
  15. 15 Mémoire et performance JS/TS - 14 - Streams et backpressure
  16. 16 Mémoire et performance JS/TS - 15 - Fuites mémoire en React
  17. 17 Mémoire et performance JS/TS - 16 - Serveurs Node.js et mémoire
  18. 18 Mémoire et performance JS/TS - 17 - Mémoire et Docker
  19. 19 Mémoire et performance JS/TS - 18 - Optimisations mémoire
  20. 20 Mémoire et performance JS/TS - 19 - Comparaison avec d'autres langages
  21. 21 Mémoire et performance JS/TS - 20 - Tester la mémoire
  22. 22 Mémoire et performance JS/TS - 21 - Glossaire

08 - FinalizationRegistry : savoir quand le GC passe

Ce que tu vas apprendre

  • Ce que fait FinalizationRegistry et son API (register, unregister, heldValue)
  • Les cas d'usage concrets : nettoyage de file handles, connexions DB, WebSocket
  • Pourquoi tu ne dois pas t'y fier pour du code critique
  • La comparaison avec les destructeurs C++ et le trait Drop de Rust

Prerequisites

Avoir lu l'article 07 sur WeakRef et WeakMap.


En C++ ou en Rust, quand un objet sort du scope, tu sais exactement quand son destructeur s'exécuté. En JavaScript, le garbage collector passe quand il veut. Pendant des annees, on n'avait aucun moyen de savoir qu'un objet venait d'etre ramasse. Puis FinalizationRegistry est arrive.

Le concept

FinalizationRegistry te permet d'enregistrer un callback qui sera appele quand un objet est collecte par le GC. Tu ne contrôles pas quand ca arrive, mais au moins tu es prevenu.

typescriptconst registry = new FinalizationRegistry((heldValue: string) => {
  console.log(`L'objet associe a "${heldValue}" a ete collecte`);
});

let obj: { data: string } | null = { data: "important" };
registry.register(obj, "mon-objet-important");

// Plus tard, quand on n'en a plus besoin
obj = null;
// ... a un moment indetermine, le GC passe et le callback s'execute

Le deuxieme argument de register est le heldValue. C'est la valeur que le callback recoit. Ca ne doit pas etre l'objet lui-meme (sinon tu créés une référencé qui empeche le GC de le collecter).

L'API complète

typescriptconst registry = new FinalizationRegistry<string>((heldValue) => {
  console.log(`Cleanup pour: ${heldValue}`);
});

// Enregistrer un objet avec un token de desinscription
const target = { name: "ressource" };
const unregisterToken = {};
registry.register(target, "ressource-42", unregisterToken);

// Si on veut annuler l'enregistrement avant la collecte
registry.unregister(unregisterToken);

Trois paramètres pour register :

  • target : l'objet a surveiller
  • heldValue : la valeur passee au callback (string, number, ce que tu veux)
  • unregisterToken (optionnel) : un objet qui permet d'annuler l'enregistrement

Cas d'usage : nettoyage de ressources externes

Le cas le plus pertinent, c'est le nettoyage de ressources que JavaScript ne gere pas tout seul. Les fichiers ouverts, les connexions base de donnees, les WebSocket.

typescriptclass ManagedConnection {
  private static registry = new FinalizationRegistry<number>((connId) => {
    console.warn(`Connexion ${connId} n'a pas ete fermee proprement`);
    ConnectionPool.forceClose(connId);
  });

  private connectionId: number;

  constructor(host: string) {
    this.connectionId = ConnectionPool.open(host);
    ManagedConnection.registry.register(this, this.connectionId);
  }

  close(): void {
    ConnectionPool.close(this.connectionId);
    // Pas besoin du callback de finalisation si on ferme proprement
  }
}

L'idee est de traiter FinalizationRegistry comme un filet de sécurité. La méthode close() reste la facon normale de libérer la ressource. Le registry n'est la que pour attraper les oublis.

Exemple avec des fichiers

typescriptclass FileWrapper {
  private static registry = new FinalizationRegistry<number>((fd) => {
    console.warn(`File descriptor ${fd} leake - fermeture d'urgence`);
    fs.closeSync(fd);
  });

  private fd: number;
  private token = {};

  constructor(path: string) {
    this.fd = fs.openSync(path, "r");
    FileWrapper.registry.register(this, this.fd, this.token);
  }

  read(buffer: Buffer): number {
    return fs.readSync(this.fd, buffer);
  }

  close(): void {
    fs.closeSync(this.fd);
    FileWrapper.registry.unregister(this.token);
  }
}

Quand close() est appele, on desinscrit avec unregister. Si le dev oublie d'appeler close() et que l'objet est collecte, le callback ferme le fichier.

Pourquoi tu ne dois PAS t'y fier

Le GC est non-déterministe. Ca veut dire :

  • Le callback peut s'exécuter des secondes, des minutes, voire jamais
  • Si le programme se termine avant que le GC passe, le callback ne s'exécuté pas
  • L'ordre d'exécution des callbacks n'est pas garanti
  • Le GC peut décider de ne jamais collecter certains objets
typescript// MAUVAIS : s'appuyer sur FinalizationRegistry pour de la logique metier
const registry = new FinalizationRegistry(() => {
  // Ceci peut ne jamais s'executer
  database.commitTransaction();
});

// BON : l'utiliser comme diagnostic
const registry = new FinalizationRegistry((label: string) => {
  metrics.increment("leaked_resources", { label });
});

La spec ECMAScript le dit clairement : une implementation JavaScript conforme n'est pas obligee d'appeler les callbacks de finalisation. Jamais.

Comparaison avec C++ et Rust

En C++, les destructeurs sont deterministes. Quand un objet sort du scope, le destructeur s'exécuté immédiatement. Avec RAII (Resource Acquisition Is Initialization), c'est la base de la gestion de ressources :

cpp{
  std::ifstream file("data.txt");
  // utilisation du fichier
} // Le destructeur ferme le fichier ICI, de facon garantie

En Rust, le trait Drop fonctionne pareil. Deterministe, garanti, dans l'ordre inverse de création :

rustimpl Drop for Connection {
    fn drop(&mut self) {
        self.close();
    }
}

FinalizationRegistry ne remplace ni les destructeurs C++ ni Drop. C'est un outil de diagnostic et de filet de sécurité, pas un mecanisme de gestion de ressources.

Aspect C++ destructeur Rust Drop FinalizationRegistry
Deterministe Oui Oui Non
Garanti Oui Oui Non
Ordre Inverse du scope Inverse du scope Indefini
Usage Gestion ressources Gestion ressources Diagnostic/filet

En pratique sur un vrai projet

Sur paltemps.fr, j'ai utilise FinalizationRegistry pour traquer les connexions WebSocket qui n'etaient pas fermees proprement. Le callback n'effectue aucune action critique, il log un warning dans les metriques. Le vrai nettoyage se fait via des méthodes explicites dans les handlers de deconnexion.

C'est la bonne mentalite : explicit cleanup d'abord, FinalizationRegistry en observateur.

typescriptconst leakTracker = new FinalizationRegistry<string>((socketId) => {
  logger.warn(`WebSocket ${socketId} collecte sans close()`);
  leakCounter.increment();
});

Quand le compteur de leaks monte, je sais qu'il y a un bug a corriger dans le code applicatif. FinalizationRegistry me donne la visibilité, pas la solution.


Résumé

  • FinalizationRegistry exécuté un callback quand un objet est collecte par le GC
  • L'API : register(target, heldValue, unregisterToken), unregister(token)
  • Utile comme filet de sécurité pour les ressources externes (fichiers, connexions, sockets)
  • Le GC est non-déterministe : le callback peut ne jamais s'exécuter
  • Ce n'est pas un remplacement des destructeurs C++ ou du trait Drop de Rust
  • Toujours préférer le nettoyage explicite, utiliser le registry pour le diagnostic

Article précédent : 07 - WeakRef et WeakMap Article suivant : 09 - DevTools Memory : investiguer dans Chrome

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.