Les raisons d'ĂȘtre de Rust
Lecture 28 min. âą
Table des matiĂšres
- Exécuter du code
- Compilation ou interprétation
- Typage
- Gestion de la mémoire
- Programmation parallĂšle
- Asynchronisme
- Conclusion
Faire du Rust, oui, mais pourquoi ?
Câest ce que nous allons essayer de montrer dans cette partie.
Posons-nous dâabord une simple question.
Quelles sont les caractéristiques de Rust ?
Rust est un langage de programmation compilé fortement typé sans garbage collector et qui a pour objectif de rendre la programmation asynchrone et parrallÚle la plus safe possible.
Ok, la rĂ©ponse est pas aussi simple que la question. đ
Je propose que lâon rĂ©alise un tour dâhorizon des connaissances nĂ©cessaires Ă la comprĂ©hension pleine et entiĂšre de la rĂ©ponse.
Exécuter du code
Un ordinateur devrait sâappeler un calculateur, il ne sait que prendre des nombres et rĂ©aliser des opĂ©rations dessus.
Pour cela il utilise ce que lâon appelle un CPU Central Processing Unit littĂ©ralement âUnitĂ© centrale de calculsâ.
Pour savoir ce quâil a Ă faire, il utilise ce que lâon appelle du code machine. Et ceci est lâunique langage quâil comprend.
Il nâest pas possible de lui donner du python ou du javascript.

La subtilitĂ© câest quâil existe autant de codes machines quâil y a de types de CPUs.
Et seul le bon type de code machine peut ĂȘtre compris par le bon CPU.

Il faut donc un chaĂźnon manquant qui rĂ©alise la liaison entre le code source dont on a lâhabitude et le code machine adaptĂ© Ă la plateforme.

Lâhistoire de la programmation a Ă©tĂ© longue et a menĂ© Ă diverses solutions techniques.
Nous allons voir ce qui existe.
Compilation ou interprétation
En programmation, il existe deux grandes familles principales de langages, les langages compilés et les langages interprétés.
Compilation
La compilation est une opération qui consiste à transformer du code source en du code machine.
On utilise pour cela un programme appelé un compilateur qui va lire le code source et le traduire en instructions compréhensibles pour le CPU.
Cette opération est réalisée une fois et échoue si le code est mal écrit.
Si tout est correct, un exécutable ou une bibliothÚque est produite.
Le code peut alors ĂȘtre exĂ©cutĂ© par le CPU sans autre outil nĂ©cessaire.
Précison
La plupart des programmes ont tout de mĂȘme besoin de bibliothĂšques installĂ©es sur le systĂšmes dâexploitation. La lib C par exemple.

Interprétation
Les langages interprĂ©tĂ©s les plus connus sont le PHP, le Javascript et le Python, pour ne citer quâeux.
Le principe est de lancer un programme appelĂ© un interprĂ©teur qui va lire le code source et transformer celui-ci en des instructions comprĂ©hensibles par le CPU de lâordinateur.

Ce qui signifie quâil faut Ă la fois le code source et le bon interprĂ©teur pour pouvoir Ă©xĂ©cuter le programme.
Attention
Votre interprĂ©teur doit ĂȘtre capable de discuter dans le langage machine du CPU.
A la diffĂ©rence dâun programme compilĂ©, un programme interprĂ©tĂ© lâest Ă chaque Ă©xĂ©cution. Cela siginifie plusieurs choses:
- on coupe la phase de compilation mais en contre partie le code doit ĂȘtre traduit Ă chaque Ă©xĂ©cution du programme
- câest globalement lent, lâinterprĂ©teur dĂ©couvre le code ligne par ligne, doit comprendre et rĂ©agir en consĂ©quence, ça prend du temps
- si le code est mal Ă©crit, le programme plantera Ă lâĂ©xĂ©cution -> en prod
VM
Il existe également une catégorie de langages comme le Java qui sont un peu entre les deux.
Le code est compilĂ© mais non pas vers du code machine mais vers du byte code. Câest une sorte de langage machine mais adaptĂ© pour un CPU virtuel.

Puis lâon fait tourner un programme qui se nomme une machine virtuelle ou VM qui se charge dâexĂ©cuter le byte code. Contrairement Ă lâinterprĂ©tateur qui prend des dĂ©cisions au fur et Ă mesure de la lecture. La machine virtuelle se comporte comme un CPU virtuel et donc ne fait globalement quâexĂ©cuter ce qui lui ai donnĂ©. Ce qui rend lâinterprĂ©tation bien plus efficace.
La VM transforme alors le byte code en un flux instructions compréhensible par le CPU, lui bien réel.

