La boucle d'événements Node.js
Qu'est-ce que la boucle d'événements ?
La boucle d'événements permet à Node.js d'effectuer des opérations d'E/S non bloquantes — malgré le fait qu'un seul thread JavaScript soit utilisé par défaut — en déléguant les opérations au noyau du système chaque fois que possible.
La plupart des noyaux modernes étant multithreadés, ils peuvent gérer plusieurs opérations s'exécutant en arrière-plan. Lorsqu'une de ces opérations est terminée, le noyau le signale à Node.js afin que la fonction de rappel appropriée puisse être ajoutée à la file d'attente de sondage pour être finalement exécutée. Nous expliquerons cela plus en détail plus loin dans ce sujet.
Explication de la boucle d'événements
Lorsque Node.js démarre, il initialise la boucle d'événements, traite le script d'entrée fourni (ou passe en mode REPL, qui n'est pas couvert dans ce document) qui peut effectuer des appels d'API asynchrones, planifier des temporisateurs ou appeler process.nextTick(), puis commence à traiter la boucle d'événements.
Le diagramme suivant présente un aperçu simplifié de l'ordre des opérations de la boucle d'événements.
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
TIP
Chaque bloc sera désigné comme une "phase" de la boucle d'événements.
Chaque phase possède une file d'attente FIFO de fonctions de rappel à exécuter. Bien que chaque phase soit spéciale à sa manière, généralement, lorsque la boucle d'événements entre dans une phase donnée, elle effectuera toutes les opérations spécifiques à cette phase, puis exécutera les fonctions de rappel dans la file d'attente de cette phase jusqu'à ce que la file d'attente soit épuisée ou que le nombre maximal de fonctions de rappel ait été exécuté. Lorsque la file d'attente est épuisée ou que la limite de fonctions de rappel est atteinte, la boucle d'événements passe à la phase suivante, et ainsi de suite.
Comme n'importe laquelle de ces opérations peut planifier plus d'opérations et que les nouveaux événements traités dans la phase poll sont mis en file d'attente par le noyau, les événements de sondage peuvent être mis en file d'attente pendant le traitement des événements de sondage. Par conséquent, les fonctions de rappel de longue durée peuvent permettre à la phase de sondage de s'exécuter beaucoup plus longtemps que le seuil d'un temporisateur. Consultez les sections temporisateurs et sondage pour plus de détails.
TIP
Il existe une légère différence entre l'implémentation Windows et l'implémentation Unix/Linux, mais cela n'est pas important pour cette démonstration. Les parties les plus importantes sont ici. Il y a en fait sept ou huit étapes, mais celles qui nous intéressent — celles que Node.js utilise réellement — sont celles ci-dessus.
Aperçu des phases
- timers: cette phase exécute les callbacks planifiés par
setTimeout()
etsetInterval()
. - pending callbacks: exécute les callbacks d'E/S différés à la prochaine itération de la boucle.
- idle, prepare: utilisé en interne uniquement.
- poll: récupère les nouveaux événements d'E/S ; exécute les callbacks liés à l'E/S (presque tous, à l'exception des callbacks de fermeture, ceux planifiés par les timers et
setImmediate()
) ; Node se bloquera ici si nécessaire. - check: les callbacks
setImmediate()
sont invoqués ici. - close callbacks: certains callbacks de fermeture, par exemple
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 timers et s'arrête proprement s'il n'y en a pas.
Phases détaillées
timers
Un timer spécifie le seuil après lequel un callback fourni peut être exécuté plutôt que l'heure exacte à laquelle une personne souhaite qu'il soit exécuté. Les callbacks des timers s'exécuteront dès qu'ils pourront être planifiés après que le délai spécifié se soit écoulé ; cependant, la planification du système d'exploitation ou l'exécution d'autres callbacks peuvent les retarder.
TIP
Techniquement, la phase poll contrôle quand les timers sont exécutés.
Par exemple, supposons que vous planifiez un timeout pour qu'il s'exécute après un seuil de 100 ms, puis que votre script commence à lire un fichier de manière asynchrone, ce qui prend 95 ms :
const fs = require('node:fs')
function someAsyncOperation(callback) {
// On suppose que cela prend 95 ms pour se terminer
fs.readFile('/path/to/file', callback)
}
const timeoutScheduled = Date.now()
setTimeout(() => {
const delay = Date.now() - timeoutScheduled
console.log(`${delay}ms se sont écoulés depuis que j'ai été planifié`)
}, 100)
// faire someAsyncOperation qui prend 95 ms pour se terminer
someAsyncOperation(() => {
const startCallback = Date.now()
// faire quelque chose qui prendra 10 ms...
while (Date.now() - startCallback < 10) {
// ne rien faire
}
})
Lorsque la boucle d'événements entre dans la phase poll, elle a une file d'attente vide (fs.readFile()
n'est pas terminée), elle attendra donc le nombre de ms restant jusqu'à ce que le seuil du timer le plus proche soit atteint. Pendant qu'elle attend que 95 ms passent, fs.readFile()
termine la lecture du fichier et son callback, qui prend 10 ms pour s'exécuter, est ajouté à la file d'attente poll et exécuté. Lorsque le callback se termine, il n'y a plus de callbacks dans la file d'attente, donc la boucle d'événements verra que le seuil du timer le plus proche a été atteint, puis reviendra à la phase timers pour exécuter le callback du timer. Dans cet exemple, vous verrez que le délai total entre la planification du timer et l'exécution de son callback sera de 105 ms.
TIP
Pour empêcher la phase poll de faire mourir de faim la boucle d'événements, libuv (la bibliothèque C qui implémente la boucle d'événements Node.js et tous les comportements asynchrones de la plateforme) a également un maximum absolu (dépendant du système) avant qu'elle n'arrête de sonder pour plus d'événements.
Rappels en attente
Cette phase exécute les rappels pour certaines opérations système, telles que les types d'erreurs TCP. Par exemple, si un socket TCP reçoit ECONNREFUSED
lors d'une tentative de connexion, certains systèmes *nix attendent pour signaler l'erreur. Ceci sera mis en file d'attente pour être exécuté dans la phase des rappels en attente.
poll
La phase poll a deux fonctions principales :
- Calculer combien de temps il faut bloquer et sonder les E/S, puis
- Traiter les événements dans la file d'attente poll.
Lorsque la boucle d'événements entre dans la phase poll et qu'il n'y a pas de minuteurs planifiés, l'une des deux choses suivantes se produira :
Si la file d'attente poll n'est pas vide, la boucle d'événements itérera sur sa file d'attente de rappels en les exécutant de manière synchrone jusqu'à ce que la file d'attente soit épuisée ou que la limite matérielle dépendante du système soit atteinte.
Si la file d'attente poll est vide, l'une des deux choses suivantes se produira :
Si des scripts ont été planifiés par
setImmediate()
, la boucle d'événements mettra fin à la phase poll et continuera à la phase de vérification pour exécuter ces scripts planifiés.Si des scripts n'ont pas été planifiés par
setImmediate()
, la boucle d'événements attendra que des rappels soient ajoutés à la file d'attente, puis les exécutera immédiatement.
Une fois la file d'attente poll vide, la boucle d'événements vérifiera les minuteurs dont les seuils de temps ont été atteints. Si un ou plusieurs minuteurs sont prêts, la boucle d'événements reviendra à la phase timers pour exécuter les rappels de ces minuteurs.
check
Cette phase permet d'exécuter des rappels immédiatement après la fin de la phase poll. Si la phase poll devient inactive et que des scripts ont été mis en file d'attente avec setImmediate()
, la boucle d'événements peut passer à la phase de vérification au lieu d'attendre.
setImmediate()
est en fait un minuteur spécial qui s'exécute dans une phase séparée de la boucle d'événements. Il utilise une API libuv qui planifie l'exécution des rappels après la fin de la phase poll.
En général, au fur et à mesure que le code est exécuté, la boucle d'événements atteindra finalement la phase poll où elle attendra une connexion entrante, une requête, etc. Cependant, si un rappel a été planifié avec setImmediate()
et que la phase poll devient inactive, elle se terminera et continuera à la phase de check au lieu d'attendre les événements poll.
Callbacks de fermeture
Si une socket ou une poignée est fermée brutalement (par exemple, socket.destroy()
), l'événement 'close'
sera émis pendant cette phase. Sinon, il sera émis via process.nextTick()
.
setImmediate()
vs setTimeout()
setImmediate()
et setTimeout()
sont similaires, mais se comportent différemment selon le moment où ils sont appelés.
setImmediate()
est conçu pour exécuter un script une fois que la phase poll actuelle est terminée.setTimeout()
planifie l'exécution d'un script après un seuil minimum en ms.
L'ordre d'exécution des temporisateurs variera en fonction du contexte dans lequel ils sont appelés. Si les deux sont appelés depuis le module principal, le timing sera lié aux performances du processus (qui peuvent être affectées par d'autres applications s'exécutant sur la machine).
Par exemple, si nous exécutons le script suivant qui n'est pas dans un cycle d'E/S (c'est-à-dire le module principal), l'ordre d'exécution des deux temporisateurs est non déterministe, car il est lié aux performances du processus :
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
Cependant, si vous déplacez les deux appels dans un cycle d'E/S, le callback immédiat est toujours exécuté en premier :
// timeout_vs_immediate.js
const fs = require('node:fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
Le principal avantage de l'utilisation de setImmediate()
sur setTimeout()
est que setImmediate()
sera toujours exécuté avant tous les temporisateurs s'il est planifié dans un cycle d'E/S, indépendamment du nombre de temporisateurs présents.
process.nextTick()
Comprendre process.nextTick()
Vous avez peut-être remarqué que process.nextTick()
n'était pas affiché dans le diagramme, même s'il fait partie de l'API asynchrone. C'est parce que process.nextTick()
ne fait pas techniquement partie de la boucle d'événements. Au lieu de cela, la nextTickQueue
sera traitée une fois l'opération courante terminée, quelle que soit la phase actuelle de la boucle d'événements. Ici, une opération est définie comme une transition depuis le gestionnaire C/C++ sous-jacent, et la gestion du JavaScript qui doit être exécuté.
En regardant notre diagramme, chaque fois que vous appelez process.nextTick()
dans une phase donnée, tous les rappels passés à process.nextTick()
seront résolus avant que la boucle d'événements ne se poursuive. Cela peut créer des situations problématiques car cela vous permet de "priver" vos E/S en effectuant des appels récursifs à process.nextTick()
, ce qui empêche la boucle d'événements d'atteindre la phase poll.
Pourquoi cela serait-il autorisé ?
Pourquoi une telle chose serait-elle incluse dans Node.js ? Cela fait partie d'une philosophie de conception selon laquelle une API doit toujours être asynchrone, même lorsqu'elle n'a pas besoin de l'être. Prenons cet extrait de code par exemple :
function apiCall(arg, callback) {
if (typeof arg !== 'string') return process.nextTick(callback, new TypeError('argument should be string'))
}
L'extrait effectue une vérification d'argument et s'il n'est pas correct, il transmet l'erreur au rappel. L'API a été mise à jour assez récemment pour permettre le passage d'arguments à process.nextTick()
, lui permettant de prendre tous les arguments passés après le rappel pour être propagés comme arguments du rappel afin que vous n'ayez pas besoin d'imbriquer les fonctions.
Ce que nous faisons, c'est de renvoyer une erreur à l'utilisateur, mais seulement après avoir permis au reste du code de l'utilisateur de s'exécuter. En utilisant process.nextTick()
, nous garantissons que apiCall()
exécute toujours son rappel après le reste du code de l'utilisateur et avant que la boucle d'événements ne puisse se poursuivre. Pour ce faire, la pile d'appels JS est autorisée à se dérouler, puis à exécuter immédiatement le rappel fourni, ce qui permet à une personne d'effectuer des appels récursifs à process.nextTick()
sans atteindre une RangeError: Maximum call stack size exceeded from v8
.
Cette philosophie peut conduire à des situations potentiellement problématiques. Prenons cet extrait de code par exemple :
let bar
// ceci a une signature asynchrone, mais appelle le rappel de manière synchrone
function someAsyncApiCall(callback) {
callback()
}
// le rappel est appelé avant que `someAsyncApiCall` ne se termine.
someAsyncApiCall(() => {
// puisque someAsyncApiCall ne s'est pas terminé, bar n'a reçu aucune valeur
console.log('bar', bar) // undefined
})
bar = 1
L'utilisateur définit someAsyncApiCall()
pour avoir une signature asynchrone, mais il fonctionne en fait 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 en fait rien d'asynchrone. En conséquence, le rappel tente de référencer bar même s'il n'a peut-être pas encore cette variable dans la portée, car le script n'a pas pu s'exécuter jusqu'au bout.
En plaçant le rappel dans un process.nextTick()
, le script a toujours la possibilité de s'exécuter jusqu'au bout, permettant à toutes les variables, fonctions, etc., d'être initialisées avant l'appel du rappel. Il a également l'avantage de ne pas permettre à la boucle d'événements de continuer. Il peut être utile pour l'utilisateur d'être alerté d'une erreur avant que la boucle d'événements ne puisse continuer. Voici l'exemple précédent utilisant process.nextTick()
:
let bar
function someAsyncApiCall(callback) {
process.nextTick(callback)
}
someAsyncApiCall(() => {
console.log('bar', bar) // 1
})
bar = 1
Voici un autre exemple concret :
const server = net.createServer(() => {}).listen(8080)
server.on('listening', () => {})
Lorsqu'un seul port est passé, le port est lié immédiatement. Ainsi, le rappel 'listening'
pourrait être appelé immédiatement. Le problème est que le rappel .on('listening')
n'aura pas été défini à ce moment-là.
Pour contourner ce problème, l'événement 'listening'
est mis en file d'attente dans un nextTick()
pour permettre au script de s'exécuter jusqu'au bout. Cela permet à l'utilisateur de définir les gestionnaires d'événements qu'il souhaite.
process.nextTick()
vs setImmediate()
Nous avons deux appels similaires pour les utilisateurs, mais leurs noms sont confus.
process.nextTick()
s'exécute immédiatement dans la même phasesetImmediate()
s'exécute à l'itération ou au'tick'
suivant de la boucle d'événements
En essence, les noms devraient être inversés. process.nextTick()
s'exécute plus immédiatement que setImmediate()
, mais c'est un artefact du passé qui a peu de chances de changer. Changer cela casserait un grand pourcentage des paquets sur npm. Chaque jour, de nouveaux modules sont ajoutés, ce qui signifie que plus on attend, plus il y a de risques de rupture. Bien qu'ils soient confus, les noms eux-mêmes ne changeront pas.
TIP
Nous recommandons aux développeurs d'utiliser setImmediate()
dans tous les cas car il est plus facile à comprendre.
Pourquoi utiliser process.nextTick()
?
Il y a deux raisons principales :
Permettre aux utilisateurs de gérer les erreurs, de nettoyer les ressources inutiles ou de réessayer la requête avant que la boucle d'événements ne se poursuive.
Il est parfois nécessaire de permettre à un rappel de s'exécuter après le déroulement de la pile d'appels, mais avant que la boucle d'événements ne se poursuive.
Un exemple est de correspondre aux attentes de l'utilisateur. Exemple simple :
const server = net.createServer()
server.on('connection', conn => {})
server.listen(8080)
server.on('listening', () => {})
Supposons que listen()
est exécuté au début de la boucle d'événements, mais que le rappel d'écoute est placé dans un setImmediate()
. A moins qu'un nom d'hôte ne soit passé, la liaison au port se fera immédiatement. Pour que la boucle d'événements se poursuive, elle doit atteindre la phase de sondage, ce qui signifie qu'il y a une chance non nulle qu'une connexion ait pu être reçue, permettant à l'événement de connexion d'être déclenché avant l'événement d'écoute.
Un autre exemple est d'étendre un EventEmitter
et d'émettre un événement depuis le constructeur :
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
constructor() {
super()
this.emit('event')
}
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
console.log("un événement s'est produit !")
})
Vous ne pouvez pas émettre un événement depuis le constructeur immédiatement car le script n'aura pas été traité au point où l'utilisateur affecte un rappel à cet événement. Ainsi, dans le constructeur lui-même, vous pouvez utiliser process.nextTick()
pour définir un rappel afin d'émettre l'événement une fois le constructeur terminé, ce qui donne les résultats attendus :
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
constructor() {
super()
// utiliser nextTick pour émettre l'événement une fois qu'un gestionnaire est affecté
process.nextTick(() => {
this.emit('event')
})
}
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
console.log("un événement s'est produit !")
})