Skip to content

Ne bloquez pas la boucle d'événements (ni le pool de workers)

Ce guide vous est-il destiné ?

Si vous écrivez quelque chose de plus compliqué qu'un bref script en ligne de commande, sa lecture devrait vous aider à écrire des applications plus performantes et plus sécurisées.

Ce document est écrit en pensant aux serveurs Node.js, mais les concepts s'appliquent également aux applications Node.js complexes. Lorsque les détails spécifiques au système d'exploitation varient, ce document est centré sur Linux.

Résumé

Node.js exécute du code JavaScript dans la boucle d'événements (initialisation et callbacks), et offre un pool de workers pour gérer les tâches coûteuses comme les E/S de fichiers. Node.js s'adapte bien, parfois mieux que des approches plus lourdes comme Apache. Le secret de l'évolutivité de Node.js est qu'il utilise un petit nombre de threads pour gérer de nombreux clients. Si Node.js peut se contenter de moins de threads, il peut alors consacrer plus de temps et de mémoire de votre système à travailler sur les clients plutôt qu'à payer des frais généraux d'espace et de temps pour les threads (mémoire, commutation de contexte). Mais comme Node.js n'a que quelques threads, vous devez structurer votre application pour les utiliser judicieusement.

Voici une bonne règle empirique pour maintenir la rapidité de votre serveur Node.js : Node.js est rapide lorsque le travail associé à chaque client à un moment donné est « petit ».

Ceci s'applique aux callbacks sur la boucle d'événements et aux tâches sur le pool de workers.

Pourquoi devrais-je éviter de bloquer la boucle d'événements et le pool de workers ?

Node.js utilise un petit nombre de threads pour gérer de nombreux clients. Dans Node.js, il existe deux types de threads : une boucle d'événements (également appelée boucle principale, thread principal, thread d'événements, etc.) et un pool de k workers dans un pool de workers (également appelé threadpool).