LâidĂ©e derriĂšre ce compromis est de bĂ©nĂ©ficier Ă la fois des avantages de la compilation qui permet de dĂ©tecter trĂšs tĂŽt les erreurs du dĂ©veloppeur ou de la dĂ©veloppeuse. Et en mĂȘme temps dâavoir une portabilitĂ©: du byte code est valide pour toute plateforme et donc pour tout CPU.
CâĂ©tait le slogan de Java
Write once. Run anywhere.
Ecrire une fois, exécuter partout.
Le choix de Rust
Rust a choisi la compilation pour dĂ©celer le plus tĂŽt possible les erreurs et ĂȘtre au plus prĂšs de la machine pour des raisons de performances. Cela induit un temps de compilation et de recompilation pour le dĂ©veloppeur mais sâassure que le code exĂ©cutĂ© est valide syntaxiquement.
La VM a également été écarté car elle induit son installation avant de pouvoir exécuter du code.
Typage
Le typage est la facultĂ© Ă donner un sens Ă des donnĂ©es. Un ordinateur ne comprend que des 0 et des 1. Câest le dĂ©veloppeur qui leur donne un sens au moyen dâun langage de programmation.
Le mĂȘme nombre 42 peut Ă la fois ĂȘtre lâentier 42, le charactĂšre * ou le dĂ©cimal 42.0.
Tout dĂ©pend de ce que lâon souhaite en faire.
Il est mĂȘme possible que ce 42 corresponde Ă la valeur de la composante rouge dâun pixel, alors il porte en plus dâune valeur, une sĂ©mantique.
moi r un entier positif correpond à la composante rouge d'un pixel. Il existe deux maniÚres de réaliser cela.
Typage dynamique
Câest de lĂ que viennent toutes les bizarreries du Javascript.
3 - "1" // renvoit 2 le nombreIci par exemple on soustrait un nombre, une quantité numérique avec une chaßne de caractÚre.
Le choix a Ă©tĂ© fait de le transformer en nombre avant dâeffectuer la soustraction. Mais cette dĂ©cision est totalement arbitraire.
On aurait pu tout a fait dĂ©cider que câĂ©tait le code ASCII de 1 qui vaut 49.
Ce qui signifie que toutes ces manipulations de types diffĂ©rents sont totalement liĂ©s aux connaissances du langages et de sa capacitĂ© Ă ne pas faire les âmauvais choixâ.
Si lâon revient Ă notre exemple de pixel, nous allons contruire un tableau de clefs et valeurs, plus communĂ©ment appelĂ© Map.
let pixel = {
r: 42,
v : 12,
b: 60
}Les champs nâĂ©tant pas typĂ©s, nous pouvons trĂšs bien avoir les mĂȘmes noms de champs mais avec des types diffĂ©rents.
let not_a_pixel = {
r: "42",
v : true,
b: -456.47
}Ce qui fait que lorsque lâon créé un tableau
let tab = [pixel, not_a_pixel]Et que lâon dĂ©sire appliquer une transformation comme faire la moyenne des composantes dâun pixel
tab.map(a => ( a.r + a.v + a.b ) / 3)On se retrouve avec ce résultat ci:
[38, NaN]Les champs existants, le processus câest bien dĂ©roulĂ©, mais le typage non.
Le premier résultat est bon, le second non, on a additionné des entiers, des booléens et des chaines de caractÚres sans se préoccuper de ce qui se passe.
Et ça pourrait trĂšs bien arrivĂ© si par exemple les donnĂ©es proviennent de la lecture dâun JSON par exemple.
Le dĂ©veloppeur ou la dĂ©veloppeuse doit faire extrĂȘmement attention Ă toutes les opĂ©rations quâil rĂ©alise explicitement ou implicitement.
Typage statique
La deuxiĂšme maniĂšre dâapprĂ©hender le sujet est de dire que lâon sait Ă tout moment ce que lâon manipule et par consĂ©quent nous sommes capables dâattribuer un scope de comportement voulus ou non.
De fait les bizarreries dâadditions de types diffĂ©rents ne devrait pas ĂȘtre possible implicitement.
Pour aider le compilateur ou lâinterprĂ©teur Ă savoir de quoi lâon parle, nous allons typer tout ce qui existe et ainsi restreindre les axes de libertĂ©s du dĂ©veloppeur et surtout lui Ă©viter des chutes dans le ravin! đ
Ainsi notre pixel nâest plus une Map mais une structure.
struct Pixel {
int r,
int v,
int b,
};Pour créer un Pixel:
struct Pixel pixel = { .r = 42, .v = 12, .b = 60 };Cela ressemble fortement à notre Map, mais il y a une différence
Il nâest pas possible de gĂ©nĂ©rer not_a_pixel!
On veut créer un Pixel, mais on ne donne pas les ingrédients pour!
struct Pixel not_a_pixel = { .r = "42", .v = true, .b = -456.47 };Le compilateur ou lâinterprĂ©teur dira quâil attend un entier comme composante r quâil a reçu un string.
De mĂȘme, si lâon rĂ©alise
struct Pixel {
int r,
int v,
int b,
};
struct NotAPixel {
string r,
bool v,
float b,
};
struct Pixel pixel = { .r = 42, .v = 12, .b = 60 };
struct NotAPixel not_a_pixel = { .r = "42", .v = true, .b = -456.47 };Les deux initialisations se passeront bien.
Par contre, si lâon tente de crĂ©er notre tableau
Pixel tab[] = { pixel, not_a_pixel };Le systĂšme dira quâil nâest pas possible de crĂ©er un tableau avec du Pixel et du NotAPixel. Nous assurant que les types manipulĂ©s sont toujours les bons.
Le typage fort ou statique impose Ă©galement une rĂšgle, le type dâune variable ne peut pas varier implicitement.

Si le langage est compilĂ© alors toutes ces vĂ©rifications sont faites avant que le programme ne puisse sâexĂ©cuter.
Le choix de Rust
Rust a choisi un typage fort pour éviter toutes manipulations implicites malencontreuses. Le fait également que le langage soit compilé aurait induit une trop grande complexité pour réaliser un typage faible.
Gestion de la mémoire
La plupart des codes qui exĂ©cutent des opĂ©rations un tant soit peu complexes, ont besoin de venir stocker dynamiquement de la donnĂ©e en mĂ©moire. Si rien nâest fait, elle sâaccumule jusquâau crash de lâapplication.
Il existe deux maniÚres principales de gérer le problÚme.
Garbage collector
La premiÚre et la moins contraignante pour le développeur est de laisser le programme se débrouiller seul.
Seul pas vraiment.
LâidĂ©e est de faire tourner Ă cĂŽtĂ© du programme utilisateur un autre programme qui a pour but de gĂ©rer la mĂ©moire de lâautre programme. Ce second programme se nomme un Garbage Collector ou ramasse-miette.

Son principe de fonctionnement est à intervalles réguliers de stopper purement et simplement le déroulé du programme.

Une fois le programme stoppĂ©, le garbage collector liste toutes les variables du programme et compte le nombre de fois que la variable est rĂ©fĂ©rencĂ©e Ă lâinstant de lâarrĂȘt de lâexĂ©cution.
Sâil existe au moins une rĂ©fĂ©rence Ă la variable, elle est gardĂ©. Sinon, la mĂ©moire occupĂ©e par la variable est libĂ©rĂ©e.

Â
Il est Ă noter que la durĂ©e de pĂ©riode de stop est dĂ©pendante du temps dâexĂ©cution du garbage collector.
Plus il y a de variables à vérifier, plus le programme fait des choses compliquées, plus le garbage collector mettra du temps à réaliser sa passe de vérification.
Et donc stoppera longuement le programme principal.
Libération explicite
La façon la plus âcasse-gueuleâ de faire les choses. Si le code est mal fait il aura des fuites de mĂ©moire on dira quâil âleakâ.
Toute la bonne utilisation et libĂ©ration de la mĂ©moire repose sur les compĂ©tences du dĂ©veloppeur et ainsi sur sa connaissance du flux dâexĂ©cution.
Lâavantage est quâil nâest plus nĂ©cessaire dâavoir un Garbage Collector. Et donc plus de freeze du programme.
Les intructions de libérations sont directement inscrites dans le code et exécuter comme le serait une addition.

Par contre cela impose une grande rigueur pour au choix:
- oublier de libérer la mémoire
- la libérer deux fois
- libérer le mauvais emplacement
- libérer quelque chose encore utilisé
Bref moult soucis et lâĂ©tape suivante qui consistera Ă faire des choses en parallĂšle ne va pas arranger les choses. Et rendre presque impossible cette maniĂšre de faire.
Il existe des outils qui a posteriori de la rédaction du code nous montre les erreurs.
Mais est ce que ce ne serait pas plus simple que lors de la compilation une analyse statique dĂ©termine pour nous ce qui doit ou pas ĂȘtre libĂ©rĂ© et surtout quand cela doit ĂȘtre fait ?.
La valeur ajoutée de Rust
Rust est un langage qui se veut le plus performant possible.
Dans certains contextes, le temps passĂ© par le Garbage collector nâest pas acceptable.
Ainsi, Rust utilise de la libĂ©ration explicite de la mĂ©moire. Mais pour toutes les raisons Ă©noncĂ©es ci-dessus, il nâest pas possible de se fier Ă la capacitĂ© du dĂ©veloppeur Ă manipuler proprement la mĂ©moire.
Câest ainsi que lâon rajoute une autre piĂšce dans la machine.
Jâai nommĂ© lâanalyse statique !
LâidĂ©e est de parcourir Ă âfroidâ le code et de deviner son comportement Ă lâexĂ©cution pour dĂ©terminer Ă quel moment tel emplacement mĂ©moire deviendra inutile.
Les différentes rÚgles qui régissent cela sont décrites plus en détails dans ce chapitre.
Ainsi Rust, mĂȘme sans Garbage Collector, ne fait pas reposer la responsabilitĂ© de la libĂ©ration de la mĂ©moire sur les frĂȘles Ă©paules du dĂ©veloppeur. đ
Au lieu de ça lui donne un cadre solide qui devient essentiel, lorsque le code perd sa séquentialité.
Programmation parallĂšle
Imaginez vous avez un tapis roulant oĂč dĂ©filent des tĂąches Ă effectuer, ces tĂąches ont chacune une durĂ©e estimĂ©e, certaines rapides se font en 1min, dâautres en 2 min et dâautres encore en 3 min.
Si vous nâavez quâun seul ouvrier sur la chaĂźne, il ne pourra travailler que sur une tĂąche Ă la fois.

Ainsi, le temps passé à effectuer les tùches sera la somme des temps de chaque tùche. Ici 6 min.
On voit bien que dans le laps de temps pour réaliser la tùche de 3 min, nous avons le temps de faire les tùches de 2 min et 1 min.
Comme dâhabitude, âdiviser pour mieux rĂ©gnerâ.
On dispatche le travail en deux tapis. A partir du moment oĂč lâouvrier ne fait rien on lui envoie une nouvelle tĂąche.
Ainsi, pendant que lâouvrier 1 sâoccupe de la tĂąche 1 de 3 min. Lâouvrier 2 Ă dĂ©jĂ terminĂ© la tĂąche 2 de 2 min et va sâattaquer la tĂąche 3.

Nous avons parrallélisé le travail !
RĂ©sultat: le temps total passĂ© Ă rĂ©aliser les 3 tĂąches nâest plus de 6 min mais de 3 min environ. đ
Dans un ordinateur
Quoi que lâon puisse croire, un ordinateur ne sait pas faire les choses en parrallĂšle.
Il prend les choses une par une et les réalise séquentiellement.
Sâil est en train de faire une addition, il ne fera pas une autre addition en mĂȘme temps.
Par contre il est super rapide ! 3 GHz que lâon peut lire. Ce qui signifie que son coeur bat Ă 3 milliards de pulsation par seconde ! Et sur chaque pulsation il peut faire une action !
Notre temps nâĂ©tant pas au milliardiĂšme de seconde et mĂȘme les programmes ne le sont pas. On peut faire croire Ă du parrallĂ©lisme en faisant quelque chose dâintelligent.
Découper le temps alloué en des bandes.
Chaque bande est appelĂ© un Thread ou fil dâĂ©xĂ©cution dans la langue de MoliĂšre.
Le programme sâil a 2 threads, il ressemblera Ă une alternance de thread 1 et de thread 2.

Avec 4 threads

Avec 10 threads

Etc âŠ
Plus lâon met de threads, plus le temps allouĂ© Ă chacun se raccourci. Câest donc une balance Ă avoir entre les tĂąches que lâon rĂ©alise et le temps passĂ© Ă les rĂ©aliser.
Si câest mal rĂ©glĂ© on peut mĂȘme passer tout son temps Ă changer de contexte et plus du tout Ă faire des choses utiles.
Remarque
Oui cette histoire de threads ressemble furieusement au Garbage Collector, et pour cause !
Le Garbage collector tourne dans un thread, le programme dans un autre. ^^
Concurrence
La concurrence est un vaste sujet. Mais disons que câest lâidĂ©e quâune donnĂ©e ne doit pas ĂȘtre modifiĂ© par plus dâune personne Ă la fois.
Imaginons 2 threads, dedans on réalise une modification de valeur.
Il possÚde en commun une variable i, celle-ci vit quelque part dans la mémoire.
i += 1Le code est identique dans les deux threads.
Décomposons le un peu:
accumulateur <- charger i;
accumulateur <- accumulateur + 1;
accumulateur -> stocker iDonc ce code:
- charge de la mémoire la valeur de
i - incrémente
i - stocke la nouvelle valeur de
i
Comme je vous lâai dit plus haut, le temps du thread est comptĂ© et dĂšs que celui-ci arrive Ă terme on passe au deuxiĂšme threads.
On peut alors dans des cas bien précis se retrouver dans la situation suivante:

ien mémoire partagée à une valeur de $0$- Le
thread 1démarre - Le
thread 1chargeide la mémoire avec une valeur de $0$ - Le
thread 1incrémentei localqui vaut maintenant $1$ - Le
thread 1passe la main authread 2 - Le
thread 2chargeide la mémoire avec une valeur de $0$ - Le
thread 2incrémentei localqui vaut maintenant $1$ - Le
thread 2stockei local = 1dansien mémoire partagée ien mémoire partagée à une valeur de $1$- Le
thread 2passe la main authread 1 - Le
thread 1stockei local = 1dansien mémoire partagée ien mémoire partagée à une valeur de $1$
Les threads se sont tĂ©lescopĂ©s! le thread 1 nâa pas eu le temps de finir, que le thread 2 a dĂ©butĂ© et sâest terminĂ©.
RĂ©sultat, au lieu dâavoir i = 2, nous avons seulement i = 1. Car le thread 1 qui va redĂ©marrer va Ă©craser ce que le thread 2 a fait.
On appelle ça une Race condition, elle nâintervient que dans des cas biens prĂ©cis de mauvais timing, mais quand cela arrive, on fait nâimporte quoi !
Pour rĂ©soudre se problĂšme nous allons utiliser un outil appelĂ© un Lock. Il sâagit dâun marqueur qui explique que la variable est en cours dâutilisation et ne peut actuellement pas ĂȘtre modifiĂ©e.
Notre code tournant dans les thread devient alors:
lock(i);
i += 1;
release(i);Que lâon dĂ©compose en :
acquérir(i);
accumulateur <- charger i;
accumulateur <- accumulateur + 1;
accumulateur -> stocker i
libérer(i);Temporellement cela donne :

ien mémoire partagée à une valeur de $0$- Le
thread 1chargeide la mémoire en acquérant le droit de modification avec une valeur de $0$ - Le
thread 1incrémentei localqui vaut maintenant $1$ - Le
thread 1passe la main authread 2 - Le
thread 2tente de chargerimais il déjà acquis parthread 1 - Le
thread 2passe la main authread 1 - Le
thread 1stockei local = 1dansien mémoire partagée ien mémoire partagée à une valeur de $1$- Le
thread 1libĂšrei - Le
thread 1passe la main authread 2 - Le
thread 2chargeide la mémoire en acquérant le droit de modification avec une valeur de $1$ - Le
thread 2incrémentei localqui vaut maintenant $2$ - Le
thread 2stockei local = 2dansien mémoire partagée ien mémoire partagée à une valeur de $2$
Ici la maniĂšre dont le systĂšme rĂ©agit est complĂštement diffĂ©rente. Une fois quâun thread a acquis la possibilitĂ© de modifier une variable partagĂ©e. les autres threads doivent attendre quâelle soit libĂ©rĂ©e avant de pouvoir eux-mĂȘme lâacquĂ©rir.
Cela suppose donc que certain threads vont faire des tours Ă âblancâ car il ne pourront pas acquĂ©rir la propriĂ©tĂ© en modification de la variable.
Mais par contre, cela rÚgle le soucis de race condition étant donnée que le premier arrivé sera le premier servi et que les suivants attendront bien sagement derriÚre.
Attention
Si le
thread 1ne libĂšre pas la variable Ă la fin de son exĂ©cution, lethread 2ne pourra plus lâacquĂ©rir.Par exemple
thread 1crash avant libération.On nomme ça un DeadLock of the Death.
Et Rust dans tout ça ?
La raison dâĂȘtre de Rust est de profiter des architecture multi-coeur des CPU modernes et donc de parallĂ©liser tout ce qui peut lâĂȘtre. Câest aussi ce que fait le Java, mais avec une JVM et tout un tas dâoutils qui rendent les choses plus que contraignante et moins âbas niveauâ que du C, par exemple.
Sauf que faire de la concurrence en C, et bien câest un peu la mĂȘme limonade que de gĂ©rer de la mĂ©moire Ă la main. Câest un enfer, on peut facilement se tromper et souvent câest sous-optimal lorsque câest mal fait.
Pour comprendre le besoin, il faut remonter Ă lâĂ©poque oĂč Rust Ă©tait dĂ©veloppĂ© Ă Mozilla. Ils avaient un besoin. Le moteur de rendu des pages Ă©tait vieillissant, il Ă©tait basĂ© sur du code Ă©crit en C/C++ et donc un peu casse-pieds pour ĂȘtre poli Ă maintenir au niveau du parrallĂ©lisme.
Câest alors quâils se sont aperçus Ă Mozilla quâil y avait un side-project dans un coin qui Ă©tait plutĂŽt pas mal. La base du langage Ă©tait de faire un langage compilĂ© qui gĂšre de maniĂšre sĂ©curisĂ© la concurrence et la programmation parrallĂšles.
Oh bah ! Dis donc ! Tiens alors ! Sâti pas exactement le besoin !
Et câest des prĂ©mices de Rust quâest sorti Servo.
Quâest ce que Rust a sous le capot niveau parrallĂšlisme ?
Et bien, le premier exemple avec la race condition est tout simplement impossible Ă compiler !
Le langage ne permet pas de faire des choses qui pourrait avoir des comportements non déterministes avec la mémoire.
De fait tout une typologie dâerreurs est Ă©liminĂ©e dâemblĂ©e par le compilateur.
De mĂȘme le Deadlock bien quâexistant peut Ă©galement ĂȘtre rĂ©parĂ© avec les outils du langage.
Tout cela permet de réaliser du parallélisme sans les inconvénients de la gestion de la mémoire ou de la concurrence qui lui sont associés.
Asynchronisme
Jâai un peut tapĂ© sur Javascript lors de la partie sur le typage. Mais clairement la gestion de la lâasynchronisme est top dans ce langage.
Mais quâest ce que lâasynchronisme ? đ§
Lâasynchronisme câest quand on ne sait pas Ă lâavance quand est-ce que lâon aura la rĂ©ponse Ă ce que lâon a demander.
Quand deux personnes discute vocalement, la discussion est synchrone. Lâun parle, lâautre rĂ©pond et le dĂ©lais entre les rĂ©pliques est nĂ©gligeables.
Par contre dans une discussion par mail. Lâun peut envoyer un mail et le destinataire rĂ©pondre 2 jours plus tard. On parle de communication asynchrone.
En gros on ne sait pas quand et mĂȘme si ça rĂ©pondra.
Câest pareil en informatique.
Certaines tĂąches sont considĂ©rĂ©es comme synchrones, dâautre non.
Faire une addition câest rapide, aller chercher sur le net le contenu dâune page câest lent !

