Mini introduction à l'asynchrone dans Rust par l'exemple

15 min, 2985 mots

Catégories: Rust

Tags: projet

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.

1fn 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]
9reqwest = { 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 :

1struct ResultUrl{
2 url: reqwest::Url,
3 status: reqwest::StatusCode,
4}
5
6fn 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
13fn 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 :

6fn 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
18fn 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
28fn 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]
9reqwest = { version = "0.11.10", features = ["blocking"] }
10env_logger = "0.9.0"

et au début de notre script :

6fn 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.

30async 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.

20fn 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.

20async 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]
9reqwest = "0.11.10"
10env_logger = "0.9.0"
11tokio = "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:

6fn 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.

11tokio = { version = "1.18.2", features = ["rt", "macros"] }

Et voilà ce que ça donne.

6#[tokio::main]
7async 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.

11tokio = { version = "1.18.2", features = ["rt", "macros"] }
12futures = "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 :

1use futures::stream::{FuturesUnordered, StreamExt};
2
23async 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