Si un thread prend beaucoup de temps pour exécuter un callback (boucle d'événements) ou une tâche (worker), on dit qu'il est « bloqué ». Lorsqu'un thread est bloqué en travaillant pour un client, il ne peut pas gérer les requêtes d'autres clients. Cela fournit deux motivations pour ne bloquer ni la boucle d'événements ni le pool de workers :

  1. Performances : si vous effectuez régulièrement une activité lourde sur l'un ou l'autre type de thread, le débit (requêtes/seconde) de votre serveur en souffrira.
  2. Sécurité : s'il est possible que pour certaines entrées, l'un de vos threads puisse se bloquer, un client malveillant pourrait soumettre cette « mauvaise entrée », bloquer vos threads et les empêcher de travailler sur d'autres clients. Ce serait une attaque par déni de service.

Revue rapide de Node

Node.js utilise l'architecture pilotée par les événements : il possède une boucle d'événements pour l'orchestration et un pool de travailleurs pour les tâches coûteuses.

Quel code s'exécute sur la boucle d'événements ?

Lors de leur démarrage, les applications Node.js effectuent d'abord une phase d'initialisation, en utilisant require pour les modules et en enregistrant des rappels pour les événements. Les applications Node.js entrent ensuite dans la boucle d'événements, répondant aux requêtes client entrantes en exécutant le rappel approprié. Ce rappel s'exécute de manière synchrone et peut enregistrer des requêtes asynchrones pour poursuivre le traitement une fois terminé. Les rappels de ces requêtes asynchrones seront également exécutés sur la boucle d'événements.

La boucle d'événements remplira également les requêtes asynchrones non bloquantes effectuées par ses rappels, par exemple, les E/S réseau.

En résumé, la boucle d'événements exécute les rappels JavaScript enregistrés pour les événements et est également responsable de la satisfaction des requêtes asynchrones non bloquantes telles que les E/S réseau.

Quel code s'exécute sur le pool de travailleurs ?

Le pool de travailleurs de Node.js est implémenté dans libuv (docs), qui expose une API générale de soumission de tâches.

Node.js utilise le pool de travailleurs pour gérer les tâches « coûteuses ». Cela inclut les E/S pour lesquelles un système d'exploitation ne fournit pas de version non bloquante, ainsi que les tâches particulièrement gourmandes en CPU.

Ce sont les API de module Node.js qui utilisent ce pool de travailleurs :

  1. gourmandes en E/S
    1. DNS : dns.lookup(), dns.lookupService().
    2. [Système de fichiers][/api/fs] : Toutes les API du système de fichiers, sauf fs.FSWatcher() et celles qui sont explicitement synchrones, utilisent le threadpool de libuv.
  2. gourmandes en CPU
    1. Crypto : crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair().
    2. Zlib : Toutes les API zlib, sauf celles qui sont explicitement synchrones, utilisent le threadpool de libuv.

Dans de nombreuses applications Node.js, ces API sont les seules sources de tâches pour le pool de travailleurs. Les applications et les modules qui utilisent un module d'extension C++ peuvent soumettre d'autres tâches au pool de travailleurs.

Pour des raisons de complétude, nous notons que lorsque vous appelez l'une de ces API à partir d'un rappel sur la boucle d'événements, la boucle d'événements paie des coûts de configuration mineurs lorsqu'elle entre dans les liaisons C++ Node.js pour cette API et soumet une tâche au pool de travailleurs. Ces coûts sont négligeables par rapport au coût global de la tâche, c'est pourquoi la boucle d'événements la décharge. Lors de la soumission de l'une de ces tâches au pool de travailleurs, Node.js fournit un pointeur vers la fonction C++ correspondante dans les liaisons C++ Node.js.

Comment Node.js décide-t-il quel code exécuter ensuite ?

Abstraitement, la boucle d'événements et le pool de travailleurs maintiennent respectivement des files d'attente pour les événements en attente et les tâches en attente.

En réalité, la boucle d'événements ne maintient pas réellement de file d'attente. Elle possède plutôt une collection de descripteurs de fichiers qu'elle demande au système d'exploitation de surveiller, en utilisant un mécanisme comme epoll (Linux), kqueue (OSX), les ports d'événements (Solaris), ou IOCP (Windows). Ces descripteurs de fichiers correspondent aux sockets réseau, à tous les fichiers qu'il surveille, etc. Lorsque le système d'exploitation indique qu'un de ces descripteurs de fichiers est prêt, la boucle d'événements le traduit en l'événement approprié et appelle la ou les callback(s) associée(s) à cet événement. Vous pouvez en apprendre davantage sur ce processus ici.

En revanche, le pool de travailleurs utilise une véritable file d'attente dont les entrées sont des tâches à traiter. Un travailleur extrait une tâche de cette file d'attente et y travaille, et lorsqu'il a terminé, le travailleur déclenche un événement « Au moins une tâche est terminée » pour la boucle d'événements.

Qu'est-ce que cela signifie pour la conception d'applications ?

Dans un système un thread par client comme Apache, chaque client en attente se voit attribuer son propre thread. Si un thread gérant un client bloque, le système d'exploitation l'interrompra et laissera un autre client prendre la relève. Le système d'exploitation garantit ainsi que les clients qui nécessitent une petite quantité de travail ne sont pas pénalisés par les clients qui nécessitent plus de travail.

Étant donné que Node.js gère de nombreux clients avec peu de threads, si un thread bloque le traitement de la requête d'un client, les requêtes client en attente peuvent ne pas être traitées tant que le thread n'a pas terminé son callback ou sa tâche. Le traitement équitable des clients est donc de la responsabilité de votre application. Cela signifie que vous ne devez pas effectuer trop de travail pour un client dans un seul callback ou une seule tâche.

C'est en partie pour cela que Node.js peut s'adapter facilement, mais cela signifie également que vous êtes responsable de la planification équitable. Les sections suivantes expliquent comment assurer une planification équitable pour la boucle d'événements et pour le pool de travailleurs.

Ne bloquez pas la boucle d'événements

La boucle d'événements remarque chaque nouvelle connexion client et orchestre la génération d'une réponse. Toutes les requêtes entrantes et les réponses sortantes passent par la boucle d'événements. Cela signifie que si la boucle d'événements passe trop de temps à un moment donné, tous les clients actuels et nouveaux n'auront pas leur tour.

Vous devez vous assurer de ne jamais bloquer la boucle d'événements. En d'autres termes, chacun de vos rappels JavaScript doit se terminer rapidement. Cela s'applique bien sûr également à vos await, vos Promise.then, etc.

Une bonne façon de garantir cela est de raisonner sur la "complexité computationnelle" de vos rappels. Si votre rappel effectue un nombre constant d'étapes quels que soient ses arguments, vous donnerez toujours à chaque client en attente un tour équitable. Si votre rappel effectue un nombre différent d'étapes en fonction de ses arguments, vous devez réfléchir à la longueur des arguments.

Exemple 1 : Un rappel à temps constant.

js
app.get('/constant-time', (req, res) => {
  res.sendStatus(200)
})

Exemple 2 : Un rappel O(n). Ce rappel s'exécutera rapidement pour les petits n et plus lentement pour les grands n.

js
app.get('/countToN', (req, res) => {
  let n = req.query.n
  // n itérations avant de donner le tour à quelqu'un d'autre
  for (let i = 0; i < n; i++) {
    console.log(`Iter ${i}`)
  }
  res.sendStatus(200)
})

Exemple 3 : Un rappel O(n^2). Ce rappel s'exécutera toujours rapidement pour les petits n, mais pour les grands n, il s'exécutera beaucoup plus lentement que l'exemple O(n) précédent.

js
app.get('/countToN2', (req, res) => {
  let n = req.query.n
  // n^2 itérations avant de donner le tour à quelqu'un d'autre
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      console.log(`Iter ${i}.${j}`)
    }
  }
  res.sendStatus(200)
})

