Fermer

août 25, 2020

La boucle d'événements Node.js: un guide du développeur sur les concepts et le code


L'asynchronie dans n'importe quel langage de programmation est difficile. Des concepts comme la concurrence, le parallélisme et les blocages font frissonner même les ingénieurs les plus chevronnés. Le code qui s'exécute de manière asynchrone est imprévisible et difficile à tracer en cas de bogues. Le problème est incontournable car l'informatique moderne a plusieurs cœurs. Il y a une limite thermique dans chaque cœur du processeur et rien ne va plus vite. Cela met la pression sur le développeur pour qu'il écrive un code efficace qui tire parti du matériel.

JavaScript est monothread, mais est-ce que cela empêche Node d'utiliser l'architecture moderne? L'un des plus grands défis consiste à gérer plusieurs threads en raison de sa complexité inhérente. Créer de nouveaux threads et gérer le changement de contexte entre les deux coûte cher. Le système d'exploitation et le programmeur doivent faire beaucoup de travail pour fournir une solution qui présente de nombreux cas extrêmes. Dans cette prise, je vais vous montrer comment Node gère ce bourbier via la boucle d'événements. J'explorerai chaque partie de la boucle d'événements Node.js et montrerai comment cela fonctionne. Cette boucle est l'une des fonctionnalités de "l'application qui tue" dans Node, car elle a résolu un problème difficile d'une manière radicalement nouvelle.

Qu'est-ce que la boucle d'événements?

La boucle d'événements est une boucle à thread unique, non bloquante , et boucle concurrente asynchrone. Pour ceux qui n'ont pas de diplôme en informatique, imaginez une requête Web qui effectue une recherche dans une base de données. Un seul thread ne peut faire qu'une seule chose à la fois. Au lieu d'attendre que la base de données réponde, elle continue de récupérer d'autres tâches dans la file d'attente. Dans la boucle d'événements, la boucle principale déroule la pile d'appels et n'attend pas les rappels. Étant donné que la boucle ne se bloque pas, il est gratuit de travailler sur plusieurs requêtes Web à la fois. Plusieurs demandes peuvent être mises en file d'attente en même temps, ce qui les rend simultanées. La boucle n'attend pas que tout d'une requête se termine, mais récupère les rappels au fur et à mesure qu'ils arrivent sans blocage.

La boucle elle-même est semi-infinie, ce qui signifie que si la pile d'appels ou la file d'attente de rappel sont vides, elle peut quitter le boucle. Considérez la pile d'appels comme un code synchrone qui se déroule, comme console.log avant que la boucle ne demande plus de travail. Node utilise libuv sous les couvertures pour interroger le système d'exploitation pour les rappels des connexions entrantes.

Vous vous demandez peut-être pourquoi la boucle d'événements s'exécute-t-elle dans un seul thread? Les threads sont relativement lourds en mémoire pour les données dont ils ont besoin par connexion. Les threads sont des ressources du système d'exploitation qui tournent, et cela ne s'adapte pas à des milliers de connexions actives.

Plusieurs threads en général compliquent également l'histoire. Si un rappel revient avec des données, il doit marshaler le contexte vers le thread en cours d'exécution. La commutation de contexte entre les threads est lente, car elle doit synchroniser l'état actuel comme la pile d'appels ou les variables locales. La boucle d'événements écrase les bogues lorsque plusieurs threads partagent des ressources, car il s'agit d'un seul thread. Une boucle à un seul thread coupe les cas de bord de sécurité des threads et peut changer de contexte beaucoup plus rapidement. C'est le vrai génie derrière la boucle. Il utilise efficacement les connexions et les threads tout en restant évolutif.

Assez de théorie; il est temps de voir à quoi cela ressemble dans le code. N'hésitez pas à suivre dans une REPL ou à télécharger le code source .

Boucle semi-infinie

La plus grande question à laquelle la boucle d'événements doit répondre est de savoir si la boucle est vivante. Si tel est le cas, il détermine combien de temps attendre dans la file d'attente de rappel. À chaque itération, la boucle déroule la pile d’appels, puis interroge.

