Les raisons d'ĂȘtre de Rust

Lecture 28 min. ‱


Table des matiĂšres

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.

le CPU ne prend que du code machine

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.

Ce code machine doit ĂȘtre adaptĂ© au 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.

Code source vers code machine avec des points d'interrogations au dessus

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.

Compilation de code source

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.

Interprétation de code source

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:

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.

CPU virtuel et Virtual Machine

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.

Interprétation de la VM du byte code

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 nombre

Ici 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.

typage fort et typage faible

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.

Garbage collector

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

dynamique du GC

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.

supression des variables non-référencées

 

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.

Free explicites

Par contre cela impose une grande rigueur pour au choix:

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.

Mono-threadé

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.

Multi-threadé

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.

Alternance de 2 threads

Avec 4 threads

Alternance de 4 threads

Avec 10 threads

Alternance de 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 += 1

Le code est identique dans les deux threads.

Décomposons le un peu:

accumulateur <- charger i;
accumulateur <- accumulateur + 1;
accumulateur -> stocker i

Donc ce code:

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:

Race condition

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 :

Lock sur mémoire partagée

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 1 ne libĂšre pas la variable Ă  la fin de son exĂ©cution, le thread 2 ne pourra plus l’acquĂ©rir.

Par exemple thread 1 crash 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 !

Asynchronisme

Et du coup qu’est ce qu’on fait en attendant ? Ben rien ! On attend 


TĂąche bloquante

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.

souris bloquée

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.

exécution threadée

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, 
).

Communication kernel

 

Remarque

Le javascript ne peut pas parler directement avec la carte réseau.

  1. Le kernel l’en empĂȘche
  2. 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:

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:

setTimeout(() => {
    console.log("1 seconde aprĂšs")
}, 1000);
console.log("maintenant");

Cela affichera dans la console

maintenant
// une pause
1 seconde aprĂšs

On 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Ăšs

Ce 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:

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Ăšs

Par 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é.

Par contre