Catégories
Astuces et Design

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 cela limite-t-il Node à 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énement?

La boucle d'événements est une boucle à thread unique, non bloquante et concurrente de manière 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 se termine d'une seule demande, 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 la boucle. Considérez la pile d'appels comme un code synchrone qui se déroule, comme console.log, avant la boucle interroge pour 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 fils 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 active. Si tel est le cas, il détermine combien de temps attendre dans la file d'attente de rappel. A chaque itération, la boucle déroule la pile d'appels, puis interroge.

Voici un exemple qui bloque la boucle principale:

setTimeout(
  () => console.log('Hi from the callback queue'),
  5000); // Keep the loop alive for this long

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

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 ne reste plus de rappels.

La file d'attente de rappel

Maintenant, que se passe-t-il lorsque je bloque la boucle principale puis que je programme un rappel? Une fois que la boucle est bloquée, elle ne place 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 quand il est débloqué. 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 à un seul thread, simultanée et non bloquante.

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

Pour éviter de bloquer la boucle principale, une idée est d'enrouler les E / S synchrones autour de async / await:

const fs = require('fs');
const readFileSync = async (path) => await fs.readFileSync(path);

readFileSync('readme.md').then((data) => console.log(data));
console.log('The event loop continues without blocking...');

Tout ce qui vient après le await 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 fait readFileSync puisable, ce qui le retire de la boucle principale. Pense à 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 rappel 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.

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 fils sous les couvertures?

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

Phases de la boucle d'événement

Voici les phases de la boucle d'événements:

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 vivante? 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 minuteries sont exécutées. C'est ici que setTimeout ou setInterval les rappels s'exécutent. La boucle vérifie le cache maintenant pour avoir des rappels actifs qui ont expiré s'exécuter.

  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 poignées pour setImmediate exécution de rappel 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.

  7. Calculez le délai d'expiration du sondage. La boucle doit savoir combien de temps elle bloque pour les E / S. Voici comment il calcule le délai:

    • Si la boucle est sur le point de se terminer, le délai d'attente est de 0.
    • S'il n'y a pas de descripteurs ou de demandes actifs, le délai d'expiration est de 0.
    • S'il existe des poignées inactives, le délai d'expiration est de 0.
    • S'il y a des descripteurs en attente dans la file d'attente, le délai d'expiration est de 0.
    • S'il existe des poignées de fermeture, le délai d'expiration est de 0.
    • Si rien de ce qui précède, le délai d'expiration est défini sur la minuterie la plus proche, ou s'il n'y a pas de minuterie active, infini.
  8. La boucle bloque 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.

  9. Vérifier l'exécution des rappels de handle. Cette phase est où setImmediate s'exécute, et c'est l'équivalent de la préparation des poignées. Tout setImmediate les rappels mis en file d'attente au milieu de l'exécution des rappels d'E / S s'exécutent ici.

  10. Les rappels de fermeture s'exécutent. Ce sont des poignées actives disposées à partir de connexions fermées.

  11. 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 être bloquante? 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. Il s’agit d’une boucle semi-infinie, car l’interrogation maintient la boucle en vie quand il ne reste plus rien à faire et qu’une connexion est active.

Voici la version Unix de ce calcul de délai d’attente est toute sa gloire C:

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

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

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

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

  if (loop->closing_handles)
    return 0;

  return uv__next_timeout(loop);
}

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

Une démonstration étape par étape

Pour afficher chaque phase en JavaScript brut:

// 1. Loop begins, timestamps are updated
const http = require('http');

// 2. The loop remains alive if there's code in the call stack to unwind
// 8. Poll for I/O and execute this callback from incoming connections
const server = http.createServer((req, res) => {
  // Network I/O callback executes immediately after poll
  res.end();
});

// Keep the loop alive if there is an open connection
// 7. If there's nothing left to do, calculate timeout
server.listen(8000);

const options = {
  // Avoid a DNS lookup to stay out of the thread pool
  hostname: '127.0.0.1',
  port: 8000
};

const sendHttpRequest = () => {
  // Network I/O callbacks run in phase 8
  // File I/O callbacks run in phase 4
  const req = http.request(options, () => {
    console.log('Response received from the server');

    // 9. Execute check handle callback
    setImmediate(() =>
      // 10. Close callback executes
       server.close(() =>
        // The End. SPOILER ALERT! The Loop dies at the end.
        console.log('Closing the server')));
  });
  req.end();
};

// 3. Timer runs in 8 secs, meanwhile the loop is staying alive
// The timeout calculated before polling keeps it alive
setTimeout(() => sendHttpRequest(), 8000);

// 11. Iteration ends

Étant donné que les rappels d'E / S de fichier s'exécutent dans la phase quatre et avant la phase neuf, attendez-vous setImmediate() pour tirer en premier:

fs.readFile('readme.md', () => {
  setTimeout(() => console.log('File I/O callback via setTimeout()'), 0);
  // This callback executes first
  setImmediate(() => console.log('File I/O callback 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 fichiers, la recherche DNS et les E / S réseau se font 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énements 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 contre 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 le UV_THREADPOOL_SIZE variable d'environnement. La taille du pool de threads par défaut est d'environ quatre.

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 créent leurs propres threads pour répondre à leurs propres besoins.

S'il y a des problèmes de débit dans Node, commencez par la boucle d'événements 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 V8 qui s'exécute de manière synchrone.

Emballer

La boucle d'événements continue à itérer à travers 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() contre setImmediate()

A la fin de chaque phase, la boucle exécute le process.nextTick() rappeler. 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 setImmediate() le rappel fait partie de la boucle d'événements globale, il n'est donc pas aussi immédiat que son nom l'indique. Car process.nextTick() nécessite 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 process.nextTick():

  1. Autorisez les E / S réseau à gérer les erreurs, le nettoyage ou réessayez la demande avant que la boucle ne se poursuive.

  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 continue.

Supposons, par exemple, qu'un émetteur d'événement souhaite déclencher un événement tout en restant dans son propre constructeur. La pile d'appels doit d'abord se dérouler avant d'appeler l'événement.

const EventEmitter = require('events');

class ImpatientEmitter extends EventEmitter {
  constructor() {
    super();

    // Fire this at the end of the phase with an unwound call stack
    process.nextTick(() => this.emit('event'));
  }
}

const emitter = new ImpatientEmitter();
emitter.on('event', () => console.log('An impatient event occurred!'));

Permettre à la pile d'appels de se dérouler peut éviter des erreurs telles que RangeError: Maximum call stack size exceeded. Un truc est de s'assurer 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.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *