Une boucle d'événements est le mécanisme de Node.js permettant de gérer les opérations d'E/S non bloquantes - même si JavaScript est monothread - en déchargeant les opérations sur le noyau du système lorsque cela est possible.
Étant donné que la plupart des cœurs sont aujourd'hui multithreads, ils peuvent gérer diverses opérations en arrière-plan. Lorsqu'une des opérations est terminée, le noyau demande à Node.js d'ajouter la fonction de rappel appropriée à la file d'attente d'interrogation et d'attendre l'opportunité de s'exécuter. Nous le présenterons en détail plus loin dans cet article.
Lorsque Node.js est démarré, il initialisera la boucle d'événements et traitera le script d'entrée fourni (ou le lancera dans le REPL, ce qui n'est pas couvert dans cet article). Il peut appeler certaines API asynchrones, planifier des minuteries, etc. ou appelez process.nextTick()
puis commencez à traiter la boucle d'événements.
Le diagramme ci-dessous montre un aperçu simplifié de la séquence d'opérations de la boucle d'événements.
┌───────────────────────────┐ ┌─>│ minuteries │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ rappels en attente │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ inactif, préparez-vous │ │ └─────────────┬────────────┘ ┌────────── ──────┐ │ ┌─────────────┴────────────┐ │ entrant : │ │ │ sondage │<─────┤ connexions, │ │ └─────────────┬─────────────┘ │ données, etc. │ │ ┌─────────────┴────────────┐ └────────── ──────┘ │ │ vérifier │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ fermer les rappels │ └─────────────────────────────┘
Remarque : Chaque case est appelée une étape du mécanisme de boucle d'événements.
Chaque étape dispose d'une file d'attente FIFO pour exécuter les rappels. Bien que chaque étape soit spéciale, généralement lorsque la boucle d'événements entre dans une étape donnée, elle effectuera toutes les opérations spécifiques à cette étape, puis exécutera les rappels dans la file d'attente de cette étape jusqu'à ce que la file d'attente soit épuisée ou que le nombre maximum de rappels ait été exécuté. Lorsque la file d'attente est épuisée ou que la limite de rappel est atteinte, la boucle d'événements passe à la phase suivante, et ainsi de suite.
Puisque n'importe laquelle de ces opérations peut planifier davantage d'opérations et de nouveaux événements mis en file d'attente par le noyau pour être traités pendant la phase d'interrogation , les événements d'interrogation peuvent être mis en file d'attente pendant le traitement des événements dans la phase d'interrogation. Par conséquent, un rappel de longue durée peut permettre à la phase d’interrogation de s’exécuter plus longtemps que la durée seuil du temporisateur. Consultez la section Minuteries et interrogations pour plus d'informations.
Remarque : Il existe des différences subtiles entre les implémentations Windows et Unix/Linux, mais cela n'est pas important pour les besoins de la démonstration. La partie la plus importante est ici. Il y a en fait sept ou huit étapes, mais ce qui nous intéresse, c'est que Node.js utilise réellement certaines des étapes ci-dessus.
MinuteriesetTimeout()
et setInterval()
.setImmediate()
), dans les autres cas, le nœud bloquera ici le cas échéant.setImmediate()
est exécutée ici.socket.on('close', ...)
.Entre chaque exécution de la boucle d'événements, Node.js vérifie s'il attend des E/S asynchrones ou des minuteurs, et sinon, s'arrête complètement.
Les minuteurs spécifient le seuil auquel le rappel fourni peut être exécuté, plutôt que l'heure exacte à laquelle l'utilisateur souhaite qu'il s'exécute. Après l'intervalle spécifié, le rappel du minuteur sera exécuté le plus tôt possible. Cependant, ils peuvent être retardés par la planification du système d'exploitation ou par d'autres rappels en cours d'exécution.
Remarque : La phase d'interrogation contrôle le moment où le minuteur s'exécute.
Par exemple, supposons que vous planifiiez un minuteur qui expire après 100 millisecondes, puis que votre script commence à lire de manière asynchrone un fichier qui prend 95 millisecondes :
const fs = require('fs'); fonction someAsyncOperation (rappel) { // Supposons que cela prenne 95 ms fs.readFile('/chemin/vers/fichier', rappel); } const timeoutScheduled = Date.now(); setTimeout(() => { const delay = Date.now() - timeoutScheduled ; console.log(`${delay}ms se sont écoulés depuis que j'étais programmé`); }, 100); // fait someAsyncOperation qui prend 95 ms pour se terminer someAsyncOperation(() => { const startCallback = Date.now(); // fait quelque chose qui prendra 10 ms... while (Date.now() - startCallback < 10) { // ne fait rien } });
Lorsque la boucle d'événements entre dans la phase d'interrogation , elle a une file d'attente vide ( fs.readFile()
n'est pas encore terminée), elle attendra donc le nombre de millisecondes restant jusqu'à ce que le seuil de minuterie le plus rapide soit atteint. Lorsqu'il attend 95 millisecondes pour que fs.readFile()
termine la lecture du fichier, son rappel, qui prend 10 millisecondes, sera ajouté à la file d'attente d'interrogation et exécuté. Une fois le rappel terminé, il n'y a plus de rappels dans la file d'attente, donc le mécanisme de boucle d'événements examinera le minuteur qui a atteint le seuil le plus rapidement et reviendra ensuite à la phase du minuteur pour exécuter le rappel du minuteur. Dans cet exemple, vous verrez que le délai total entre la planification du minuteur et l'exécution de son rappel sera de 105 millisecondes.
REMARQUE : Pour éviter que la phase d'interrogation n'affame la boucle d'événements, libuv (la bibliothèque C qui implémente la boucle d'événements Node.js et tout le comportement asynchrone de la plate-forme) a également un maximum strict (en fonction du système).
Cette phase exécute des rappels pour certaines opérations système (telles que les types d'erreur TCP). Par exemple, certains systèmes * nix souhaitent attendre pour signaler une erreur si un socket TCP reçoit ECONNREFUSED
lors de la tentative de connexion. Celui-ci sera mis en file d'attente pour exécution pendant la phase de rappel en attente .
La phase d'interrogation a deux fonctions importantes :
calculer la durée pendant laquelle les E/S doivent être bloquées et interrogées.
Ensuite, gérez les événements dans la file d’attente d’interrogation .
Lorsque la boucle d'événements entre dans la phase d'interrogation et qu'aucun minuteur n'est programmé, l'une des deux choses suivantes se produit :
Si la file d'attente d'interrogation n'est pas vide
, la boucle d'événements parcourt la file d'attente de rappel et les exécute de manière synchrone jusqu'à ce que la file d'attente soit vide. , ou une limite stricte liée au système atteinte.
Si la file d'attente d'interrogation est vide , deux autres choses se produisent :
si le script est planifié par setImmediate()
, la boucle d'événements mettra fin à la phase d'interrogation et poursuivra la phase de vérification pour exécuter ces scripts planifiés.
Si le script n'est pas planifié par setImmediate()
, la boucle d'événements attendra que le rappel soit ajouté à la file d'attente, puis l'exécutera immédiatement.
Une fois la file d'attente d'interrogation vide, la boucle d'événements recherche un minuteur qui a atteint son seuil de temps. Si un ou plusieurs temporisateurs sont prêts, la boucle d'événements revient à la phase du temporisateur pour exécuter les rappels pour ces temporisateurs.
Cette phase permet d'exécuter un rappel immédiatement après la fin de la phase d'interrogation. Si la phase d'interrogation devient inactive et que le script est mis en file d'attente après avoir utilisé setImmediate()
, la boucle d'événements peut continuer vers la phase de vérification au lieu d'attendre.
setImmediate()
est en fait un minuteur spécial qui s'exécute dans une phase distincte de la boucle d'événements. Il utilise une API libuv pour planifier l'exécution des rappels une fois la phase d'interrogation terminée.
Généralement, lors de l'exécution du code, la boucle d'événements finit par atteindre la phase d'interrogation, où elle attend les connexions entrantes, les requêtes, etc. Cependant, si le rappel a été planifié à l'aide de setImmediate()
et que la phase d'interrogation devient inactive, elle mettra fin à cette phase et passera à la phase de vérification au lieu de continuer à attendre l'événement d'interrogation.
Si le socket ou le gestionnaire est fermé soudainement (par exemple socket.destroy()
), l'événement 'close'
sera émis à ce stade. Sinon, il sera émis via process.nextTick()
.
setImmediate()
et setTimeout()
sont très similaires, mais ils se comportent différemment en fonction du moment où ils sont appelés.
setImmediate()
est conçu pour exécuter le script une fois la phase d'interrogation en cours terminée.setTimeout()
exécute le script après qu'un seuil minimum (en ms) soit dépassé.L'ordre dans lequel les timers sont exécutés varie en fonction du contexte dans lequel ils sont appelés. Si les deux sont appelés depuis le module principal, le minuteur sera limité par les performances du processus (qui peuvent être affectées par d'autres applications en cours d'exécution sur l'ordinateur).
Par exemple, si vous exécutez le script suivant qui ne se trouve pas dans un cycle d'E/S (c'est-à-dire le module principal), l'ordre dans lequel les deux timers sont exécutés n'est pas déterministe car il est limité par les performances du processus :
// timeout_vs_immediate.js setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immédiat'); }); $ nœud timeout_vs_immediate.js temps mort immédiat $ nœud timeout_vs_immediate.js immédiat timeout
Cependant, si vous placez ces deux fonctions dans une boucle d'E/S et que vous les appelez, setImmediate sera toujours appelé en premier :
// timeout_vs_immediate.js const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immédiat'); }); }); $ nœud timeout_vs_immediate.js immédiat temps mort $ nœud timeout_vs_immediate.js immédiatLe principal avantage de l'utilisation de setImmediate() pour
le timeout
setImmediate()
setTimeout()
est que si setImmediate()
est planifié pendant le cycle d'E/S, il sera exécuté avant tout minuteur, en fonction du nombre de minuteurs sans rapport avec
Vous avez peut-être remarqué process.nextTick()
n'est pas affiché dans le diagramme, même s'il fait partie de l'API asynchrone. En effet, process.nextTick()
ne fait pas techniquement partie de la boucle d'événements. Au lieu de cela, il gérera nextTickQueue
une fois l'opération en cours terminée, quelle que soit l'étape actuelle de la boucle d'événements. Une opération ici est considérée comme une transition depuis le processeur C/C++ sous-jacent et gère le code JavaScript qui doit être exécuté.
En regardant notre diagramme, chaque fois que process.nextTick()
est appelé dans une phase donnée, tous les rappels transmis à process.nextTick()
seront résolus avant que la boucle d'événements ne continue. Cela peut créer de mauvaises situations, car cela vous permet de "affamer" vos E/S via des appels récursifs process.nextTick()
, empêchant la boucle d'événements d'atteindre l'étape d'interrogation .
Pourquoi quelque chose comme ça est-il inclus dans Node.js ? Cela s'explique en partie par une philosophie de conception selon laquelle une API doit toujours être asynchrone, même si ce n'est pas obligatoire. Prenons cet extrait de code comme exemple :
function apiCall(arg, callback) { if (type d'argument !== 'string') retourner le processus.nextTick( rappel, new TypeError('l'argument doit être une chaîne') ); }
Extrait de code pour la vérification des paramètres. Si elle est incorrecte, l'erreur est transmise à la fonction de rappel. L'API a été récemment mise à jour pour permettre de transmettre des arguments à process.nextTick()
ce qui lui permettra d'accepter n'importe quel argument après la position de la fonction de rappel et de transmettre les arguments à la fonction de rappel en tant qu'arguments à la fonction de rappel afin que vous n'ayez pas pour imbriquer la fonction.
Ce que nous faisons, c'est renvoyer l'erreur à l'utilisateur, mais seulement après que le reste du code de l'utilisateur a été exécuté. En utilisant process.nextTick()
, nous garantissons que apiCall()
exécute toujours sa fonction de rappel après le reste du code utilisateur et avant de laisser la boucle d'événement se poursuivre. Pour y parvenir, la pile d'appels JS est autorisée à se dérouler, puis à exécuter immédiatement le rappel fourni, permettant ainsi d'effectuer des appels récursifs à process.nextTick()
sans atteindre RangeError: 超过V8 的最大调用堆栈大小
.
Ce principe de conception peut entraîner certains problèmes potentiels. Prenons cet extrait de code comme exemple :
let bar ; // ceci a une signature asynchrone, mais appelle le rappel de manière synchrone fonction someAsyncApiCall (rappel) { rappel(); } // le rappel est appelé avant la fin de `someAsyncApiCall`. someAsyncApiCall(() => { // depuis que someAsyncApiCall est terminé, la barre n'a reçu aucune valeur console.log('bar', bar); // non défini }); bar = 1;
L'utilisateur définit someAsyncApiCall()
comme ayant une signature asynchrone, mais en fait il s'exécute de manière synchrone. Lorsqu'il est appelé, le rappel fourni à someAsyncApiCall()
est appelé dans la même phase de la boucle d'événements car someAsyncApiCall()
ne fait rien de manière asynchrone. Par conséquent, la fonction de rappel tente de référencer bar
, mais la variable n'est peut-être pas encore dans la portée car le script n'a pas encore terminé son exécution.
En plaçant le rappel dans process.nextTick()
, le script a toujours la capacité de s'exécuter jusqu'à la fin, permettant à toutes les variables, fonctions, etc. d'être initialisées avant que le rappel ne soit appelé. Il présente également l'avantage de ne pas laisser la boucle d'événements se poursuivre et permet d'avertir l'utilisateur lorsqu'une erreur se produit avant de laisser la boucle d'événements se poursuivre. Voici l'exemple précédent utilisant process.nextTick()
:
let bar; fonction someAsyncApiCall (rappel) { process.nextTick(rappel); } someAsyncApiCall(() => { console.log('bar', bar); // 1 }); bar = 1;
Ceci est un autre exemple réel :
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
Ce n'est que lorsque le port est transmis que le port sera immédiatement lié. Par conséquent, le rappel 'listening'
peut être appelé immédiatement. Le problème est que le rappel de .on('listening')
n'a pas été défini à ce moment-là.
Pour contourner ce problème, l'événement 'listening'
est mis en file d'attente dans nextTick()
pour permettre au script de s'exécuter jusqu'à son terme. Cela permet à l'utilisateur de définir les gestionnaires d'événements de son choix.
En ce qui concerne l'utilisateur, nous avons deux appels similaires, mais leurs noms prêtent à confusion.
process.nextTick()
est exécuté immédiatement à la même étape.setImmediate()
se déclenche à la prochaine itération ou « tick » de la boucle d'événements.Essentiellement, les deux noms doivent être échangés car process.nextTick()
se déclenche plus rapidement que setImmediate()
, mais il s'agit d'un héritage du passé et il est donc peu probable qu'il change. Si vous effectuez un échange de nom de manière imprudente, vous casserez la plupart des packages sur npm. De nouveaux modules sont ajoutés chaque jour, ce qui signifie que chaque jour nous devons attendre, plus les dommages potentiels peuvent survenir. Même si ces noms prêtent à confusion, les noms eux-mêmes ne changeront pas.
Nous recommandons aux développeurs d'utiliser setImmediate()
dans toutes les situations car c'est plus facile à comprendre.
Il y a deux raisons principales :
permettre à l'utilisateur de gérer les erreurs, nettoyer toutes les ressources inutiles ou réessayer la requête avant que la boucle d'événements ne continue.
Parfois, il est nécessaire d'exécuter le rappel après le déroulement de la pile, mais avant que la boucle d'événements ne continue.
Voici un exemple simple qui répond aux attentes des utilisateurs :
const server = net.createServer(); server.on('connexion', (conn) => {}); serveur.écouter (8080); server.on('listening', () => {});
Supposons que listen()
s'exécute au début de la boucle d'événement, mais que le rappel d'écoute est placé dans setImmediate()
. À moins qu'un nom d'hôte ne soit transmis, le port sera immédiatement lié. Pour que la boucle d'événements continue, elle doit atteindre la phase d'interrogation , ce qui signifie qu'il est possible qu'une connexion ait été reçue et que l'événement de connexion ait été déclenché avant l'événement d'écoute.
Un autre exemple exécute un constructeur de fonction qui hérite de EventEmitter
et souhaite appeler le constructeur :
const EventEmitter = require('events'); const util = require('util'); fonction MonÉmetteur() { EventEmitter.call(this); this.emit('événement'); } util.inherits(MyEmitter, EventEmitter); const monEmitter = new MyEmitter(); monEmitter.on('événement', () => { console.log('un événement s'est produit !'); });
Vous ne pouvez pas déclencher l'événement immédiatement à partir du constructeur car le script n'a pas encore été traité au point où l'utilisateur attribue une fonction de rappel à l'événement. Ainsi, dans le constructeur lui-même, vous pouvez utiliser process.nextTick()
pour configurer un rappel afin que l'événement soit émis une fois le constructeur terminé, ce qui est attendu :
const EventEmitter = require('events'); const util = require('util'); fonction MonÉmetteur() { EventEmitter.call(this); // utilise nextTick pour émettre l'événement une fois qu'un gestionnaire est assigné processus.nextTick(() => { this.emit('événement'); }); } util.inherits(MyEmitter, EventEmitter); const monEmitter = new MyEmitter(); monEmitter.on('événement', () => { console.log('un événement s'est produit !'); });
Source : https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/