Et du coup quâest ce quâon fait en attendant ? Ben rien ! On attend âŠ
TĂąche bloquante

La premiĂšre tĂąche est trĂšs courte, suffisamment pour que lâutilisateur la considĂšre synchrone.
La seconde par contre est de lâordre de la milliseconde. On nomme ceci une tĂąche bloquante.
En imaginant que la tĂąche 3 est la mise Ă jour de lâaffichage du curseur de la souris.
Nous nous retrouvons avec la souris bloquĂ©e le temps que la page web ne charge ⊠đ„
Pire si elle ne charge jamais, câest finito, le programme est bloquĂ© Ă©ternellement.

LâidĂ©e est donc dâutiliser ce que lâon a appris prĂ©cĂ©demment. Les threads !
On met un premier thread à réaliser en boucle la mise à jour du pointeur de la souris.
Et dans un second le fetch de la page.
Ainsi la souris nâest jamais bloquĂ©e, mĂȘme si la page ne charge jamais.

Donc victoire, non ?
Sur le papier, ouiâŠ

Le javascript est mono-threadĂ©. Autrement dit, il nâest pas possible dâavoir plus dâun thread.
Event loop
De fait on se retrouve dans une situation similaire Ă ne pas avoir de thread du tout (mĂȘme si câest plus compliquĂ© que ça âŠ).

On ne peut pas avoir de tĂąche blocante dans notre boucle dâĂ©vĂšnement ou Event Loop.
Comme lâon ne peut pas dĂ©porter le travail dans un autre thread. Il faut que lâon trouve une autre maniĂšre de dĂ©porter la travail.
Cela tombe bien, un programme en JS ne trourne pas seul, on peut demander Ă quelquâun dâautre de faire le travail Ă notre place.
Ce quelquâun se nomme un Kernel.
Mais pour cela encore un peu dâarchitecture systĂšme pour bien comprendre.
Le navigateur internet nâest pas le seul programme qui tourne sur un ordinateur, si plusieurs programmes ont besoin en mĂȘme temps dâaccĂ©der Ă la carte rĂ©seau. Il faut un chef dâorchestre. Ce chef dâorchestre est le Kernel.
Il est le seul Ă pouvoir discuter avec les pĂ©riphĂ©riques (carte rĂ©seau, Ă©crans, imprimantes, disques, âŠ).