Voici un exemple qui bloque la boucle principale:

 setTimeout (
  () => console.log ('Salut de la file d'attente de rappel'),
  5000); // Gardez la boucle vivante aussi longtemps

const stopTime = Date.now () + 2000;
while (Date.now () <stopTime) {} // Bloquer la boucle principale

Si vous exécutez ce code, notez que la boucle est bloquée pendant deux secondes. Mais la boucle reste active jusqu'à ce que le rappel s'exécute dans cinq secondes. Une fois la boucle principale débloquée, le mécanisme d'interrogation détermine combien de temps il attend les rappels. Cette boucle meurt lorsque la pile d'appels se déroule et qu'il n'y a plus de callbacks.

The Callback Queue

Maintenant, que se passe-t-il lorsque je bloque la boucle principale et que je programme un rappel? Une fois que la boucle est bloquée, elle ne met plus de rappels dans la file d’attente:

 const stopTime = Date.now () + 2000;
while (Date.now () < stopTime) {} // Block the main loop

// This takes 7 secs to execute
setTimeout(() => console.log ('Ran callback A'), 5000);

Cette fois, la boucle reste active pendant sept secondes. La boucle d'événements est stupide dans sa simplicité. Il n'a aucun moyen de savoir ce qui pourrait être mis en file d'attente à l'avenir. Dans un système réel, les rappels entrants sont mis en file d'attente et exécutés car la boucle principale est libre d'interroger. La boucle d'événements passe par plusieurs phases séquentiellement lorsqu'elle est débloquée. Donc, pour réussir cet entretien d'embauche sur la boucle, évitez le jargon sophistiqué comme «émetteur d'événements» ou «modèle de réacteur». C'est une humble boucle monothread, concurrente et non bloquante.

La boucle d'événements avec async / await

Pour éviter de bloquer la boucle principale, une idée est d'enrouler les E / S synchrones autour de async / await: [19659012] const fs = require ('fs');
const readFileSync = async (chemin) => attendre fs.readFileSync (chemin);

readFileSync ('readme.md'). then ((données) => console.log (données));
console.log ('La boucle d'événements continue sans blocage …');

Tout ce qui vient après le attente provient de la file d'attente de rappel. Le code se lit comme un code de blocage synchrone, mais il ne bloque pas. Notez que async / await rend readFileSync puisable ce qui le retire de la boucle principale. Pensez à tout ce qui vient après await comme non bloquant via un rappel.

Divulgation complète: le code ci-dessus est uniquement à des fins de démonstration. En vrai code, je recommande fs.readFile qui déclenche un callback qui peut être enroulé autour d'une promesse. L'intention générale est toujours valide, car cela supprime le blocage des E / S de la boucle principale.

Pour aller plus loin

Et si je vous disais que la boucle d'événements a plus que la pile d'appels et la file d'attente de rappel? Et si la boucle d'événements n'était pas une seule mais plusieurs? Et si elle pouvait avoir plusieurs threads sous les couvertures?

Maintenant, je veux vous emmener derrière la façade et dans la mêlée des composants internes de Node.

Phases de boucle d'événement

Voici les phases de boucle d'événement: [19659005]  Phases de la boucle d'événement

Source de l'image: documentation libuv

  1. Les horodatages sont mis à jour. La boucle d'événements met en cache l'heure actuelle au début de la boucle pour éviter les appels système fréquents liés à l'heure. Ces appels système sont internes à libuv.

  2. La boucle est-elle active? Si la boucle a des poignées actives, des demandes actives ou des poignées de fermeture, elle est active. Comme indiqué, les rappels en attente dans la file d'attente maintiennent la boucle active.

  3. Les temporisations dues s'exécutent. C'est là que s'exécutent les rappels setTimeout ou setInterval . La boucle vérifie maintenant que le cache a des rappels actifs qui ont expiré.

  4. Les rappels en attente dans la file d'attente s'exécutent. Si l'itération précédente a reporté des rappels, ceux-ci s'exécutent à ce stade. L'interrogation exécute généralement les rappels d'E / S immédiatement, mais il existe des exceptions. Cette étape traite de tous les retardataires de l'itération précédente.

  5. Les gestionnaires inactifs s'exécutent – principalement à cause d'une mauvaise dénomination, car ils s'exécutent à chaque itération et sont internes à libuv.

  6. Préparez les descripteurs pour l'exécution du rappel setImmediate dans l'itération de la boucle. Ces descripteurs s'exécutent avant que la boucle ne bloque les E / S et prépare la file d'attente pour ce type de rappel. La boucle doit savoir combien de temps elle bloque pour les E / S. Voici comment il calcule le timeout:

    • Si la boucle est sur le point de se terminer, timeout est de 0.
    • S'il n'y a pas de descripteurs ou de requêtes actifs, timeout est de 0.
    • S'il y a des descripteurs inactifs, timeout est 0.
    • S'il y a des descripteurs en attente dans la file d'attente, timeout est 0.
    • S'il y a des descripteurs fermants, timeout est 0.
    • Si aucune des réponses ci-dessus, le timeout est défini au plus proche. minuterie, ou s'il n'y a pas de minuterie active, infini .
  7. La boucle se bloque pour les E / S avec la durée de la phase précédente. Les rappels liés aux E / S dans la file d'attente s'exécutent à ce stade.

  8. Les rappels de contrôle de handle s'exécutent. C'est dans cette phase que s'exécute setImmediate et c'est l'équivalent de la préparation des poignées. Tous les rappels setImmediate mis en file d'attente au milieu de l'exécution des rappels d'E / S s'exécutent ici.

  9. Les rappels de fermeture s'exécutent. Ce sont des descripteurs actifs supprimés à partir de connexions fermées.

  10. L'itération se termine.

Vous vous demandez peut-être pourquoi l'interrogation bloque les E / S alors qu'elle est censée ne pas bloquer? La boucle ne se bloque que lorsqu'il n'y a pas de rappel en attente dans la file d'attente et que la pile d'appels est vide. Dans Node, la minuterie la plus proche peut être définie par setTimeout par exemple. Si elle est définie sur l'infini, la boucle attend les connexions entrantes avec plus de travail. C'est une boucle semi-infinie, parce que l'interrogation maintient la boucle en vie quand il n'y a plus rien à faire et qu'il y a une connexion active.

Voici la version Unix de ce calcul de timeout est toute sa gloire C:

 int uv_backend_timeout (const uv_loop_t * boucle) {
  if (boucle-> stop_flag! = 0)
    return 0;

  if (! uv__has_active_handles (boucle) &&! uv__has_active_reqs (boucle))
    return 0;

  if (! QUEUE_EMPTY (& loop-> idle_handles))
    return 0;

  if (! QUEUE_EMPTY (& loop-> pending_queue))
    return 0;

  if (boucle-> poignées de fermeture)
    return 0;

  return uv__next_timeout (boucle);
}

Vous n'êtes peut-être pas très familier avec C, mais cela se lit comme l'anglais et fait exactement ce qui est dans la phase sept.

Une démonstration phase par phase

Pour afficher chaque phase en JavaScript simple:

 / / 1. La boucle commence, les horodatages sont mis à jour
const http = require ('http');

// 2. La boucle reste active s'il y a du code dans la pile d'appels à dérouler
// 8. Interrogez les E / S et exécutez ce rappel à partir des connexions entrantes
serveur const = http.createServer ((req, res) => {
  // Le rappel d'E / S réseau s'exécute immédiatement après l'interrogation
  res.end ();
});

// Garde la boucle active s'il y a une connexion ouverte
// 7. S'il ne reste plus rien à faire, calculez le délai
server.listen (8000);

options const = {
  // Évitez une recherche DNS pour rester en dehors du pool de threads
  nom d'hôte: '127.0.0.1',
  port: 8000
};

const sendHttpRequest = () => {
  // Les rappels d'E / S réseau s'exécutent en phase 8
  // Les rappels d'E / S de fichier s'exécutent en phase 4
  const req = http.request (options, () => {
    console.log ('Réponse reçue du serveur');

    // 9. Exécuter le rappel de la poignée de contrôle
    setImmediate (() =>
      // 10. Le rappel de fermeture s'exécute
       serveur.close (() =>
        // La fin. ALERTE SPOIL! La boucle meurt à la fin.
        console.log ('Fermeture du serveur')));
  });
  req.end ();
};

// 3. La minuterie s'exécute en 8 secondes, pendant que la boucle reste active
// Le timeout calculé avant l'interrogation le maintient en vie
setTimeout (() => sendHttpRequest (), 8000);

// 11. L'itération se termine

Etant donné que les rappels d'E / S de fichier s'exécutent dans la phase quatre et avant la phase neuf, attendez-vous à ce que setImmediate () se déclenche en premier:

 fs.readFile ('readme.md', () => {
  setTimeout (() => console.log ('Rappel d'E / S de fichier via setTimeout ()'), 0);
  // Ce rappel s'exécute en premier
  setImmediate (() => console.log ('Rappel d'E / S de fichier via setImmediate ()'));
});

Les E / S réseau sans recherche DNS sont moins chères que les E / S sur fichiers, car elles s'exécutent dans la boucle d'événements principale. Les E / S de fichiers sont à la place mises en file d'attente via le pool de threads. Une recherche DNS utilise également le pool de threads, ce qui rend les E / S réseau aussi chères que les E / S de fichiers.

Le pool de threads

Les composants internes des nœuds comportent deux parties principales: le moteur JavaScript V8 et libuv. Les E / S de fichier, la recherche DNS et les E / S réseau s'effectuent via libuv.

Voici l'architecture globale:

 Présentation de la conception du pool de threads

Source de l'image: documentation libuv

Pour les E / S réseau, la boucle d'événement interroge à l'intérieur du thread principal. Ce thread n'est pas thread-safe car il ne change pas de contexte avec un autre thread. Les E / S de fichiers et la recherche DNS sont spécifiques à la plate-forme, l'approche consiste donc à les exécuter dans un pool de threads. Une idée consiste à effectuer vous-même une recherche DNS pour rester en dehors du pool de threads, comme indiqué dans le code ci-dessus. Mettre une adresse IP par rapport à localhost par exemple, supprime la recherche du pool. Le pool de threads a un nombre limité de threads disponibles, qui peuvent être définis via la variable d'environnement UV_THREADPOOL_SIZE . La taille du pool de threads par défaut est d'environ 4.

V8 s'exécute dans une boucle séparée, vide la pile d'appels, puis redonne le contrôle à la boucle d'événements. La version 8 peut utiliser plusieurs threads pour le ramasse-miettes en dehors de sa propre boucle. Considérez V8 comme le moteur qui prend en JavaScript brut et l'exécute sur le matériel.

Pour le programmeur moyen, JavaScript reste monothread car il n'y a pas de thread-safety. Les internes V8 et libuv lancent leurs propres threads séparés pour répondre à leurs propres besoins.

S'il y a des problèmes de débit dans Node, commencez par la boucle d'événement principale. Vérifiez combien de temps il faut à l'application pour effectuer une seule itération. Cela ne devrait pas durer plus de cent millisecondes. Ensuite, vérifiez la famine du pool de threads et ce qui peut être expulsé du pool. Il est également possible d’augmenter la taille de la piscine via la variable d’environnement. La dernière étape consiste à effectuer un microbenchmark du code JavaScript dans la V8 qui s'exécute de manière synchrone.

Conclusion

La boucle d'événements continue à parcourir chaque phase lorsque les rappels sont mis en file d'attente. Mais, dans chaque phase, il existe un moyen de mettre en file d'attente un autre type de rappel.

process.nextTick () vs setImmediate ()

À la fin de chaque phase, la boucle exécute le process.nextTick () rappel. Notez que ce type de rappel ne fait pas partie de la boucle d'événements car il s'exécute à la fin de chaque phase. Le rappel setImmediate () fait partie de la boucle d'événements globale, il n'est donc pas aussi immédiat que son nom l'indique. Comme process.nextTick () a besoin d'une connaissance approfondie de la boucle d'événements, je recommande d'utiliser setImmediate () en général.

Il y a plusieurs raisons pour lesquelles vous pourriez avoir besoin de process.nextTick () :

  1. Autoriser les E / S réseau à gérer les erreurs, nettoyer ou réessayer la requête avant que la boucle ne continue.

  2. Il peut être nécessaire d’exécuter un rappel après le déroulement de la pile d’appels mais avant que la boucle ne continue.

Disons par exemple qu'un émetteur d'événement veut déclencher un événement alors qu'il est toujours dans son propre constructeur. La pile d'appels doit d'abord se dérouler avant d'appeler l'événement.

 const EventEmitter = require ('events');

La classe ImpatientEmitter étend EventEmitter {
  constructeur () {
    super();

    // Déclenchez ceci à la fin de la phase avec une pile d'appels déroulée
    process.nextTick (() => this.emit ('événement'));
  }
}

émetteur const = new ImpatientEmitter ();
emitter.on ('event', () => console.log ('Un événement impatient s'est produit!'));

Permettre à la pile d'appels de se dérouler peut éviter des erreurs telles que RangeError: La taille maximale de la pile d'appels est dépassée . Un problème est de s’assurer que process.nextTick () ne bloque pas la boucle d’événements. Le blocage peut être problématique avec les appels de rappel récursifs dans la même phase.

Conclusion

La boucle d'événements est la simplicité dans sa sophistication ultime. Cela prend un problème difficile comme l'asynchronie, la sécurité des threads et la concurrence. Il arrache ce qui n’aide pas ou ce dont il n’a pas besoin et maximise le débit de la manière la plus efficace possible. Pour cette raison, les programmeurs Node passent moins de temps à rechercher les bogues asynchrones et plus de temps à fournir de nouvelles fonctionnalités.




Source link