Comprendre le séquençage et les comportements asynchrones en Javascript pour tirer partie du parallélisme

author
Andréas Hanss · Oct 18, 2020
dev | 11 min
Article
1
const chalk = require("chalk").default;
2
3
// Fail after time
4
function ApiError(time, theme) {
5
return new Promise((_, reject) => {
6
setTimeout(() => {
7
reject("=> API call " + chalk.red("failed") + " with [" + chalk.blue(theme) + "] at " + time);
8
}, time);
9
});
10
}
11
12
// Block execution and succeed after time
13
function createFile(time, theme) {
14
return new Promise(resolve => {
15
setTimeout(() => {
16
resolve("=> Created file for [" + chalk.blue(theme) + "]");
17
}, time);
18
});
19
}
20
21
// Succeed after time
22
function ApiSuccess(time, theme) {
23
return new Promise(resolve => {
24
setTimeout(() => {
25
resolve("=> Succeed API call on [" + chalk.blue(theme) + "]");
26
}, time);
27
});
28
}
29
30
// Loop occurence
31
async function loopOccurence(currentTheme) {
32
const loopJobName = chalk.green("✅ Finished job [" + chalk.blue(currentTheme) + "] at");
33
34
// Starting timer for counting duration of loop
35
console.time(loopJobName);
36
37
try {
38
console.log(`=> API call [${chalk.blue(currentTheme)}] is started`);
39
40
// To make some task fails and some work 50% chances.
41
const randomBooleanTrue = Math.random() >= 0.5;
42
43
if (randomBooleanTrue) {
44
await ApiError(3000, currentTheme);
45
} else {
46
const result = await ApiSuccess(3000, currentTheme);
47
console.log(result);
48
}
49
50
// If API succeeded, create file action
51
const writeOperationResult = await createFile(2000, currentTheme);
52
console.log(writeOperationResult);
53
54
// End timer
55
console.timeEnd(loopJobName);
56
} catch (error) {
57
console.log(error);
58
59
// End timer
60
console.timeEnd(loopJobName);
61
}
62
}
63
64
(async () => {
65
try {
66
const programExecutionTimer = chalk.green("✅ Reached end of script time");
67
68
const themes = ["sport", "cooking", "education"];
69
70
// Starting timer for script execution duration
71
console.time(programExecutionTimer);
72
console.log(chalk.blueBright("🚀 Starting synchronous script execution"));
73
74
// 1st case : Synchronous loop that wait at each occurence
75
for (let currentTheme of themes) {
76
await loopOccurence(currentTheme);
77
}
78
79
// 2nd case: asynchronous loop that use parallelism
80
// for (let currentTheme of themes) {
81
// loopOccurence(currentTheme);
82
// }
83
84
// 3rd case: asynchronous loop that use parallelism using functional programming
85
// themes.forEach(loopOccurence);
86
87
// 4th case: Even better and most efficient way
88
// await Promise.all(themes.map(loopOccurence));
89
90
// End timer for script execution duration
91
console.timeEnd(programExecutionTimer);
92
} catch (error) {
93
console.log(error);
94
}
95
})();

Javascript utilise un modèle de gestion de la concurrence mono-thread, cet article présente quelques réflexions et exemples pour comprendre les bases du module de concurrence de JS.

Nous allons étudier cela grâce à deux démonstrations: une première boucle séquentielle et synchrone puis une seconde avec du parallélisme et de l'asynchronicité.

🗓 Sommaire

  1. 🛫 Les bases du threading et de la concurrence
  2. 📌 Le modèle de concurrence et les comportements asynchrones de Javascript pour du code parallèle
  3. 🚀 Exemple : Boucle asynchrones vs boucle synchrones en Javascript

🛫 Les bases du threading et de la concurrence

Brièvement : en utilisant un langage traditionnel comme C ou Java, le programme fait parfois des choses qui prennent un certain temps pour que le CPU calcule.

Pendant ce temps, on ne peut pas exécuter d'autre code que les instructions actuelles. Le programme peut être gelé et l'interface utilisateur risque de ne pas répondre du tout le temps que cette opération se termine.

C'est une comportement tout à fait « normal » et l'ont doit malheureusement faire avec…

➡️ Comment empêcher les blocages d'interface utilisateur ?

Pour résoudre ce problème, on peut utiliser un thread, qui permet de déléguer l'exécution d'un code dans un sous-processus. Ceci conduit à des actions non-bloquantes et à l'exécution de code parallèle grâce au multithreading.

C'est la base du parallélisme de code et de la gestion de la concurrence dans les langages standards/anciens.