Quelle attention faut-il porter ?

Node.js utilise le moteur Google V8 pour JavaScript, qui est assez rapide pour de nombreuses opérations courantes. Les exceptions à cette règle sont les expressions régulières et les opérations JSON, décrites ci-dessous.

Cependant, pour les tâches complexes, vous devez envisager de borner l'entrée et de rejeter les entrées trop longues. De cette façon, même si votre rappel a une complexité importante, en bornant l'entrée, vous vous assurez que le rappel ne peut pas prendre plus que le temps du pire des cas sur l'entrée acceptable la plus longue. Vous pouvez ensuite évaluer le coût du pire des cas de ce rappel et déterminer si son temps d'exécution est acceptable dans votre contexte.

Blocage de la boucle d'événements : REDOS

Un moyen courant de bloquer désastreusement la boucle d'événements est d'utiliser une expression régulière « vulnérable » expression régulière.

Éviter les expressions régulières vulnérables

Une expression régulière (regexp) compare une chaîne de caractères d'entrée à un motif. On considère généralement qu'une correspondance regexp nécessite une seule passe dans la chaîne d'entrée --- O(n) temps où n est la longueur de la chaîne d'entrée. Dans de nombreux cas, une seule passe suffit. Malheureusement, dans certains cas, la correspondance regexp peut nécessiter un nombre exponentiel de passages dans la chaîne d'entrée --- O(2^n) temps. Un nombre exponentiel de passages signifie que si le moteur nécessite x passages pour déterminer une correspondance, il aura besoin de 2*x passages si nous ajoutons seulement un caractère de plus à la chaîne d'entrée. Le nombre de passages étant linéairement lié au temps requis, l'effet de cette évaluation sera de bloquer la boucle d'événements.

Une expression régulière vulnérable est une expression pour laquelle votre moteur d'expressions régulières peut prendre un temps exponentiel, vous exposant à REDOS sur une « entrée malveillante ». Déterminer si votre motif d'expression régulière est vulnérable (c'est-à-dire si le moteur d'expressions régulières peut prendre un temps exponentiel) est en réalité une question difficile à résoudre, et cela varie selon que vous utilisez Perl, Python, Ruby, Java, JavaScript, etc., mais voici quelques règles empiriques qui s'appliquent à tous ces langages :

  1. Évitez les quantificateurs imbriqués comme (a+)*. Le moteur regexp de V8 peut gérer rapidement certains d'entre eux, mais d'autres sont vulnérables.
  2. Évitez les « OU » avec des clauses qui se chevauchent, comme (a|a)*. Là encore, ceux-ci sont parfois rapides.
  3. Évitez d'utiliser des backréférences, comme (a.*) \1. Aucun moteur regexp ne peut garantir l'évaluation de celles-ci en temps linéaire.
  4. Si vous effectuez une simple correspondance de chaînes, utilisez indexOf ou l'équivalent local. Ce sera moins coûteux et ne prendra jamais plus de O(n).