Â
Remarque
Le javascript ne peut pas parler directement avec la carte réseau.
- Le kernel lâen empĂȘche
- Le JS ne parle pas la langue de la carte réseau
Et en autre chose, nous pouvons gentillement lui demander dâaller chercher la page Ă notre place.
Cette demande est instannée, par contre le Kernel fera la vraie demande quand il aura le temps.
Il nous dit alors, âquand jâaurai fini, tu pourras trouver la rĂ©ponse Ă cet emplacementâ.
Ainsi le fait de demander au Kernel est synchrone et non bloquant.
Par contre le fait dâattendre est asynchrone et bloquant.

Serait-on dans la mĂȘme situation que prĂ©cĂ©demment ? Au lieu dâattendre la page, on attend maintenant le Kernel ?
Que nenni !
Nous allons placer notre tĂąche dans une boucle et lui demander de surveiller la case oĂč le rĂ©sultat se trouvera.
Tant que les donnĂ©es ne sont pas lĂ , il continue de surveiller, on dit quâon poll.
Ce poll est une opĂ©ration synchrone et suffisamment rapide pour ĂȘtre non blocante.

Lorsque finalement, les données sont enfin là .
Le Kernel, lâindique.
Lorsque la tĂąche dĂ©tecte que les donnĂ©es sont prĂȘtes.

Elle se met Ă consommer.

Et une fois que la tùche est terminée, on la retire de la boucle.

Plusieurs tĂąches asynchrones
A partir du moment oĂč lâon est capable de gĂ©rer une tĂąche, on peut en gĂ©rer plusieurs.
LâExĂ©cuteur est lâentitĂ© qui est chargĂ© de rĂ©veiller pĂ©riodiquement les tĂąches pour quâelles vĂ©rifient si ce quâelles ont demandĂ©s au Kernel est prĂȘt ou non.

LâExĂ©cuteur va alors passer sur chaque tĂąche et demander âtâas fini ?â.

Si la rĂ©ponse est nĂ©gative, lâExecuteur ne fait rien et passe au suivant.

RĂ©-itĂ©rant lâopĂ©ration.

Si la réponse est positive.

Alors lâExecuteur, consomme le rĂ©sultat de la tĂąche.

Puis désenregistre la tùche terminée.

Le cycle alors recommence.

JusquâĂ ce quâil nây ait plus rien Ă faire.

Chose intéressante. Il est possible quand le langage le permet de combiner les deux:
- les tĂąches asynchrones
- les threads
En effet, on peut trĂȘs bien avoir plusieurs exĂ©cution en simultannĂ© dans des threads diffĂ©rents.
Et donc gérer bien plus de tùches en parrallÚles.