La gestion de la concurrence est un chapitre compliqué de la programmation, plus particulièrement pour les débutants. Je ne creuserais donc pas plus que cela le sujet ici. Ce que nous avons vu plus haut est amplement suffisant pour comprendre ce qui suit, étant donné que Javascript n'est pas un language multi-threadé.

Du coup:

❓ Comment traite-t-on les problèmes de concurrence avec Javascript ?

📌 Le modèle de concurrence et les comportements asynchrones de Javascript pour du code parallèle

Comme nous avons pu le voir ci-dessus, JavaScript est un langage mono-threadé, cela est vrai pour n'importe quelle implémentation de moteur JS comme V8 de Chrome, JavaScriptCore pour Safari d'Apple ou Hermes de Facebook pour React native.

❓ Qu'est-ce que cela signifie ?

Cela signifie que JavaScript ne pourra exécuter qu'une seule tâche à la fois et que chaque tâche sera mise en file d'attente pour être traitée.

Mais comme nous l'avons vu plus haut, nous aurons parfois besoin de faire plusieurs choses à la fois ou bien nous aurons des instructions qui peuvent prendre plusieurs secondes à traiter, ce qui conduit à des blocage à cause du thread blocking.

⚙️ Mais Javascript n'a pas de multi-threads

Donc, la seule façon d'y faire face est de déléguer l'exécution du code à un gestionnaire externe, de continuer l'exécution du script et d'exécuter un code de rappel une fois que l'exécution a été traitée.

Et c'est ce qui est implémenté par le moteur JSs dans quelque chose qui se nomme l' « event loop ».

JavaScript peut déléguer des comportements asynchrones au système d'exploitation en utilisant certaines fonctions « natives » fournies par le noyau.

La plupart du temps ce sont des opération IO, mais cela peut être autre chose.

  • Dans un navigateur web nous avons par exemple fetch ou bien setTimeout.
  • Pour NodeJS nous avons readFile et writeFile qui délègue aux IO système.

Une fois ces actions appelées, le système prend les ordres, exécute toutes les instructions dans son coin dans des sous-processus....

⚙️ La boucle d'évènement JavaScript

Ensuite le système va rapeller le programme en executant une callback: cela fait parti du fameux cycle de la boucle d'évènement Javascript.

Si vous n'avez jamais entendu parler de cela, jetez-donc un oeil ici: https://developer.mozilla.org/fr/docs/Web/JavaScript/Concurrence_et_boucle_des_événements

💣 Brièvement: La boucle d'événement est un élément du moteur JS qui tourne en boucle indéfiniment pendant que le programme est en cours d'exécution, cette boucle exécutera des instructions dans une file d'attente dans un ordre spécifique.

Ces instructions sont fournies par des instructions de rappel.

Les instructions de rappel fournissent du code qui doit être exécuté une fois que le système a terminé l'exécution asynchrone du code délégué, comme par exemple lire un fichier sur le système.

On parle d'instructions de rappel mais en réalité il y en a deux types:

  • Les fonctions de rappel (callback) qui recevront les paramètres de l'OS une fois appelées.
  • Ou les Promesse JavaScript qui résolvent ou rejettent (en utilisant async attendre comme exemple)

✋🏻⚠️ Je ne développerai pas ici ce qu'est une promesse (Promise), vous pouvez consulter ce guide très bien écrit à ce sujet : https://developer.mozilla.org/fr/docs/Web/JavaScript/Reference/Objets_globaux/Promise

🚀 Exemple : Boucles asynchrones vs boucles synchrones en JavaScript

Dans l'exemple suivant, nous allons itérer à travers un tableau de thématiques pour démontrer la différence entre une boucle bloquante et une boucle non-bloquante utilisant des comportements asynchrones.

Pour chaque thématique nous avons besoin de :

  1. Récupérer des données d'une API distante…
  2. Et écrire quelque chose dans un fichier.

✋🏻 Tous les exemples de codes suivants sont composés de code bouchon qui ne font que retarder le retour de la fonction en utilisant une Promesse Javascript. C'est une façon de simuler une tâches déléguée au système.

Afin d'utiliser le snippet suivant, vous devrez créer un fichier index.js, copier les snippets de code donnés et exécuter un npm init -y && npm install chalk afin d'installer cette petite librairie utilitaire qui permet d'utiliser de la couleur dans le texte de la console.

Il suffit ensuite de lancer le script en tapant node ./index.js

❶ Première approche : Exécution « synchrone » du code

On commence par l'approche « synchrone », celle-ci bloquera l'exécution dans la boucle en utilisant une fonction asynchrone avec le mot-clé await tant que la fonction appelée n'aura pas retourné de valeur.

💡 Info : En fait, ce n'est pas vraiment synchrone comme on le pense. L'utilisation de await ne fera que s'assurer que le code est exécuté séquentiellement au sein de la fonction. Chaque fois que l'on attends, on laisses d'autres instructions s'exécuter ailleurs et plus tard le programme continuera où nous attendions.