Si vous n'êtes pas sûr que votre expression régulière soit vulnérable, n'oubliez pas que Node.js n'a généralement pas de problème à signaler une correspondance, même pour une regexp vulnérable et une longue chaîne d'entrée. Le comportement exponentiel est déclenché lorsqu'il y a une non-correspondance, mais Node.js ne peut pas en être certain avant d'avoir essayé de nombreux chemins dans la chaîne d'entrée.

Un exemple de REDOS

Voici un exemple de regexp vulnérable exposant son serveur à REDOS :

js
app.get('/redos-me', (req, res) => {
  let filePath = req.query.filePath
  // REDOS
  if (filePath.match(/(\/.+)+$/)) {
    console.log('valid path')
  } else {
    console.log('invalid path')
  }
  res.sendStatus(200)
})

L’expression régulière vulnérable dans cet exemple est une mauvaise façon de vérifier un chemin valide sous Linux. Elle correspond aux chaînes qui sont une séquence de noms délimités par "/", comme /a/b/c. Elle est dangereuse car elle viole la règle 1 : elle possède un quantificateur doublement imbriqué.

Si un client interroge avec filePath ///.../\n (100 "/" suivis d'un caractère de nouvelle ligne que le "." de l'expression régulière ne correspondra pas), alors la boucle d'événements prendra effectivement une éternité, bloquant la boucle d'événements. L'attaque REDOS de ce client empêche tous les autres clients d'obtenir un tour jusqu'à ce que la correspondance d'expression régulière se termine.

Pour cette raison, vous devriez vous méfier de l'utilisation d'expressions régulières complexes pour valider les données saisies par l'utilisateur.

Ressources anti-REDOS

Il existe des outils pour vérifier la sécurité de vos expressions régulières, tels que :

Cependant, aucun de ces outils ne détectera toutes les expressions régulières vulnérables.

Une autre approche consiste à utiliser un moteur d'expressions régulières différent. Vous pouvez utiliser le module node-re2, qui utilise le moteur d'expressions régulières RE2 ultrarapide de Google. Mais attention, RE2 n'est pas compatible à 100 % avec les expressions régulières de V8, vérifiez donc les régressions si vous remplacez le module node-re2 pour gérer vos expressions régulières. Et les expressions régulières particulièrement complexes ne sont pas prises en charge par node-re2.

Si vous essayez de faire correspondre quelque chose d'"évident", comme une URL ou un chemin de fichier, trouvez un exemple dans une bibliothèque d'expressions régulières ou utilisez un module npm, par exemple ip-regex.

Blocage de la boucle d'événements : modules principaux de Node.js

Plusieurs modules principaux de Node.js possèdent des API coûteuses synchrones, notamment :

Ces API sont coûteuses, car elles impliquent des calculs importants (chiffrement, compression), nécessitent des E/S (E/S de fichiers) ou potentiellement les deux (processus enfant). Ces API sont destinées à la commodité des scripts, mais ne sont pas destinées à être utilisées dans le contexte du serveur. Si vous les exécutez sur la boucle d'événements, elles mettront beaucoup plus de temps à se terminer qu'une instruction JavaScript typique, bloquant la boucle d'événements.

Dans un serveur, vous ne devez pas utiliser les API synchrones suivantes de ces modules :

  • Chiffrement :
    • crypto.randomBytes (version synchrone)
    • crypto.randomFillSync
    • crypto.pbkdf2Sync
    • Vous devez également faire attention à fournir une entrée importante aux routines de chiffrement et de déchiffrement.
  • Compression :
    • zlib.inflateSync
    • zlib.deflateSync
  • Système de fichiers :
    • N'utilisez pas les API synchrones du système de fichiers. Par exemple, si le fichier auquel vous accédez se trouve dans un système de fichiers distribué comme NFS, les temps d'accès peuvent varier considérablement.
  • Processus enfant :
    • child_process.spawnSync
    • child_process.execSync
    • child_process.execFileSync

Cette liste est raisonnablement complète à partir de Node.js v9.

Blocage de la boucle d'événements : attaque par déni de service JSON

JSON.parse et JSON.stringify sont d'autres opérations potentiellement coûteuses. Bien qu'elles soient en O(n) par rapport à la longueur de l'entrée, pour un n important, elles peuvent prendre un temps surprenant.

Si votre serveur manipule des objets JSON, en particulier ceux provenant d'un client, vous devez faire attention à la taille des objets ou des chaînes avec lesquels vous travaillez sur la boucle d'événements.

Exemple : blocage JSON. Nous créons un objet obj de taille 2^21 et nous le convertissons en chaîne avec JSON.stringify, exécutons indexOf sur la chaîne, puis JSON.parse. La chaîne JSON.stringify'd fait 50 Mo. Il faut 0,7 seconde pour convertir l'objet en chaîne, 0,03 seconde pour indexOf sur la chaîne de 50 Mo et 1,3 seconde pour analyser la chaîne.

js
let obj = { a: 1 }
let niter = 20
let before, str, pos, res, took
for (let i = 0; i < niter; i++) {
  obj = { obj1: obj, obj2: obj } // Double de taille à chaque itération
}
before = process.hrtime()
str = JSON.stringify(obj)
took = process.hrtime(before)
console.log('JSON.stringify took ' + took)
before = process.hrtime()
pos = str.indexOf('nomatch')
took = process.hrtime(before)
console.log('Pure indexof took ' + took)
before = process.hrtime()
res = JSON.parse(str)
took = process.hrtime(before)
console.log('JSON.parse took ' + took)

Il existe des modules npm qui offrent des API JSON asynchrones. Voir par exemple :

  • JSONStream, qui possède des API de flux.
  • Big-Friendly JSON, qui possède également des API de flux ainsi que des versions asynchrones des API JSON standard utilisant le paradigme de partitionnement sur la boucle d'événements décrit ci-dessous.

Calculs complexes sans bloquer la boucle d'événements

Supposons que vous souhaitiez effectuer des calculs complexes en JavaScript sans bloquer la boucle d'événements. Vous avez deux options : le partitionnement ou le déchargement.

Partitionnement

Vous pouvez partitionner vos calculs afin que chacun s'exécute sur la boucle d'événements mais cède régulièrement (donne des tours à) d'autres événements en attente. En JavaScript, il est facile de sauvegarder l'état d'une tâche en cours dans une closure, comme le montre l'exemple 2 ci-dessous.

Pour un exemple simple, supposons que vous souhaitiez calculer la moyenne des nombres de 1 à n.

Exemple 1 : moyenne non partitionnée, coûte O(n)

js
for (let i = 0; i < n; i++) sum += i
let avg = sum / n
console.log('avg: ' + avg)

Exemple 2 : moyenne partitionnée, chacune des n étapes asynchrones coûte O(1).

js
function asyncAvg(n, avgCB) {
  // Sauvegarder la somme en cours dans la closure JS.
  let sum = 0
  function help(i, cb) {
    sum += i
    if (i == n) {
      cb(sum)
      return
    }
    // "Récursion asynchrone".
    // Planifier l'opération suivante de manière asynchrone.
    setImmediate(help.bind(null, i + 1, cb))
  }
  // Démarrer l'assistant, avec CB pour appeler avgCB.
  help(1, function (sum) {
    let avg = sum / n
    avgCB(avg)
  })
}
asyncAvg(n, function (avg) {
  console.log('avg of 1-n: ' + avg)
})

Vous pouvez appliquer ce principe aux itérations de tableau, etc.

Déchargement

Si vous devez effectuer une tâche plus complexe, le partitionnement n'est pas une bonne option. En effet, le partitionnement n'utilise que la boucle d'événements, et vous ne profiterez probablement pas des multiples cœurs disponibles sur votre machine. N'oubliez pas que la boucle d'événements doit orchestrer les requêtes client, et non les exécuter elle-même. Pour une tâche compliquée, déplacez le travail de la boucle d'événements vers un pool de workers.

Comment décharger

Vous avez deux options pour un pool de workers de destination sur lequel décharger le travail.

  1. Vous pouvez utiliser le pool de workers Node.js intégré en développant un module d'extension C++. Sur les anciennes versions de Node, construisez votre module d'extension C++ en utilisant NAN, et sur les versions plus récentes, utilisez N-API. node-webworker-threads offre un moyen d'accéder au pool de workers Node.js en JavaScript uniquement.
  2. Vous pouvez créer et gérer votre propre pool de workers dédié au calcul plutôt qu'au pool de workers Node.js axé sur les E/S. Les moyens les plus simples de le faire sont d'utiliser Child Process ou Cluster.

Vous ne devez pas simplement créer un processus enfant pour chaque client. Vous pouvez recevoir les requêtes client plus rapidement que vous ne pouvez créer et gérer les processus enfants, et votre serveur pourrait devenir une bombe à fourches.

