Traitement par lots avec les générateurs asynchrones en Node.js

Le traitement de volumes importants de données représente un défi récurrent dans les applications serveur modernes. Qu'il s'agisse d'importer des millions d'enregistrements depuis une base de données, de synchroniser des fichiers issus d'une API externe ou d'agréger des événements utilisateur, la question de la mémoire, de la latence et de la prévisibilité des performances se pose rapidement. Les générateurs asynchrones, introduits nativement dans Node.js à partir de la version 10, offrent une réponse élégante à ces problématiques en combinant la paresse des itérateurs avec la puissance du modèle asynchrone.

Plutôt que de charger l'intégralité d'un jeu de données en mémoire avant de le transformer, cette approche permet de produire et de consommer les éléments un à un, tout en orchestrant des pauses naturelles entre chaque étape. Le code qui en résulte se lit comme une succession d'opérations synchrones, alors qu'il pilote en réalité des promesses, des timers et des flux réseau. Cette abstraction constitue une brique fondamentale pour concevoir des pipelines de données robustes.

Fondements des générateurs asynchrones

Un générateur asynchrone se déclare avec la syntaxe async function* et utilise le mot-clé await à l'intérieur de son corps. Chaque expression yield renvoie une promesse au consommateur, qui la reçoit via une boucle for await...of. Contrairement à un générateur classique, le consommateur n'a ainsi plus besoin d'envelopper manuellement les valeurs dans des promesses : la conversion est implicite.

Voici à quoi ressemble un producteur minimaliste :

async function* nombresPairs(max) {
  for (let i = 0; i <= max; i += 2) {
    await new Promise(r => setTimeout(r, 10));
    yield i;
  }
}

(async () => {
  for await (const n of nombresPairs(20)) {
    console.log(n);
  }
})();

Cette structure isole la logique de production de la logique de consommation, ce qui favorise la testabilité et la réutilisation. Un même générateur peut alimenter plusieurs consommateurs sans dupliquer le mécanisme d'attente sous-jacent.

Modèle producteur-consommateur appliqué aux lots

L'intérêt véritable apparaît lorsque l'on transforme chaque élément à la volée et que l'on regroupe les résultats par paquets. Une fonction batch() peut accumuler des items dans un tableau jusqu'à atteindre une taille définie, puis céder le lot complet au consommateur :

async function* batch(iterable, taille = 100) {
  let tampon = [];
  for await (const item of iterable) {
    tampon.push(item);
    if (tampon.length >= taille) {
      yield tampon;
      tampon = [];
    }
  }
  if (tampon.length) yield tampon;
}

Cette abstraction permet de découper des opérations coûteuses — insertion en base, envoi vers une API tierce, calcul analytique — en unités digestes. Le débit global s'améliore, les transactions restent courtes et la mémoire allouée reste stable, quelle que soit la taille de la source.

Concurrence contrôlée et limitation du débit

Laisser un générateur pousser ses lots sans retenue risque de saturer la cible : trop de requêtes simultanées, dépassement des quotas d'une API, épuisement des connexions à une base de données. Une bibliothèque comme p-limit ou une simple file de sémaphores écrite à la main règle ce point :

async function* avecLimite(source, limite, worker) {
  const semaphore = new Semaphore(limite);
  for await (const lot of source) {
    await semaphore.acquire();
    worker(lot).finally(() => semaphore.release());
  }
}

L'idée centrale consiste à suspendre la production tant que le nombre d'opérations en vol dépasse un seuil. Cette mécanique s'apparente à une fenêtre coulissante qui absorbe les pics et lisse la charge. Elle rejoint le principe de backpressure déjà présent dans les streams Node.js, mais avec une granularité au niveau du lot plutôt qu'au niveau de l'octet.

Intégration avec les streams et l'écosystème

Les générateurs asynchrones s'interfacent naturellement avec les streams pour composer des pipelines plus larges. La méthode Readable.from() accepte n'importe quel itérable asynchrone et le transforme en flux Node.js standard :

const { Readable } = require('node:stream');

const flux = Readable.from(producteurAsynchrone());
flux.pipe(destination);

Cette passerelle ouvre la porte à des outils éprouvés comme pipeline(), Transform ou through2. Les développeurs habitués aux opérations classiques de stream retrouvent leurs repères tout en bénéficiant de la lisibilité offerte par les générateurs. Pour ceux qui préfèrent des interfaces encore plus haut niveau, plusieurs bibliothèques — iter-tools, streaming-iterables — proposent des utilitaires prêts à l'emploi.

Pour approfondir les bases de l'outillage Node.js et la création d'outils en ligne de commande, l'article sur CLI avec Commander.js constitue un excellent point d'entrée.

Cas d'usage concrets

Les scénarios d'application se multiplient dès qu'une source de données est paginée ou événementielle. Migration d'un entrepôt de données, ingestion de fichiers CSV issus d'un partenaire, synchronisation d'utilisateurs entre deux systèmes, agrégation de journaux de plusieurs microservices : à chaque fois, la combinaison async function* + for await...of + batch() simplifie radicalement l'orchestration.

Dans un contexte d'analyse, on peut par exemple lire un fichier JSON Lines, transformer chaque ligne en objet enrichi, puis calculer des métriques par fenêtre glissante de mille éléments. Le code reste séquentiel à la lecture, mais le moteur gère en interne la concurrence des lectures disque et des calculs. Résultat : un script lisible qui tient la route face à des fichiers de plusieurs gigaoctets sans saturer la RAM.

Pièges fréquents et bonnes pratiques

Quelques écueils méritent une attention particulière. Premièrement, ne jamais oublier d'itérer jusqu'au bout : un générateur abandonné prématurément peut laisser des ressources ouvertes. Deuxièmement, distinguer clairement la logique de production (réseau, disque, base) de la logique de transformation : les regrouper dans une même fonction nuit à la testabilité. Troisièmement, mesurer systématiquement l'empreinte mémoire et la latence à l'aide d'outils comme clinic.js ou node --inspect.

Côté bonnes pratiques, encapsuler chaque générateur dans un module dédié favorise la réutilisation. Préférer des fonctions pures pour les transformations, et garder les effets de bord à la périphérie du pipeline. Documenter la sémantique de pagination ou de reprise en cas d'erreur : un générateur doit pouvoir être réinvoqué sans effet secondaire caché.

Les générateurs asynchrones sont devenus, en quelques versions de Node.js, un pilier discret mais essentiel de l'outillage côté serveur. Leur adoption progresse à mesure que les architectures orientées événements et les sources de données volumineuses se généralisent.

Pour rester à jour sur les évolutions de l'écosystème Node.js et découvrir d'autres ressources dédiées aux développeurs, la rubrique dédiée regorge de tutoriels, d'analyses et de retours d'expérience. Explorez les différentes sections pour prolonger votre apprentissage, et testez ces patterns sur vos propres charges de travail : c'est en manipulant que l'on appréhende vraiment la subtilité de la production paresseuse et de la concurrence maîtrisée.