Mini introduction à l'asynchrone dans Rust par l'exemple
Je me suis mis à l’apprentissage de Rust. Un langage qui m’attire pour plusieurs raisons dont son côté bas niveau et son fort typage. Et je dois dire que pour le moment, ça me plait beaucoup. Dans le cadre de mon apprentissage, j’ai décidé d’écrire des articles sur ce que j’ai appris. Ne dit-on pas : “la meilleure manière d’apprendre c’est d’enseigner” ?
Alors je n’irai pas jusqu’à dire que je vais enseigner dans ces articles, mais je vais au moins essayer de synthétiser et de transmettre par écrit ce que j’ai appris sur ce language.
Alors pour ce premier article, je vais expliquer très simplement le fonctionnement de l’asynchrone dans Rust.
Pour cela, on va partir sur un petit projet très simple. Faire un script qui va interroger plusieurs urls pour vérifier qu’ils sont bien fonctionnels.
Définitions
L’asynchrone, c’est le fait de ne pas executer une tâche strictement dans l’ordre donnée. L’asynchrone touche à plusieurs notions. Deux d’entre elles sont la simultanéité (concurrency) et le parallélisme (parallelism). Parfois confondues, elles sont toutefois différentes.
Prenons l’exemple de la préparation d’un gâteau. Voici la recette :
- Préparer la pâte (25min)
- Préchauffer le four (15min)
- Cuire dans le four (20min)
Si l’on respecte de façon stricte la recette, on pourra sortir le gâteau du four au bout de 1h.
Mais j’imagine qu’en réalité, personne ne prendrait 1h pour faire ce gâteau. Tout simplement parce qu’on intègre naturellement la notion de simultanéité dans nos tâches, c’est-à-dire qu’on va commencer une autre tâche durant les temps d’attente.
Ce qu’on va faire c’est :
- Mettre à préchauffer le four. (0min)
- Sans attendre qu’il soit préchauffé, on va commencer à préparer la pâte. (25min)
- Le four étant préchauffé, on peut mettre à cuire le gâteau (20min)
Nous venons de réduire le temps de préparation du gâteau de 60 minutes à 45 minutes ! Tout cela parce que nous avons executé d’autres tâches durant les temps d’attente lorsque cela était possible. Il s’agit de la simultanéité (concurrency).
Mais on pourrait faire encore mieux. Demander de l’aide à notre enfant. Au lieu de travailler seul, on travaille à deux. Cela va avoir un impact sur notre préparation de la pâte. Au lieu de mettre 25 min, on pourrait mettre environ 13min. On finirait alors la préparation de la pâte avant que le four soit préchauffé. Il faudrait alors attentre encore 2min avant de pouvoir cuire le gâteau.
En travaillant à deux en parallèle, on passe de 45 minutes à 35 minutes de préparation.
Pour résumer :
Si vous alternez entre deux tâches, alors vous travaillez sur les deux tâches simultanément mais pas en parallèle. Pour que l’on puisse parler de parallélisme, il faudrait deux personnes, une dédiée à chaque tâche.
En informatique, un processus ne fait qu’une tâche à la fois. Il peut donc executer des tâches simultanément mais pas en parallèle. Pour executer des tâches en parallèle, il va alors falloir utiliser plusieurs processus. Dans notre cas, nous allons uniquement voir la notion de simultanéité. Je garde la notion de parallélisme pour un prochain article.
La base du projet
Voici le code de départ.
1 fn main() {
2 let url = "https://pierre-galvez.fr";
3
4 let req = reqwest::blocking::get(url).expect("Erreur dans la requete");
5 let status = req.status();
6
7 println!("Le status de la requete \"{}\" est : {}", url, status);
8 }
On a là un simple script qui va interroger l’url https://pierre-galvez.fr et va afficher le statut de la réponse. Pour le moment, pas d’asynchrone.
Pour effectuer la requête, on utilise la crate Reqwest qui est un client HTTP assez simple. Dans cette base, on utilise le module blocking qui permet de faire une requête synchrone, c’est-à-dire que le script va attendre la réponse avant de continuer.
Pour que ça fonctionne, il ne faut pas oublier de charger la crate avec son module en mettant dans cargo.toml :
8 [dependencies]
9 reqwest = { version = "0.11.10", features = ["blocking"] }
Ce qu’on peut faire tout de suite, c’est organiser un peu mieux notre code pour éviter qu’il ne devienne illisible par la suite. Pour cela, on va sortir la requête de la fonction main
pour la mettre dans une fonction que l’on appelera request_url
et qui prendra en fonction l’url souhaité. On obtient donc :
1 struct ResultUrl{
2 url: reqwest::Url,
3 status: reqwest::StatusCode,
4 }
5
6 fn main() {
7 let url = "https://pierre-galvez.fr";
8 let result = request_url(url);
9
10 println!("Le status de la requete \"{}\" est : {}", result.url, result.status);
11 }
12
13 fn request_url(url: &str) -> ResultUrl {
14 let req = reqwest::blocking::get(url).expect("Erreur dans la requete");
15 let status = req.status();
16 let url = req.url();
17
18 ResultUrl {
19 url: url.clone(),
20 status: status
21 }
22 }
On peut maintenant facilement vérifier plusieurs urls de cette façon :
6 fn main() {
7 let urls = vec![
8 "https://pierre-galvez.fr",
9 "https://docs.rs",
10 "https://www.rust-lang.org",
11 "https://www.getzola.org",
12 "https://jimskapt.github.io/rust-book-fr/",
13 ];
14
15 check_urls(urls);
16 }
17
18 fn check_urls(urls: Vec<&str>) {
19 for url in urls {
20 let result = request_url(url);
21 println!(
22 "Le status de la requete \"{}\" est : {}",
23 result.url, result.status
24 );
25 }
26 }
27
28 fn request_url(url: &str) -> ResultUrl {
29 let req = reqwest::blocking::get(url).expect("Erreur dans la requete");
30 let status = req.status();
31 let url = req.url();
32
Pour pouvoir comparer par la suite les performances de notre script, j’exécute le script précédé de la commande time
. Actuellement, nous sommes à une moyenne de 2,9294s.
Avant de continuer, on va simplement ajouter de quoi afficher les logs pour voir un peu mieux ce qu’il se passe. Pour cela on va simplement ajouter la crate env_logger :
8 [dependencies]
9 reqwest = { version = "0.11.10", features = ["blocking"] }
10 env_logger = "0.9.0"
et au début de notre script :
6 fn main() {
7 env_logger::init();
Puis au lieu de lancer la commande cargo run
, il faut lancer RUST_LOG=debug cargo run
On peut voir maintenant 2 lignes de logs supplémentaires provenant de la crate reqwest.
DEBUG reqwest::connect starting new connection: https://pierre-galvez.fr
DEBUG reqwest::async_impl::client response ‘200 OK’ for https://pierre-galvez.fr
On voit bien qu’on est dans du synchrone. Chaque requête est suivi de sa réponse avant de passer à la requête suivante.
Si l’on regarde bien, on peut voir que la requête qui est effectuée est faite avec le module async_impl et non blocking comme on l’a demandé. La raison est que reqwest, de base, utilise de l’asynchrone. Le module blocking va donc lancer une requête asynchrone mais va la bloquer pour nous, en attendant la réponse.
L’asynchrone: les bases
C’est donc le moment de parler de l’asynchrone dans Rust.
Si vous lancez le script, voilà en partie ce qu’il se passe avec notre fonction get
de Reqwest :
- Une connexion TCP est ouverte avec le serveur correspondant à l’URL fournie
- La requête est envoyée sous forme de requête HTTP
- On attend le résultat
- Le résultat est retourné, le script peut continuer.
Cette opération peut nous sembler courte lorsque le script est lancé. Mais en réalité, les temps d’attente à chaque étape décrite ci-dessus sont très longs pour un processus. En effet, ce temps d’attente où le processus ne fait rien est un temps qu’il pourrait passer à faire énormément de choses.
L’idée de l’asynchrone c’est de faire en sorte qu’à chaque fois qu’il y a un temps d’attente, le processus fasse autre chose dans notre script.
Dans notre cas, ce que l’on peut faire c’est faire en sorte que lorsque le processus lance un get
sur une url et attend, qu’il puisse déjà commencer à lancer un autre get
. Ce qui permettrait de gagner du temps.
Comme on l’a dit, par défaut reqwest lance une requête asynchrone. Notre module blocking force l’attente de la réponse. On va l’enlever.
30 async fn request_url(url: &str) -> ResultUrl {
31 let req = reqwest::get(url).await.expect("Erreur dans la requete");
32 let status = req.status();
33 let url = req.url();
34
35 ResultUrl {
36 url: url.clone(),
37 status: status,
38 }
39 }
Nous avons ajouté 2 choses. La première, c’est le label async
devant notre fonction check_url
.
Async transforme la fonction en une fonction asynchrone. Le retour de cette fonction devient donc un trait Future.
Future est un type de données qui ne représente pas une valeur mais la capacité de produire une valeur à un moment donné dans le futur.
Ce Future peut être en quelque sorte dans un état “en cours” ou un état “fini”. Pour lancer l’exécution de cette Future il faut l’appeler spécifiquement avec la commande await
. Ce qui est utile pour nous. On va lancer toutes les request_url que l’on souhaite et récupérer les résultats plus tard, lorsque cela nous sera utile. On est donc bien dans un mode asynchrone.
La deuxième chose que l’on a ajouté, c’est la commande await
après la fonction get
. En fait, la fonction get
est elle-même asynchrone et nous renvoit donc une Future. Si l’on poursuit notre script, on fait un req.status()
. Sans le label async
, cela planterait car on exécuterait ça non pas sur la réponse de la requête mais sur la Future. Appeler la commande await
c’est simplement dire : Je veux lancer la fonction get
et attendre que la fonction soit finie, que la Future passe dans l’état “fini”, avant de poursuivre le script avec la réponse.
Notre fonction check_url
est donc maintenant asynchrone et renvoie une Future qui est dans un premier temps dans l’état “en cours” et passera automatiquement dans l’état “fini” lorsque la fonction get
sera finie et que le reste de la fonction aura été executé.
Si on lance le script tel quel, ça va planter.
20 fn check_urls(urls: Vec<&str>) {
21 for url in urls {
22 let result = request_url(url);
23 println!(
24 "Le status de la requete \"{}\" est : {}",
25 result.url, result.status
26 );
27 }
28 }
En effet, dans la fonction check_urls
, ligne 22, request_url
ne renvoit plus ResultUrl
(qui est utilisé pour la suite) mais une Future comme on l’a dit. Du coup, là aussi il va falloir ajouter la commande await
ainsi que le label async
à la fonction, car elle est maintenant asynchrone.
20 async fn check_urls(urls: Vec<&str>) {
21 for url in urls {
22 let result = request_url(url).await;
23 println!(
24 "Le status de la requete \"{}\" est : {}",
25 result.url, result.status
26 );
27 }
28 }
Si on lance le script tel quel, il ne va rien se passer. On va même avoir un message d’alerte qui nous informe que nous executons la fonction check_url
qui renvoie une Future. Sauf que cette Future, comme on l’a dit, n’est jamais executé, car nous n’avons pas appelé la commande await
dessus. Le script se finit sans avoir exécuter les requêtes.
Pour traiter ces fonctions asynchrones, pour gérer la simultanéité de façon cohérente… il faut un composant appelé runtime. Il en existe plusieurs. Pour notre projet, on va utiliser Tokio qui est surement le runtime le plus utilisé.
Chargeons la crate Tokio.
8 [dependencies]
9 reqwest = "0.11.10"
10 env_logger = "0.9.0"
11 tokio = "1.18.2"
On va maintenant pouvoir bloquer le script en attendant que la Future renvoyée par check_urls
soit terminée. Pour cela, on ajoute simplement les lignes 8 et 18:
6 fn main() {
7 env_logger::init();
8 let rt = tokio::runtime::Runtime::new().unwrap();
9
10 let urls = vec![
11 "https://pierre-galvez.fr",
12 "https://docs.rs",
13 "https://www.rust-lang.org",
14 "https://www.getzola.org",
15 "https://jimskapt.github.io/rust-book-fr/",
16 ];
17
18 rt.block_on(check_urls(urls));
19 }
Cette fonctionnalité sur la fonction main
étant très utilisée, la crate propose un raccourci qui fait exactement la même chose. Pour cela, il va juste falloir charger deux features à la crate.
11 tokio = { version = "1.18.2", features = ["rt", "macros"] }
Et voilà ce que ça donne.
6 #[tokio::main]
7 async fn main() {
8 env_logger::init();
9
10 let urls = vec![
11 "https://pierre-galvez.fr",
12 "https://docs.rs",
13 "https://www.rust-lang.org",
14 "https://www.getzola.org",
15 "https://jimskapt.github.io/rust-book-fr/",
16 ];
17
18 check_urls(urls).await;
19 }
On retrouve nos await
et async
dans la fonction main
. Si on lance le script, ça fonctionne. Par contre, on n’a pas gagné en temps d’exécution.
L’asynchrone: FuturesUnordered
Le problème vient du fait que, même si l’on est en asynchrone, nous attendons systématiquement le retour de request_url
dans notre boucle for
de la fonction check_urls
. Ce qu’on a fait là, c’est finalement faire du synchrone déguisé dans un habillage d’asynchrone.
Pour remédier à cela, il va falloir que la boucle for
s’exécute sans attendre la réponse de la Future récupérée.
Pour cela, plusieurs façons de faire. Pour ma part, je vais utiliser la structure FuturesUnordered fournie par la crate Futures, qui est une crate elle aussi très utilisée pour l’asynchrone, fournissant plus de fonctionnalités utiles.
11 tokio = { version = "1.18.2", features = ["rt", "macros"] }
12 futures = "0.3.21"
FuturesUnordered est une Future qui contient elle-même plusieurs Future. Plutôt que d’attendre le retour de chaque Future comme on le fait, on va pouvoir mettre toutes nos Future dans une seule et simplement attendre que celle-ci soit finie pour continuer. De plus, on va pouvoir récupérer les résultats des requêtes au fur et à mesure qu’ils arrivent.
Voilà ce que ça donne :
1 use futures::stream::{FuturesUnordered, StreamExt};
2
23 async fn check_urls(urls: Vec<&str>) {
24 let mut stream = FuturesUnordered::new();
25
26 for url in urls {
27 stream.push(request_url(url));
28 }
29
30 while let Some(request) = stream.next().await {
31 println!(
32 "Le status de la requete \"{}\" est : {}",
33 request.url, request.status
34 );
35 }
36 }
Si on lance notre script, on voit bien que les résultats n’arrivent pas forcément dans l’ordre de nos urls fournis. Mais ce qui est le plus marquant, c’est le temps d’exécution. Nous sommes passés d’une moyenne de 2,9294s à une moyenne de 0,841s, ce qui fait un gain d’environ 70% et ce, juste pour 5 urls.
Conclusion
Nous avons donc là un mini programme qui gère parfaitement l’asynchrone. Dans la mesure où notre programme contient principalement du temps d’attente (attente du retour des requêtes) et très peu de traitement par le programme lui-même, il n’est pas nécessaire d’utiliser le parallélisme.
Maintenant, imaginez que l’on souhaite faire un traitement sur les retours des requêtes, alors il serait pertinent d’y inclure du parallélisme. On pourrait alors dans un processus faire nos requêtes (notre programme actuel) et en parallèle, faire le traitement souhaité au fur et à mesure que les réponses arrivent. Peut-être que ce sera le contenu de notre prochain article.
Pour aller plus loin
- La traduction française du livre Asynchronous Programming in Rust
- Le chapitre Async concepts using async-std de la documentation du runtime Async-std, alternative à Tokio. Vraiment excellent mais en anglais.