
Node a été créé à l'origine pour créer des serveurs Web hautes performances. En tant que moteur d'exécution côté serveur pour JavaScript, il possède des fonctionnalités telles que les E/S asynchrones basées sur les événements et le thread unique. Le modèle de programmation asynchrone basé sur la boucle d'événements permet à Node de gérer une concurrence élevée et améliore considérablement les performances du serveur. En même temps, car il conserve les caractéristiques monothread de JavaScript, Node n'a pas besoin de gérer des problèmes tels que la synchronisation des états et. impasse sous multi-threads. Il n’y a pas de surcharge de performances causée par le changement de contexte de thread. Sur la base de ces caractéristiques, Node présente les avantages inhérents d'une haute performance et d'une concurrence élevée, et diverses plates-formes d'applications réseau à haut débit et évolutives peuvent être construites sur cette base.
Cet article approfondira le mécanisme sous-jacent d'implémentation et d'exécution de la boucle asynchrone et événementielle de Node. J'espère qu'il vous sera utile.
Pourquoi Node utilise-t-il l'asynchrone comme modèle de programmation principal ?
Comme mentionné précédemment, Node a été créé à l'origine pour créer des serveurs Web hautes performances. En supposant qu'il existe plusieurs ensembles de tâches non liées à accomplir dans le scénario commercial, il existe deux solutions traditionnelles modernes :
l'exécution série monothread.
Complété en parallèle avec plusieurs threads.
L'exécution série monothread est un modèle de programmation synchrone, bien qu'elle soit plus conforme à la façon de penser du programmeur en séquence et facilite l'écriture de code plus pratique, car elle exécute les E/S de manière synchrone, elle ne peut traiter que les E/S. en même temps, une seule requête entraînera une réponse lente du serveur et ne pourra pas être appliquée dans des scénarios d'application à haute concurrence. De plus, comme elle bloque les E/S, le processeur attendra toujours la fin des E/S et ne pourra pas le faire. d'autres choses, ce qui limitera la puissance de traitement du processeur. Pour être pleinement utilisé, cela finira par conduire à une faible efficacité,
et le modèle de programmation multithread donnera également des maux de tête aux développeurs en raison de problèmes tels que la synchronisation des états et le blocage de la programmation. Bien que le multithreading puisse améliorer efficacement l’utilisation du processeur sur les processeurs multicœurs.
Bien que le modèle de programmation d'exécution série monothread et d'exécution parallèle multithread ait ses propres avantages, il présente également des inconvénients en termes de performances et de difficulté de développement.
De plus, à partir de la vitesse de réponse aux requêtes du client, si le client obtient deux ressources en même temps, la vitesse de réponse de la méthode synchrone sera la somme des vitesses de réponse des deux ressources, et la vitesse de réponse du La méthode asynchrone sera au milieu des deux, la plus grande, l'avantage en termes de performances est très évident par rapport à la synchronisation. À mesure que la complexité de l'application augmente, ce scénario évoluera vers la réponse à n requêtes en même temps, et les avantages de l'asynchrone par rapport à la synchronisation seront mis en évidence.
Pour résumer, Node donne sa réponse : utilisez un seul thread pour éviter les blocages multi-thread, la synchronisation d'état et d'autres problèmes ; utilisez les E/S asynchrones pour éviter qu'un seul thread ne se bloque afin de mieux utiliser le processeur. C'est pourquoi Node utilise l'asynchrone comme modèle de programmation de base.
De plus, afin de compenser le défaut d'un thread unique qui ne peut pas utiliser de processeurs multicœurs, Node fournit également un sous-processus similaire aux Web Workers dans le navigateur, qui peut utiliser efficacement le processeur via des processus de travail.
Après avoir expliqué pourquoi nous devrions utiliser l'asynchrone, comment implémenter l'asynchrone ?
Il existe deux types d'opérations asynchrones que nous appelons habituellement : l'une concerne les opérations liées aux E/S telles que les E/S de fichiers et les E/S réseau ; l'autre concerne les opérations non liées aux E/S telles que setTimeOut et setInterval . Évidemment, l'asynchrone dont nous parlons fait référence aux opérations liées aux E/S, c'est-à-dire aux E/S asynchrones.
Les E/S asynchrones sont proposées dans l'espoir que les appels d'E/S ne bloqueront pas l'exécution des programmes suivants, et que le temps d'attente initial pour la fin des E/S sera alloué à d'autres activités requises pour l'exécution. Pour atteindre cet objectif, vous devez utiliser des E/S non bloquantes.
Le blocage des E/S signifie qu'une fois que la CPU a lancé un appel d'E/S, elle se bloquera jusqu'à ce que les E/S soient terminées. Connaissant les E/S bloquantes, les E/S non bloquantes sont faciles à comprendre. Le CPU reviendra immédiatement après le lancement de l'appel d'E/S au lieu de bloquer et d'attendre. Le CPU peut gérer d'autres transactions avant que les E/S ne soient terminées. De toute évidence, par rapport aux E/S bloquantes, les E/S non bloquantes améliorent davantage les performances.
Ainsi, puisque des E/S non bloquantes sont utilisées et que le CPU peut revenir immédiatement après le lancement de l'appel d'E/S, comment sait-il que les E/S sont terminées ? La réponse est un sondage.
Afin d'obtenir l'état des appels d'E/S à temps, la CPU appellera continuellement les opérations d'E/S à plusieurs reprises pour confirmer si l'E/S a été terminée. Cette technologie d'appels répétés pour déterminer si l'opération est terminée est appelée interrogation. .
De toute évidence, l'interrogation amènera le processeur à effectuer des jugements d'état à plusieurs reprises, ce qui constitue un gaspillage de ressources CPU. De plus, l'intervalle d'interrogation est difficile à contrôler si l'intervalle est trop long, l'achèvement de l'opération d'E/S ne recevra pas de réponse dans les délais, ce qui réduit indirectement la vitesse de réponse de l'application si l'intervalle est trop court ; Le processeur sera inévitablement dépensé en interrogation. Cela prend plus de temps et réduit l'utilisation des ressources du processeur.
Par conséquent, bien que l'interrogation réponde à l'exigence selon laquelle les E/S non bloquantes ne bloquent pas l'exécution des programmes suivants, pour l'application, elle ne peut toujours être considérée que comme une sorte de synchronisation, car l'application doit toujours attendre les E/S. O pour revenir complètement. J'ai encore passé beaucoup de temps à attendre.
L'E/S asynchrone parfaite que nous attendons devrait être que l'application lance un appel non bloquant. Il n'est pas nécessaire d'interroger en permanence l'état de l'appel d'E/S via une interrogation. Au lieu de cela, la tâche suivante peut être traitée directement après. Les E/S sont terminées, transmettez simplement les données à l'application via un sémaphore ou un rappel.
Comment implémenter ces E/S asynchrones ? La réponse est le pool de threads.
Bien que cet article ait toujours mentionné que Node est exécuté dans un seul thread, le thread unique signifie ici que le code JavaScript est exécuté sur un seul thread. Pour des parties telles que les opérations d'E/S qui n'ont rien à voir avec la logique métier principale, en s'exécutant dans une autre implémentation sous forme de threads, cela n'affectera ni ne bloquera l'exécution du thread principal. Au contraire, cela peut améliorer l'efficacité d'exécution du thread principal et réaliser des E/S asynchrones.
Grâce au pool de threads, laissez le thread principal effectuer uniquement des appels d'E/S, laissez les autres threads effectuer des E/S bloquantes ou des E/S non bloquantes ainsi que la technologie d'interrogation pour terminer l'acquisition de données, puis utilisez la communication entre les threads pour terminer l'I/O. /O Les données obtenues sont transmises, ce qui implémente facilement les E/S asynchrones :

Le thread principal effectue des appels d'E/S, tandis que le pool de threads effectue des opérations d'E/S, termine l'acquisition des données, puis transmet les données au thread principal via la communication entre les threads pour terminer un appel d'E/S et le thread principal. réutilise La fonction de rappel expose les données à l'utilisateur, qui les utilise ensuite pour effectuer des opérations au niveau de la logique métier. Il s'agit d'un processus d'E/S asynchrone complet dans Node. Pour les utilisateurs, il n'y a pas besoin de s'inquiéter des détails fastidieux d'implémentation de la couche sous-jacente. Il leur suffit d'appeler l'API asynchrone encapsulée par Node et de transmettre la fonction de rappel qui gère la logique métier, comme indiqué ci-dessous :
const fs = require. ("fs") ;
fs.readFile('exemple.js', (données) => {
// Logique métier du processus}); Le mécanisme d'implémentation asynchrone sous-jacent de Nodejs est différent selon les plates-formes : sous Windows, IOCP est principalement utilisé pour envoyer des appels d'E/S au noyau système et obtenir les opérations d'E/S terminées à partir du noyau équipé. avec une boucle d'événements pour terminer le processus d'E/S asynchrone ; ce processus est implémenté via epoll sous Linux ; via kqueue sous FreeBSD et via les ports d'événements sous Solaris ; Le pool de threads est directement fourni par le noyau (IOCP) sous Windows, tandis que la série *nix est implémentée par libuv elle-même.
En raison de la différence entre la plate-forme Windows et la plate-forme *nix , Node fournit libuv comme couche d'encapsulation abstraite, de sorte que tous les jugements de compatibilité de plate-forme soient complétés par cette couche, garantissant que le nœud de couche supérieure et le pool de threads personnalisés de couche inférieure et IOCP sont indépendants les uns des autres. Node déterminera les conditions de la plate-forme lors de la compilation et compilera sélectivement les fichiers sources dans le répertoire unix ou le répertoire win dans le programme cible :

Ce qui précède est l’implémentation asynchrone de Node.
(La taille du pool de threads peut être définie via la variable d'environnement UV_THREADPOOL_SIZE . La valeur par défaut est 4. L'utilisateur peut ajuster la taille de cette valeur en fonction de la situation réelle.)
Ensuite, la question est, après avoir obtenu les données transmises par le pool de threads, comment fonctionne le thread principal ? Quand la fonction de rappel est-elle appelée ? La réponse est la boucle d'événements.
Étant donné queutilise des fonctions de rappel pour traiter les données d'E/S, cela implique inévitablement la question de savoir quand et comment appeler la fonction de rappel. Dans le développement réel, des scénarios d'appels d'E/S asynchrones multiples et multi-types sont souvent impliqués. Comment organiser raisonnablement les appels de ces rappels d'E/S asynchrones et garantir le déroulement ordonné des rappels asynchrones est un problème difficile. E/S asynchrones En plus de /O, il existe également des appels asynchrones non-E/S tels que des minuteries. Ces API sont hautement en temps réel et ont des priorités en conséquence plus élevées. Comment planifier des rappels avec des priorités différentes ?
Par conséquent, il doit exister un mécanisme de planification pour coordonner les tâches asynchrones de différentes priorités et types afin de garantir que ces tâches s'exécutent de manière ordonnée sur le thread principal. Comme les navigateurs, Node a choisi la boucle d'événements pour faire ce gros travail.
Node divise les tâches en sept catégories en fonction de leur type et de leur priorité : minuteries, en attente, inactive, préparation, sondage, vérification et fermeture. Pour chaque type de tâche, il existe une file d'attente de tâches premier entré, premier sorti pour stocker les tâches et leurs rappels (les minuteries sont stockées dans un petit tas supérieur). Sur la base de ces sept types, Node divise l'exécution de la boucle d'événements en sept étapes suivantes :
La priorité d'exécution de cette étape de
À ce stade, la boucle d'événements vérifiera la structure de données (tas minimum) qui stocke le minuteur, parcourra les minuteurs, comparera l'heure actuelle et l'heure d'expiration un par un et déterminera si le minuteur a expiré. , la minuterie sera La fonction de rappel est retirée et exécutée.
La phaseexécutera des rappels lorsque des exceptions réseau, IO et autres se produisent. Certaines erreurs signalées par *nix seront traitées à ce stade. De plus, certains rappels d'E/S qui devaient être exécutés lors de la phase d'interrogation du cycle précédent seront reportés à cette phase.
ne sont utilisées qu'à l'intérieur de la boucle d'événements.
récupère les nouveaux événements d'E/S ; exécute les rappels liés aux E/S (presque tous les rappels à l'exception des rappels d'arrêt, des rappels programmés par minuterie et setImmediate() ) ;
Poll, c'est-à-dire que l'étape d'interrogation est l'étape la plus importante de la boucle d'événements. Les rappels pour les E/S réseau et les E/S fichiers sont principalement traités à cette étape. Cette étape a deux fonctions principales :
calculer la durée pendant laquelle cette étape doit bloquer et interroger les E/S.
Gérez les rappels dans la file d’attente d’E/S.
Lorsque la boucle d'événements entre dans la phase d'interrogation et qu'aucun minuteur n'est défini :
si la file d'attente d'interrogation n'est pas vide, la boucle d'événements parcourra la file d'attente, les exécutant de manière synchrone jusqu'à ce que la file d'attente soit vide ou que le nombre maximum pouvant être exécuté soit atteint.
Si la file d'attente d'interrogation est vide, l'une des deux autres choses qui se produisent :
S'il y a setImmediate() qui doit être exécuté, la phase d'interrogation se termine immédiatement et la phase de vérification est entrée pour exécuter le rappel.
S'il n'y a pas de rappels setImmediate() à exécuter, la boucle d'événements restera dans cette phase en attendant que les rappels soient ajoutés à la file d'attente, puis les exécutera immédiatement. La boucle d'événements attendra l'expiration du délai d'attente. La raison pour laquelle j'ai choisi de m'arrêter ici est que Node gère principalement les E/S, afin de pouvoir répondre aux E/S plus rapidement.
Une fois la file d'attente d'interrogation vide, la boucle d'événements recherche les minuteurs qui ont atteint leur seuil de temps. Si un ou plusieurs temporisateurs atteignent le seuil de temps, la boucle d'événements reviendra à la phase des temporisateurs pour exécuter les rappels pour ces temporisateurs.
phase exécutera les rappels de setImmediate() dans l'ordre.
Cette phase exécutera certains rappels pour fermer les ressources, tels que socket.on('close', ...) . L'exécution retardée de cette étape aura peu d'impact et aura la priorité la plus faible.
Lorsque le processus Node démarre, il initialisera la boucle d'événements, exécutera le code d'entrée de l'utilisateur, effectuera les appels d'API asynchrones correspondants, la planification de la minuterie, etc., puis commencera à entrer dans la boucle d'événements :
┌───────── ── ────────────────┐ ┌─>│ minuteries │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ rappels en attente │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ inactif, préparez-vous │ │ └─────────────┬────────────┘ ┌────────── ──────┐ │ ┌─────────────┴────────────┐ │ entrant : │ │ │ sondage │<─────┤ connexions, │ │ └─────────────┬─────────────┘ │ données, etc. │ │ ┌─────────────┴────────────┐ └────────── ──────┘ │ │ vérifier │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ fermer les rappels │ └─────────────────────────────┘Chaque
itération de la boucle d'événement (souvent appelée tick) sera comme indiqué ci-dessus. La priorité L'ordre entre dans les sept étapes d'exécution.Chaque étape exécutera un certain nombre de rappels dans la file d'attente.La raison pour laquelle seul un certain nombre est exécuté mais pas tous est exécuté est d'éviter que le temps d'exécution de l'étape en cours ne soit trop long et. éviter l'échec de l'étape suivante. Non exécuté.
OK, ce qui précède est le flux d'exécution de base de la boucle d'événements. Examinons maintenant une autre question.
Pour le scénario suivant :
const server = net.createServer(() => {}).listen(8080);
server.on('listening', () => {}); Lorsque le service est lié avec succès au port 8000, c'est-à-dire lorsque listen() est appelé avec succès, le rappel de l'événement listening n'a pas encore été lié, donc une fois le port lié avec succès, le rappel de l'événement listening que nous avons transmis ne sera pas exécuté.
En pensant à une autre question, nous pouvons avoir certains besoins pendant le développement, tels que la gestion des erreurs, le nettoyage des ressources inutiles et d'autres tâches de faible priorité. Si ces logiques sont exécutées de manière synchrone, cela affectera l'efficacité de l'exécution. si setImmediate() est transmis de manière asynchrone, par exemple sous la forme de rappels, leur timing d'exécution ne peut pas être garanti et les performances en temps réel ne sont pas élevées. Alors comment gérer ces logiques ?
Sur la base de ces problèmes, Node s'est référé au navigateur et a implémenté un ensemble de mécanismes de micro-tâches. Dans Node, en plus d'appeler new Promise().then() la fonction de rappel transmise sera encapsulée dans une microtâche. Le rappel de process.nextTick() sera également encapsulé dans une microtâche, ainsi que la priorité d'exécution de la. ce dernier sera plus élevé que le premier.
Avec les microtâches, quel est le processus d’exécution de la boucle événementielle ? En d’autres termes, quand les microtâches sont-elles exécutées ?
Dans le nœud 11 et les versions ultérieures, une fois qu'une tâche d'une étape est exécutée, la file d'attente des microtâches est immédiatement exécutée et la file d'attente est effacée.
L'exécution de la microtâche commence après l'exécution d'une étape avant le nœud 11.
Par conséquent, avec les microtâches, chaque cycle de la boucle d'événements exécutera d'abord une tâche dans l'étape des minuteries, puis effacera les files d'attente de microtâches de process.nextTick() et new Promise().then() dans l'ordre, puis continuera à exécuter la tâche suivante dans l'étape des minuteurs ou l'étape suivante, c'est-à-dire une tâche dans l'étape en attente, et ainsi de suite dans cet ordre.
En utilisant process.nextTick() , Node peut résoudre le problème de liaison de port ci-dessus : dans la méthode listen() , l'émission de l'événement listening sera encapsulée dans un rappel et transmise à process.nextTick() , comme indiqué dans le pseudo suivant code :
fonction écouter() {
// Effectuer les opérations de port d'écoute...
// Encapsuler l'émission de l'événement `listening` dans un rappel et le transmettre à `process.nextTick()` dans process.nextTick(() => {
émettre('écoute');
});
}; Une fois le code actuel exécuté, la microtâche commencera à être exécutée, émettant ainsi listening et déclenchant l'appel du rappel d'événement.
en raison de l'imprévisibilité et de la complexité de l'asynchrone lui-même, lors de l'utilisation de l'API asynchrone fournie par Node, même si nous maîtrisons le principe d'exécution de la boucle d'événements, il peut encore y avoir certains phénomènes qui ne sont pas intuitifs ou attendus. .
Par exemple, l'ordre d'exécution des timers ( setTimeout , setImmediate ) différera selon le contexte dans lequel ils sont appelés. Si les deux sont appelés depuis le contexte de niveau supérieur, leur temps d'exécution dépend des performances du processus ou de la machine.
Regardons l'exemple suivant :
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immédiat');
}); Quel est le résultat de l’exécution du code ci-dessus ? D'après notre description de la boucle d'événements à l'instant, vous pourriez avoir cette réponse : Puisque la phase des timers sera exécutée avant la phase de vérification, le rappel de setTimeout() sera exécuté en premier, puis le rappel de setImmediate() sera exécuté.
En fait, le résultat de sortie de ce code est incertain. Timeout peut être généré en premier, ou immédiat peut être généré en premier. En effet, les deux temporisateurs sont appelés dans le contexte global lorsque la boucle d'événements démarre et s'exécute jusqu'à l'étape des temporisateurs, la durée actuelle peut être supérieure à 1 ms ou inférieure à 1 ms, en fonction des performances d'exécution de la machine. , il est en fait incertain setTimeout() sera exécuté lors de la première étape des minuteries, donc différents résultats de sortie apparaîtront.
(Lorsque la valeur de delay (le deuxième paramètre de setTimeout ) est supérieure à 2147483647 ou inférieure à 1 , delay sera défini sur 1 )
Regardons le code suivant :
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immédiat');
});
}); On peut voir que dans ce code, les deux minuteries sont encapsulées dans des fonctions de rappel et passées dans readFile . Il est évident que lorsque le rappel est appelé, l'heure actuelle doit être supérieure à 1 ms, donc le rappel de setTimeout le fera. être plus long que le rappel de setImmediate Le rappel est appelé en premier, donc le résultat imprimé est : timeout immediate .
Les éléments ci-dessus concernent les minuteries auxquelles vous devez prêter attention lorsque vous utilisez Node. De plus, vous devez également faire attention à l'ordre d'exécution de process.nextTick() , new Promise().then() et setImmediate() . Étant donné que cette partie est relativement simple, elle a déjà été mentionnée et ne sera pas répétée. .
: L'article commence par une explication plus détaillée des principes de mise en œuvre de la boucle d'événements Node du point de vue de la raison pour laquelle l'asynchrone est nécessaire et de la manière de l'implémenter, et mentionne certaines questions connexes qui nécessitent une attention particulière. toi.