Inconvénients du déchargement L'inconvénient de l'approche de déchargement est qu'elle entraîne des frais généraux sous forme de coûts de communication. Seule la boucle d'événements est autorisée à voir l'« espace de noms » (état JavaScript) de votre application. À partir d'un worker, vous ne pouvez pas manipuler un objet JavaScript dans l'espace de noms de la boucle d'événements. Au lieu de cela, vous devez sérialiser et désérialiser tous les objets que vous souhaitez partager. Ensuite, le worker peut opérer sur sa propre copie de ces objets et renvoyer l'objet modifié (ou un « patch ») à la boucle d'événements.

Pour les problèmes de sérialisation, consultez la section sur JSON DOS.

Quelques suggestions pour le déchargement

Vous pouvez souhaiter faire la distinction entre les tâches gourmandes en CPU et les tâches gourmandes en E/S, car elles présentent des caractéristiques très différentes.

Une tâche gourmande en CPU ne progresse que lorsque son worker est planifié, et le worker doit être planifié sur l'un des cœurs logiques de votre machine. Si vous avez 4 cœurs logiques et 5 workers, l'un de ces workers ne peut pas progresser. Par conséquent, vous payez des frais généraux (coûts mémoire et de planification) pour ce worker et n'obtenez rien en retour.

Les tâches gourmandes en E/S impliquent l'interrogation d'un fournisseur de services externe (DNS, système de fichiers, etc.) et l'attente de sa réponse. Lorsqu'un worker avec une tâche gourmande en E/S attend sa réponse, il n'a rien d'autre à faire et peut être désordonné par le système d'exploitation, donnant à un autre worker la possibilité de soumettre sa requête. Ainsi, les tâches gourmandes en E/S progresseront même lorsque le thread associé ne fonctionne pas. Les fournisseurs de services externes tels que les bases de données et les systèmes de fichiers ont été hautement optimisés pour gérer de nombreuses requêtes en attente simultanément. Par exemple, un système de fichiers examinera un grand ensemble de requêtes d'écriture et de lecture en attente pour fusionner les mises à jour conflictuelles et récupérer les fichiers dans un ordre optimal.

Si vous ne vous fiez qu'à un seul pool de workers, par exemple le pool de workers Node.js, les caractéristiques différentes des tâches liées au CPU et aux E/S peuvent nuire aux performances de votre application.

Pour cette raison, vous pouvez souhaiter maintenir un pool de workers de calcul séparé.

Déchargement : conclusions

Pour les tâches simples, comme l'itération sur les éléments d'un tableau arbitrairement long, le partitionnement peut être une bonne option. Si votre calcul est plus complexe, le déchargement est une meilleure approche : les coûts de communication, c'est-à-dire les frais généraux de passage d'objets sérialisés entre la boucle d'événements et le pool de travailleurs, sont compensés par l'avantage d'utiliser plusieurs cœurs.

Cependant, si votre serveur repose fortement sur des calculs complexes, vous devriez vous demander si Node.js est vraiment adapté. Node.js excelle pour les travaux liés aux E/S, mais pour les calculs coûteux, ce n'est peut-être pas la meilleure option.

Si vous adoptez l'approche du déchargement, consultez la section sur le blocage du pool de travailleurs.

Ne bloquez pas le pool de travailleurs

Node.js possède un pool de travailleurs composé de k travailleurs. Si vous utilisez le paradigme de déchargement décrit ci-dessus, vous pouvez avoir un pool de travailleurs de calcul séparé, auquel les mêmes principes s'appliquent. Dans les deux cas, supposons que k est beaucoup plus petit que le nombre de clients que vous pourriez gérer simultanément. Cela est conforme à la philosophie « un thread pour de nombreux clients » de Node.js, le secret de son évolutivité.

Comme indiqué ci-dessus, chaque travailleur termine sa tâche actuelle avant de passer à la suivante dans la file d'attente du pool de travailleurs.

Maintenant, il y aura une variation dans le coût des tâches nécessaires pour gérer les requêtes de vos clients. Certaines tâches peuvent être effectuées rapidement (par exemple, la lecture de fichiers courts ou mis en cache, ou la production d'un petit nombre d'octets aléatoires), tandis que d'autres prendront plus de temps (par exemple, la lecture de fichiers plus volumineux ou non mis en cache, ou la génération d'un plus grand nombre d'octets aléatoires). Votre objectif doit être de minimiser la variation des temps de tâche, et vous devez utiliser le partitionnement des tâches pour y parvenir.

Minimiser la variation des temps de tâche

Si la tâche actuelle d'un travailleur est beaucoup plus coûteuse que les autres tâches, elle ne sera pas disponible pour travailler sur d'autres tâches en attente. En d'autres termes, chaque tâche relativement longue diminue efficacement la taille du pool de travailleurs d'un jusqu'à ce qu'elle soit terminée. Ceci est indésirable car, jusqu'à un certain point, plus il y a de travailleurs dans le pool de travailleurs, plus le débit du pool de travailleurs (tâches/seconde) est élevé et donc plus le débit du serveur (requêtes client/seconde) est élevé. Un client avec une tâche relativement coûteuse diminuera le débit du pool de travailleurs, diminuant ainsi le débit du serveur.

Pour éviter cela, vous devez essayer de minimiser la variation de la durée des tâches que vous soumettez au pool de travailleurs. S'il est approprié de traiter les systèmes externes accessibles par vos requêtes E/S (DB, FS, etc.) comme des boîtes noires, vous devez être conscient du coût relatif de ces requêtes E/S et éviter de soumettre des requêtes que vous pouvez vous attendre à être particulièrement longues.

Deux exemples devraient illustrer la variation possible des temps de tâche.

Exemple de variation : lectures de système de fichiers de longue durée

Supposons que votre serveur doive lire des fichiers afin de traiter certaines demandes client. Après avoir consulté les API du système de fichiers File system de Node.js, vous avez opté pour l'utilisation de fs.readFile() par souci de simplicité. Cependant, fs.readFile() n'est (actuellement) pas partitionné : il soumet une seule tâche fs.read() couvrant l'ensemble du fichier. Si vous lisez des fichiers plus courts pour certains utilisateurs et des fichiers plus longs pour d'autres, fs.readFile() peut introduire une variation significative de la durée des tâches, au détriment du débit du pool de workers.

Dans le pire des cas, supposons qu'un attaquant puisse convaincre votre serveur de lire un fichier arbitraire (il s'agit d'une vulnérabilité de traversée de répertoire). Si votre serveur exécute Linux, l'attaquant peut nommer un fichier extrêmement lent : /dev/random. Dans la pratique, /dev/random est infiniment lent, et chaque worker chargé de lire depuis /dev/random ne terminera jamais cette tâche. Un attaquant soumet ensuite k demandes, une pour chaque worker, et aucune autre demande client utilisant le pool de workers ne progressera.

Exemple de variation : opérations cryptographiques de longue durée

Supposons que votre serveur génère des octets aléatoires cryptographiquement sécurisés à l'aide de crypto.randomBytes(). crypto.randomBytes() n'est pas partitionné : il crée une seule tâche randomBytes() pour générer autant d'octets que vous l'avez demandé. Si vous créez moins d'octets pour certains utilisateurs et plus d'octets pour d'autres, crypto.randomBytes() est une autre source de variation de la durée des tâches.

Partitionnement des tâches

Les tâches ayant des coûts temporels variables peuvent nuire au débit du pool de workers. Pour minimiser la variation des temps de tâche, dans la mesure du possible, vous devez partitionner chaque tâche en sous-tâches de coût comparable. Lorsque chaque sous-tâche est terminée, elle doit soumettre la sous-tâche suivante, et lorsque la dernière sous-tâche est terminée, elle doit en informer l'expéditeur.

Pour reprendre l'exemple de fs.readFile(), vous devez plutôt utiliser fs.read() (partitionnement manuel) ou ReadStream (partitionnement automatique).

Le même principe s'applique aux tâches liées au processeur ; l'exemple asyncAvg peut être inapproprié pour la boucle d'événements, mais il est bien adapté au pool de workers.

Lorsque vous partitionnez une tâche en sous-tâches, les tâches plus courtes se développent en un petit nombre de sous-tâches, et les tâches plus longues se développent en un plus grand nombre de sous-tâches. Entre chaque sous-tâche d'une tâche plus longue, le worker auquel elle a été affectée peut travailler sur une sous-tâche d'une autre tâche plus courte, améliorant ainsi le débit global des tâches du pool de workers.

Notez que le nombre de sous-tâches terminées n'est pas une mesure utile du débit du pool de workers. Occupez-vous plutôt du nombre de tâches terminées.