En Javascript
Le javascript a la plus belle API dâasynchronisme. Cette APi ayant Ă©tĂ© dĂ©veloppĂ©e trĂšs tĂŽt dans la vie du langage pour rĂ©gler le problĂšme du mono-threading.
Par exemple, si lâon veut attendre 1 seconde avant de rĂ©aliser un travail.
On utilise alors la fonction setTimeout du langage, qui prend deux paramĂštres:
- la fonction a exécuter lorsque le temps est écoulé
- le nombre de millisecondes Ă attendre
setTimeout(() => {
console.log("1 seconde aprĂšs")
}, 1000);
console.log("maintenant");Cela affichera dans la console
maintenant
// une pause
1 seconde aprĂšsOn a bien le bon comportement, on fait la requĂȘte dâattendre 1 seconde, ce qui est synchrone et non bloquant. Ce qui permet de passer instannĂ©ment Ă la tĂąche suivant dâĂ©criture de âmaintenantâ.
Il est possible de créer ses propres fonctions aynchrones.
Pour cela, nous utilisons un outil du langage appélé les promesses ou Promise.
La Promise prend en argument une fonction, que lâon nomme resolver.
Lorsque le timeout est terminĂ© (qui est lui mĂȘme une Promise). La mĂ©thode resolve est appelĂ©e avec le rĂ©sultat de la promesse.
Cela a pour consĂ©quence dâappeler la mĂ©thode then qui possĂšde le rĂ©sultat en paramĂštre. Et finalement exĂ©cuter le console.log.
new Promise(resolve => {
console.log("register");
setTimeout(() => {
resolve(1)
}, 1000);
console.log("end register");
}).then(x => {
console.log(`${x} seconde aprĂšs`)
})Ici encore la console affiche instannément
register
end register
Promise {<pending>}Mais marque un temps dâattente de au moins 1 seconde avant dâafficher
1 seconde aprÚsCe qui est le résultat attendu.
Petite remarque sur le Promise {<pending>}.
Ceci est le rĂ©sultat du new Promise, câest une promesse mais pas encore rĂ©alisĂ©e dâoĂč le <pending>.
Si vous voulez un tour dâhorizon plus complet, la doc est ici. đ
Maintenant on peut également vouloir temporiser le démarrage du timeout.
const timeout = (t) => new Promise(resolve => {
setTimeout(() => {
resolve(t/1000)
}, 1000);
})Et ainsi pouvoir faire:
timeout(1000).then(x => {
console.log(`${x} seconde aprĂšs`)
});Ok, les promesses câest cool mais est ce quâon peut faire mieux ?
Oui biensûr !
Le javascript moderne introduit une syntaxe basée sur les mot clefs:
- async : définit une fonction comme asynchrone (définie une Promise)
- await : permet dâattendre la rĂ©solution dâune fonction asynchrone (comme then)
const main = async () => {
console.log("maintenant");
await timeout(1000);
console.log("1 seconde aprĂšs")
}
main();Ce qui affiche:
maintenant
Promise {<pending>}
1 seconde aprÚsPar cette technique, on est capable de rendre synchrone des tùches pour attendre des résultats asynchrones.
Mais comme tout est asynchrone en rĂ©alitĂ©, les tĂąches bloquĂ©es ne bloque pas lâensemble des tĂąches, ce qui donne une expĂ©rience dĂ©veloppeur des plus intĂ©ressntes.
Il se passent une foultitude de choses en arriÚre plan mais pour le développeur tout devient simple.
let data = await fetch("https://dummyjson.com/products/1")
.then(result => result.json());
console.log(data.id);Remarque
Il est possible de capturer les erreurs
let data = await fetch("https://dummyjson.com/products/1") .then(result => result.json()) .catch(err => console.error(err))
En Rust
Rust possÚde également son API asynchrone.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
println!("maintenant");
sleep(Duration::from_secs(1)).await;
println!("1 seconde aprĂšs");
}Lien vers le playground.
Contrairement à JS, le Rust a choisi de découpler la grammaire du langage de son utilisation.
Ainsi lâExĂ©cuteur nâest pas directement disponible. Il faut utiliser une lib qui en fourni un, ici tokio, mais il en existe dâautres.
Le but de cette partie nâest pas dâexpliquer lâasynchronisme en Rust, il aura son article dĂ©diĂ© !!
Je voudrai juste vous attirer lâattention sur la similaritĂ© dâAPI avec le JS.
async fn main() {
task.await;
}La diffĂ©rence avec le JS, câest quâau lieu dâavoir:
await task()en Rust nous faisons
task.await;Discussion sur lâasynchronisme et le threading
LâintĂ©rĂȘt principal de lâasynchronisme par rapport au multi-threading est que câest le programme qui dĂ©fini et gĂšre le cycle de vie des tĂąches.
Contrairement au multi-threading oĂč lâon est totalement dĂ©pendant du bon vouloir du systĂšme, qui peut arrĂȘter une tĂąche et la relancer quand bon lui semble.
Les threads sont adaptés à des tùches longues et peu nombreuses.
Tandis que les jobs asynchrones sont préférés pour des tùches courtes et nombreuses.
Câest au dĂ©veloppeur de penser son application en fonction des besoins Ă remplir.
Rust fourni les outils Ă la fois pour manipuler les threads mais aussi les jobs.
Ce qui en fait un langage adaptĂ© aux contraintes modernes des dĂ©veloppements dâapplications asynchrones et sur des architectures multi-coeurs.
Conclusion
En résumé.
- Rust est un langage compilĂ© ce qui lui permet de se passer dâinterprĂ©teur ou de VM, facilitant son dĂ©ploiement
- Par son aspect compilĂ©, il peut sâadapter Ă une vaste catĂ©gorie dâusages (programmation systĂšme, web, moteur 3D, WASM, âŠ.)
- Son typage trĂšs Ă©voluĂ© permet de manipuler en toute sĂ©curitĂ©. TrĂšs utile lors dâune refactorisation
- Ce typage permet de détecter trÚs tÎt dans le processus de développement de potentielles erreurs
- Lâabsence de Garbage Collector et lâanalyse statique Ă la compilation en font un langage extrĂȘment performant
- Rust a été construit pour les architectures multi-coeur modernes, et permet donc de manipuler en toute sécurité les primitives de threading et de job asynchrones. Ceci permettant de gérer la concurrence sans peur.
Par contre
- Rust est un langage exigeant qui demandera au développeur ou à la développeuse de respecter un certain nombre de rÚgles contraignantes
- Le code Rust compilĂ© est plus lourd en terme de taille sur le disque que du code C compilĂ© qui fait la mĂȘme choses
