← Retour au blog
Concurrence

Promises et gestion des ressources : le problème de l'interruption

Le problème méconnu des Promises : leur nature non-interruptible empêche la libération des ressources. Tour d'AbortController et des Effects comme alternative composable.

📅 ✍️ Antoine Coulon
promisesinterruptionresource-managementabortcontrollereffect

Les Promises sont la brique de base de l’asynchronisme en JavaScript, au point qu’on finit par les croire infaillibles. Elles le sont pourtant beaucoup moins qu’on ne le pense, et sont à l’origine de nombreux bugs lorsqu’elles sont mal maîtrisées. Parmi tous leurs défauts, l’un reste largement méconnu du grand public alors qu’il peut avoir de lourdes répercussions en production : la gestion des ressources.

Je ne couvrirai pas ici l’ensemble des problèmes liés aux Promises, il y aurait matière à toute une série. Concentrons-nous sur un seul, trop souvent ignoré : ce qui arrive aux ressources mobilisées par une opération asynchrone lorsque celle-ci n’a plus aucune raison de continuer.

Les Promises ne savent pas s’interrompre

C’est la racine du problème : de par leur nature, les Promises ne sont pas interruptibles. Une fois lancée, une Promise va jusqu’au bout de son exécution, point. Il n’existe aucun mécanisme intégré pour lui dire « arrête-toi, on n’a plus besoin de ton résultat » et, surtout, pour garantir que les ressources qu’elle a mobilisées seront correctement libérées.

Promise.all et Promise.race sont les exemples les plus parlants de fonctions susceptibles de provoquer des fuites mémoire (memory leaks) précisément à cause de cette limitation.

Le cas de Promise.race

Prenons Promise.race, dont le but est de mettre plusieurs opérations en compétition. La plus rapide gagne la course et remporte le droit de retourner son résultat ; c’est le seul résultat que l’on récupère.

Mais qu’advient-il des opérations qui ont perdu ? C’est tout le piège : elles continuent de s’exécuter malgré tout, parce qu’elles ne sont jamais interrompues. Le résultat est ignoré, mais le travail, lui, va à son terme.

Imaginez maintenant que l’une de ces opérations perdantes mobilise une connexion à une socket ou à une base de données, un file descriptor, un timer, ou n’importe quelle autre ressource critique. Nous n’avons strictement aucune garantie que ces ressources seront libérées. Pire encore, tant que ces opérations tournent, elles maintiennent l’event loop occupé et peuvent empêcher le process Node.js de se terminer proprement.

L’exemple suivant illustre exactement ce comportement, et le contraste avec une approche capable d’interrompre les perdants.

import { setTimeout } from "node:timers/promises";

/**
 * Promise.race ne permet pas l'interruption des losers, donc le process Node.js
 * restera en vie tant que le timeout de 10s ne sera pas terminé.
 * Cela a aussi pour conséquence la mobilisation de mémoire et de ressources
 * au niveau de l'event loop alors que le timer n'a plus d'importance : il a perdu
 * la course.
 */
const winnerWithLosersRunningStill = Promise.race([
  setTimeout(1000).then(() => console.log("1s done")),
  // Continuera de tourner en background
  setTimeout(10000).then(() => console.log("10s done")),
]);

import { Duration, Effect, pipe } from "effect";

const logDelay = (duration: Duration.Duration) =>
  pipe(Effect.log(duration.toString()), Effect.delay(duration));

/**
 * Effect.race permet d'interrompre automatiquement les losers grâce à la propriété
 * interruptible des Effects par défaut. Aucune ressource n'est gaspillée et le
 * programme est terminé proprement, ce qui permet au process Node.js de se terminer
 * correctement.
 */
const winnerEndingTheRace = pipe(
  // Ligne d'arrivée franchie, le programme est terminé
  logDelay(Duration.seconds(1)),
  // Interrompu automatiquement après 1s
  Effect.race(logDelay(Duration.seconds(10))),
  Effect.runFork
);

Dans la première moitié, le timer de 10 secondes a perdu la course dès la première, mais il continue de vivre dans l’event loop, retardant la fin du process et consommant des ressources pour un résultat dont plus personne ne veut.

Écrire du code ressource-safe avec des Promises

Comment, alors, écrire du code qui respecte le cycle de vie des ressources malgré cette limitation ? Il existe aujourd’hui deux grandes approches.

AbortController : efficace mais fastidieux

La plateforme web fournit l’API AbortController, qui permet de créer un signal (AbortSignal) propageable au sein d’une chaîne d’opérations. Ce signal sert à demander l’interruption d’opérations asynchrones : chaque maillon de la chaîne doit l’écouter et réagir en conséquence.

La solution est efficace, mais elle reste fastidieuse à mettre en œuvre. On se retrouve rapidement à faire du signal drilling : passer manuellement le signal de fonction en fonction, à travers toutes les couches du programme, pour qu’il soit accessible là où l’opération annulable se trouve réellement. Pour des programmes non triviaux, écrire du code composable et maintenable avec ce type d’API devient très vite compliqué.

Effect : l’interruption comme propriété native

L’autre approche consiste à s’appuyer sur des primitives plus puissantes que les Promises. Les Effects, via une bibliothèque comme Effect, supportent nativement les interruptions. Un Effect est interruptible par défaut, et le runtime se charge de garantir la bonne libération des ressources lorsqu’une ou plusieurs opérations sont annulées.

C’est exactement ce que montre la seconde moitié de l’exemple : Effect.race interrompt automatiquement les perdants. Aucune ressource n’est gaspillée, le programme se termine proprement, et le process Node.js peut s’arrêter comme prévu, là où Promise.race le laissait suspendu.

L’avantage décisif d’Effect tient à la simplicité avec laquelle on définit des modèles d’interruption et on y réagit, quelle que soit la complexité du programme. La gestion des ressources reste linéaire par rapport à la complexité du programme, alors qu’avec AbortController, elle tend à croître bien plus vite à mesure que le code grossit. Et ce constat vaut, plus largement, pour beaucoup d’autres aspects de la gestion de la concurrence avec Effect.

Conclusion

Le problème n’est pas que les Promises soient mauvaises, elles restent un outil indispensable. Le problème, c’est qu’elles n’ont jamais été conçues pour modéliser l’interruption et le cycle de vie des ressources. Tant qu’une opération asynchrone ne peut être arrêtée, on ne peut garantir que ce qu’elle a ouvert sera refermé.

AbortController apporte une réponse fonctionnelle mais coûteuse en ergonomie. Les Effects, eux, traitent l’interruption comme une propriété de premier ordre, ce qui rend le code ressource-safe non seulement possible, mais composable et maintenable. Dès que vos programmes mobilisent des ressources critiques et que des opérations peuvent perdre leur course, c’est une distinction qui mérite toute votre attention.