Éviter le partitionnement des tâches

Rappelons que le but du partitionnement des tâches est de minimiser la variation des temps de tâche. Si vous pouvez distinguer les tâches plus courtes des tâches plus longues (par exemple, la somme d'un tableau par rapport au tri d'un tableau), vous pouvez créer un pool de travailleurs pour chaque classe de tâche. Le routage des tâches courtes et des tâches longues vers des pools de travailleurs séparés est un autre moyen de minimiser la variation du temps de tâche.

En faveur de cette approche, le partitionnement des tâches entraîne des frais généraux (les coûts de création d'une représentation de tâche du pool de travailleurs et de la manipulation de la file d'attente du pool de travailleurs), et l'évitement du partitionnement vous permet d'économiser les coûts des déplacements supplémentaires vers le pool de travailleurs. Cela vous évite également de faire des erreurs dans le partitionnement de vos tâches.

L'inconvénient de cette approche est que les travailleurs de tous ces pools de travailleurs subiront des frais généraux d'espace et de temps et se feront concurrence pour le temps CPU. N'oubliez pas que chaque tâche liée au processeur ne progresse que lorsqu'elle est planifiée. Par conséquent, vous ne devriez envisager cette approche qu'après une analyse minutieuse.

Pool de travailleurs : conclusions

Que vous utilisiez uniquement le pool de travailleurs Node.js ou que vous mainteniez des pools de travailleurs séparés, vous devez optimiser le débit des tâches de votre ou vos pools.

Pour ce faire, minimisez la variation des temps de tâche en utilisant le partitionnement des tâches.

Les risques des modules npm

Alors que les modules principaux de Node.js offrent des blocs de construction pour une grande variété d'applications, il est parfois nécessaire d'en faire plus. Les développeurs Node.js bénéficient énormément de l'écosystème npm, avec des centaines de milliers de modules offrant des fonctionnalités pour accélérer votre processus de développement.

N'oubliez pas, cependant, que la majorité de ces modules sont écrits par des développeurs tiers et sont généralement publiés avec seulement des garanties de meilleure tentative. Un développeur utilisant un module npm doit se préoccuper de deux choses, bien que la seconde soit souvent oubliée.

  1. Respecte-t-il ses API ?
  2. Ses API pourraient-elles bloquer la boucle d'événements ou un travailleur ? De nombreux modules ne font aucun effort pour indiquer le coût de leurs API, au détriment de la communauté.

Pour les API simples, vous pouvez estimer le coût des API ; le coût de la manipulation de chaînes n'est pas difficile à comprendre. Mais dans de nombreux cas, il n'est pas clair combien une API peut coûter.

Si vous appelez une API qui pourrait faire quelque chose de coûteux, vérifiez le coût. Demandez aux développeurs de le documenter ou examinez le code source vous-même (et soumettez un PR documentant le coût).

N'oubliez pas que, même si l'API est asynchrone, vous ne savez pas combien de temps elle pourrait passer sur un travailleur ou sur la boucle d'événements dans chacune de ses partitions. Par exemple, supposons que dans l'exemple asyncAvg donné ci-dessus, chaque appel à la fonction auxiliaire a additionné la moitié des nombres plutôt que l'un d'eux. Alors cette fonction serait toujours asynchrone, mais le coût de chaque partition serait O(n), et non O(1), ce qui la rend beaucoup moins sûre à utiliser pour des valeurs arbitraires de n.

Conclusion

Node.js possède deux types de threads : une boucle d'événements et des Workers k. La boucle d'événements est responsable des rappels JavaScript et des E/S non bloquantes, et un Worker exécute les tâches correspondant au code C++ qui complète une requête asynchrone, y compris les E/S bloquantes et les tâches gourmandes en CPU. Les deux types de threads ne travaillent que sur une activité à la fois. Si un rappel ou une tâche prend beaucoup de temps, le thread qui l'exécute est bloqué. Si votre application effectue des rappels ou des tâches bloquants, cela peut entraîner une baisse du débit (clients/seconde) au mieux, et un déni de service complet au pire.

Pour écrire un serveur web à haut débit et plus résistant aux attaques par déni de service, vous devez vous assurer que, sur des entrées bénignes et malveillantes, ni votre boucle d'événements ni vos Workers ne seront bloqués.