Trois exemples de codage qui montrent comment gérer un point de terminaison d’API de blocage de boucle d’événement
Comprendre le fonctionnement de la boucle d’événements Node.js est essentiel pour améliorer la manière dont nous écrivons le code JavaScript destiné à s’exécuter sur ce moteur. Connaître les différentes piles et phases traversées par la boucle d’événements et ce qui se passe dans chacune d’elles n’est que la base pour comprendre comment notre code doit être structuré pour fonctionner correctement au-dessus de cette architecture.
Loin d’être un simple guide de boucle d’événement Node.js (que vous pouvez facilement trouver sur Internet), dans cet article, je vais essayer de vous donner une idée générale de ce que signifie bloquer la boucle d’événement Node.js, et nous Nous verrons trois des stratégies les plus couramment utilisées pour le surmonter.
Commençons par un exemple simple d’API Express d’une application de blocage. Jetez un oeil au code suivant :
Cette API simple consiste en un point de terminaison unique qui calcule la somme des carrés des premiers entiers N-1 :
GET /calculate/2
va nous donner{ “result": 1 }
GET /calculate/5
va nous donner{ “result": 30 }
GET /calculate/10
va nous donner{ "result": 285 }
Maintenant, pour consommer ce point de terminaison, disons que nous codons un client qui envoie plusieurs requêtes en parallèle et mesure le temps qu’il faut pour que chacune se termine :
Maintenant, nous courons node index.js
et node client.mjs
. Voici la sortie :
node client.mjs
sending request #1 with n=4
sending request #2 with n=5
sending request #3 with n=1000000
sending request #4 with n=10
sending request #5 with n=1000000000
sending request #6 with n=10000
finished request #1, result for n=4 is 14, computed in 56 ms
finished request #2, result for n=5 is 30, computed in 36 ms
finished request #3, result for n=1000000 is 333332833333127550, computed in 37 ms
finished request #4, result for n=10 is 285, computed in 37 ms
finished request #5, result for n=1000000000 is 3.333333328333552e+26, computed in 1357 ms
finished request #6, result for n=10000 is 333283335000, computed in 1362 ms
Faites attention à la requête #6, pour n=10K
. Cela n’aurait pas dû prendre plus de temps qu’il n’en a fallu pour n=1M
(demande #3), alors, que s’est-il passé ?
Blocage de la boucle d’événements
Lorsqu’une nouvelle requête arrive à notre API Express, un événement est émis dans la boucle d’événements et une nouvelle instance de rappel est transmise à la pile de rappel. Cette instance de rappel effectuera le calcul de la somme des carrés. Comme un seul rappel peut être exécuté simultanément (par défaut, Node.js a un seul thread d’exécution), les autres sont mis en file d’attente et attendront que celui en cours d’exécution se termine.
Notre point de terminaison exécute une tâche gourmande en CPU (ce qui n’est pas recommandé pour les rappels Node.js). Par conséquent, une instance de rappel peut prendre beaucoup de temps pour se terminer, comme c’est le cas pour la requête n° 5. Pendant que la requête #5 est en cours d’exécution, la requête #6 attend la prochaine itération de la boucle d’événement, qui ne se produit jamais tant que l’instance de rappel de la requête #5 n’est pas terminée.
Le graphique suivant montre le temps d’exécution moyen pour chaque requête lors de l’utilisation de cette approche après avoir exécuté le script client cinq fois :
Dans un scénario réel, la requête #6 pourrait représenter un client d’API critique qui ne devrait pas attendre longtemps pour obtenir une réponse. Ainsi, nous voulons minimiser le temps d’exécution de chaque requête en les rendant indépendantes. Il existe plusieurs stratégies à suivre pour y parvenir. Je vais me concentrer sur trois des plus couramment utilisés.
Lorsque N est un grand nombre, le rappel de demande de traitement doit passer par beaucoup de travail pour calculer le résultat final. Le thread d’exécution Node.js principal ne peut être consacré à aucune autre tâche lorsque ce calcul l’occupe entièrement. Cependant, l’approche de partitionnement des tâches nous permet de diviser une grande partie du travail gourmand en CPU en un ensemble de blocs de travail.
Chaque bloc de travail est exécuté au cours d’une itération de boucle d’événement, permettant ainsi au thread principal d’exécuter d’autres rappels en file d’attente après avoir terminé chaque bloc. Le calcul du résultat final est terminé lorsque le dernier morceau de travail est terminé. Chaque bloc de travail planifie l’exécution du bloc suivant pour la prochaine itération de la boucle, permettant ainsi au thread principal d’extraire les instances de rappel des files d’attente qui doivent être exécutées pendant l’itération en cours.
Vous trouverez ci-dessous l’extrait de cette version de l’API. Nous allons y regarder de plus près pour mieux comprendre comment cela fonctionne :
La calculateResultPartitioned
la fonction renvoie un Promise
avec le résultat final du calcul pour un N
. Il définit les fonctions computePartial
et fn
.
computePartial
calcule partiellement le résultat en calculant 100 000 nombres dans la plage[0, N)
.fn
n’est qu’une fonction d’ordonnancement. Il appelle d’abord lecomputePartial
fonction et planifie ensuite la prochaine exécution (pour calculer le morceau suivant) en utilisant lasetImmediate
accrocher. Une fois que tous les morceaux ont été calculés (on sait qu’en regardant lesprevious
valeur de la variable), il résout le principalPromise
.
Comme vous l’avez peut-être deviné, bien qu’elle soit très populaire, cette approche est la moins préférée en raison de la complexité qu’elle apporte au code. Cependant, l’exécution de cette version de l’API à l’aide du même client renvoie le résultat suivant :
node client.mjs
sending request #1 with n=4
sending request #2 with n=5
sending request #3 with n=1000000
sending request #4 with n=10
sending request #5 with n=1000000000
sending request #6 with n=10000
finished request #1, result for n=4 is 14, computed in 59 ms
finished request #2, result for n=5 is 30, computed in 39 ms
finished request #4, result for n=10 is 285, computed in 40 ms
finished request #6, result for n=10000 is 333283335000, computed in 46 ms
finished request #3, result for n=1000000 is 333332833333127550, computed in 80 ms
finished request #5, result for n=1000000000 is 3.333333328333552e+26, computed in 16437 ms
D’une part, la requête #6 est exécutée et terminée beaucoup plus tôt et plus rapidement que la requête #5, ce qui est souhaitable. D’un autre côté, la requête #5 prend maintenant beaucoup plus de temps à terminer que la version originale. Remarquez comment cela affecte les temps d’exécution moyens, comme le montre le graphique suivant :
La requête n° 5 prend autant de temps à se terminer en raison du nombre de blocs de travail de taille 100 k contenus dans N=1000000000
itérations. Le fait de devoir planifier, mettre en file d’attente et afficher un rappel à chaque itération de boucle ajoute une surcharge supplémentaire au thread principal, ce qui le rend beaucoup plus lent. Notez que vous pouvez obtenir des résultats plus fluides en essayant avec une taille de bloc de travail différente (ici, j’essaie 100k).
Node.js Fils de travail sont une fonctionnalité moderne qui vous permet d’exécuter facilement des tâches en parallèle. Nous pouvons l’utiliser, en démarrant un nouveau fil à chaque fois qu’une demande est faite. Ce fil sera exclusivement dédié au calcul du résultat final pour une valeur spécifique de N
. Les processus enfants de Node.js pourraient être utilisés de la même manière. Ici, je préfère les threads de travail car ils constituent une solution plus simple et plus légère.
Pour utiliser les threads de travail, nous devons d’abord définir un solveOperationAsync
fonction chargée de démarrer chaque worker
instance de thread. Voici à quoi cela ressemble :
Il s’agit d’un module assez simple qui suit deux chemins d’exécution selon qu’il est exécuté depuis le processus parent ou le worker :
- lorsqu’il est exécuté par le processus parent, il crée une nouvelle instance de thread de travail, envoie le
N
valeur, et résout unePromise
avec le résultat final - lorsqu’il est exécuté par un thread de travail, il reçoit simplement le
N
valeur et calcule le résultat, en le renvoyant au parent avant de terminer
Maintenant, notre API Express ressemblera à ceci :
Notez que la gestion de la demande se produit dans le processus parent, donc un nouveau thread de travail est démarré chaque fois qu’une nouvelle demande est reçue. Puisque nous utilisons un Promise
pour faire le calcul, il écoutera les nouvelles requêtes à venir pendant que la précédente est en cours de résolution, ne bloquant donc pas les autres clients.
Idéalement, les threads de travail doivent être créés à l’aide d’un pool de travail, limitant le nombre de threads en cours d’exécution et empêchant ainsi notre infrastructure d’aller au-delà de ses limites de ressources.
Cette approche fonctionne beaucoup plus rapidement que la précédente. Jetez un oeil aux résultats que j’ai obtenus:
node client.mjs
sending request #1 with n=4
sending request #2 with n=5
sending request #3 with n=1000000
sending request #4 with n=10
sending request #5 with n=1000000000
sending request #6 with n=10000
finished request #1, result for n=4 is 14, computed in 139 ms
finished request #2, result for n=5 is 30, computed in 119 ms
finished request #4, result for n=10 is 285, computed in 118 ms
finished request #6, result for n=10000 is 333283335000, computed in 119 ms
finished request #3, result for n=1000000 is 333332833333127550, computed in 124 ms
finished request #5, result for n=1000000000 is 3.333333328333552e+26, computed in 1601 ms
Notez que chaque requête se termine en fonction de sa valeur de N (plus la valeur est faible, plus elle se termine tôt), mais pour une raison quelconque, il faut maintenant plus de temps pour calculer quand N
est 4
, 5
ou 10
. Cela peut être dû au travail supplémentaire nécessaire pour configurer chaque thread de travail avant son exécution.
La Cluster Node.js La fonctionnalité vous permet d’avoir plusieurs instances parallèles du même processus Node.js, qui seront réparties en fonction des besoins de l’application. Contrairement aux threads de travail, les processus de cluster sont isolés les uns des autres, ayant ainsi leurs propres piles d’appels, espace mémoire et threads. Cela en fait une solution plus robuste et plus lourde et plus rapide.
Ajoutons le clustering Node.js à notre API comme suit :
Ce code est assez similaire à l’approche des threads de travail car nous avons également deux chemins d’exécution en fonction du processus qui l’exécute. Remarquez la ligne cluster.schedulingPolicy = cluster.SCHED_RR
, cela était nécessaire sur mon Windows car il utilisait une stratégie différente au début, ce qui le ralentissait. Cela indique au moteur Node.js d’utiliser une stratégie Round Robin lors de l’équilibrage de charge.
Cette version de l’API semble fonctionner beaucoup plus rapidement que les autres. Jetez un œil aux résultats :
node client.mjs
sending request #1 with n=4
sending request #2 with n=5
sending request #3 with n=1000000
sending request #4 with n=10
sending request #5 with n=1000000000
sending request #6 with n=10000
finished request #1, result for n=4 is 14, computed in 45 ms
finished request #2, result for n=5 is 30, computed in 27 ms
finished request #4, result for n=10 is 285, computed in 26 ms
finished request #6, result for n=10000 is 333283335000, computed in 27 ms
finished request #3, result for n=1000000 is 333332833333127550, computed in 33 ms
finished request #5, result for n=1000000000 is 3.333333328333552e+26, computed in 1490 ms
Comme vous l’avez peut-être remarqué, cette approche correspond tout à fait à ce que nous recherchons :
- requêtes avec de faibles valeurs de
N
terminer presque immédiatement, en moins de 50 ms - requêtes avec de grandes valeurs de
N
qui pourrait prendre plus de temps à se terminer, ne bloquez pas les requêtes suivantes, qui sont gérées par différents processus
Lorsque vous travaillez avec Node.js, évitez autant que possible de bloquer la boucle d’événements comme première approche de travail. Évitez les tâches à forte utilisation du processeur ou effectuez-les de manière entièrement asynchrone à l’aide d’un environnement de travail. Cela éliminera le besoin d’adopter l’un de ces modèles.
L’approche cluster semble être la meilleure de toutes les stratégies présentées dans cet article. Si des processus parallèles doivent communiquer entre eux, vous feriez mieux d’essayer les threads de travail. Les deux stratégies vous permettent de contrôler la façon dont les processus/threads sont spammés, ce qui peut être résolu en mettant en œuvre une stratégie de mise en commun qui les crée et les tue à la demande.
L’approche de partitionnement des tâches semble être la plus compliquée et la plus inutile. Je recommande de ne pas l’utiliser, car cela rend les choses plus difficiles à comprendre. Utilisez-le si vous n’avez pas besoin/ne voulez pas spammer plus de processus supplémentaires que le principal, et il est peu probable que l’API reçoive plusieurs demandes simultanément. Tenez également compte des problèmes de temps de réponse, car certaines demandes peuvent prendre beaucoup plus de temps.
Espérons que cet article a clarifié certaines des stratégies les plus couramment utilisées pour gérer le blocage de la boucle d’événements Node.js à l’aide d’exemples concrets et pratiques.
Merci d’avoir lu, et restez connecté pour d’autres articles pratiques comme celui-ci !