Ceci conduit aux postulats suivants :

  • Exécution séquentielle du programme
  • Parallélisme de code inexistant
  • La garantie que pour chaque thématiques l'appel à l'API a été retourné et que le fichier a été essayé d'être écrit avec succès ou échec, avant de passer à la prochaine itération.

Bien que cela puisse parfois être intéressant pour certains cas d'utilisation pour garantir le séquençage lorsque les contenus ont des effets de bords les uns sur les autres, dans le cas d'utilisation suivant, ce n'est pas la meilleure façon d'atteindre notre but.

En exécutant le script suivant dans un programme NodeJS, vous pouvez voir que le temps total d'exécution du script est assez élevé par rapport, mais comme nous le séquençons, le temps d'exécution augmente de manière linéaire.

❷ Deuxième approche : Exécution de code asynchrone et parallélisme

Comme chacune de nos thématiques n'ont aucun rapport entre elles en termes de dépendance et d'effets de bord, notre fonction de rappel loopOccurence est presque une fonction pure, car elle n'a pas d'effet de bords sur notre application. (pour être une vraie fonction pure, elle ne devrait pas avoir d'effets de bord du tout).

👨🏻‍🔬 Une fonction pure est une fonction qui, en prenant les mêmes arguments, produira et retournera le même résultat à chaque fois. Cette fonction ne cause non plus pas d'effets de bord en dehors de son champ d'application.

Et pour cette raison, je vous invite à voir ce qui se passe si vous supprimez le mot-clé await dans le snippet précédent comme décrit ci-dessous.

💡 Les mots clés async / await sont des outils pour contrôler le flux d'exécution d'une fonction. En supprimant le mot-clé await, on demande à l'interpréteur de ne pas attendre que la fonction réponde avant de passer à la prochaine itération de la boucle.

Cette approche tire parti du parallélisme puisque le moteur JS envoie les trois requêtes en même temps.

💡 Info : Ah oui, j'oubliais, vous n'utilisez pas await à l'intérieur de la boucle mais cela ne veut pas dire qu'il ne faut pas l'utiliser du tout, en fait vous devrez gérer les erreurs et vous assurer que tout se passe bien. Nous verrons cela dans la section suivante.

Les performances et la réactivité de l'application sont grandement améliorées et nous pouvons toujours garder notre séquençage à l'intérieur de chaque boucle de thématiques en attendant d'abord l'état de retour d'appel de l'API et ensuite créer le fichier.

Si l'on considère le temps d'exécution, c'est bien mieux que notre première approche.

Pourquoi ? Parce que JS délègue l'exécution de manière parallèle, de sorte que le script peut accéder plus rapidement aux autres instructions (itérations de boucle dans notre cas), ce qui se traduit par de meilleures performances.

🎁 Bonus : Utilisation de la programmation fonctionnelle et de l'instruction forEach

Nous aurions également pu remplacer notre boucle for...of  par une fonction de programmation fonctionnelle nommée forEach. Le résultat serait exactement le même que celui que vous voyez ici.

💡 Voulez-vous en savoir plus sur la fonction forEach ? Cliquez ici !

⚡️🔓 La manière la plus sûrs et efficace qui permet également de gérer les erreurs : map + Promise.all

En fait, il me manquait quelque chose de vraiment important : m'assurer que tout fonctionne bien et gérer les erreurs.

Vous pouvez améliorer l'extrait ci-dessus avec ce qui suit en utilisant une combinaison de la fonction map de la programmation fonctionnelle et de la fonction Promise.all.

La première volonté permet de mapper la promesse qui sera transmise à la seconde. La seconde recevra un tableau de promesses et fera un « échec rapide » si l'une des promesses du tableau donné échoue. Il s'agit de notre scénario de gestion des erreurs.

🚛 À emporter / TLDR ; 📦

  • Les applications JavaScript s'exécutent sur un seul thread. Vous devrez utiliser un mécanisme asynchrone pour traiter cet attribut de thread unique.
  • Les mécanismes asynchrones tirent parti des fonctions de rappels JavaScript et des promesses.
  • Les mots clés async et await sont des outils permettant de contrôler le flux d'exécution au sein d'une fonction.
  • L'utilisation de l'asynchronisme peut grandement améliorer les performances et la réactivité de vos applications en utilisant le parallélisme.
  • À l'intérieur des instructions de boucle : vous pouvez utiliser forEach ou for...of  pour obtenir des itérations asynchrones. Ou bien : vous pouvez utiliser for...of avec le mot-clé await ou des générateurs JS pour obtenir une exécution synchrone du code dans la fonction.