Aller au contenu

Async/Await en Rust

Imaginez un restaurant. Sur la page La concurrence en Rust, vous avez découvert les threads. C’est comme embaucher un serveur par table. Ça fonctionne, mais les serveurs coûtent cher, et la plupart du temps ils restent plantés là à attendre la cuisine.

L’async (programmation asynchrone), c’est comme avoir un seul serveur qui prend beaucoup de commandes. Il prend la commande de la table 1, la transmet à la cuisine et, pendant que le plat cuit, il passe à la table 2. Personne ne reste les bras croisés. C’est l’idée derrière async et .await en Rust : pendant qu’une tâche attend (le réseau, un fichier, un minuteur), une autre tâche peut s’exécuter.

L’async brille quand votre programme passe beaucoup de temps à attendre : serveurs web, clients d’API, applications de chat, bots. Cette page utilise tokio, le runtime async le plus populaire de Rust.

La bibliothèque standard de Rust vous donne les mots-clés async et .await, mais pas le « moteur » qui exécute réellement le code asynchrone. Ce moteur s’appelle un runtime (environnement d’exécution), et tokio est celui qu’utilisent la plupart des projets. Ajoutez-le à un nouveau projet :

Terminal window
cargo new async_demo
cd async_demo
cargo add tokio --features full

Cela ajoute cette ligne à votre Cargo.toml :

[dependencies]
tokio = { version = "1", features = ["full"] }

La feature full active tout (minuteurs, tâches, réseau, et plus encore). C’est le choix le plus simple pendant que vous apprenez. Les boutons « Essayez par vous-même » de cette page fonctionnent aussi : le Playground Rust intègre tokio.

use std::time::Duration;
async fn make_coffee() -> String {
tokio::time::sleep(Duration::from_millis(20)).await;
String::from("café")
}
#[tokio::main]
async fn main() {
println!("Commande en cours...");
let drink = make_coffee().await;
println!("Voici votre {}", drink);
}

Sortie :

Commande en cours...
Voici votre café
▶ Essayez par vous-même

Trois nouveautés ici :

  • async fn crée une fonction qui peut se mettre en pause en plein milieu et reprendre plus tard.
  • .await signifie « attends que ce soit fini, et pendant qu’on attend, laisse les autres tâches s’exécuter ». Vous ne pouvez utiliser .await qu’à l’intérieur d’une fonction async.
  • #[tokio::main] démarre le runtime tokio et y exécute votre async fn main. Sans lui, main ne peut pas être async.

tokio::time::sleep est la version async de « attendre un peu ». Elle ne met en pause que cette tâche, pas tout le thread.

Voici ce qui surprend le plus de monde. Appeler une async fn ne l’exécute pas. Vous obtenez à la place un future (une valeur « future ») : une valeur qui représente « du travail qui sera fait plus tard ». Rien ne se passe tant que vous ne l’avez pas attendu avec .await :

async fn say_hello() {
println!("Bonjour depuis l'intérieur du future !");
}
#[tokio::main]
async fn main() {
let future = say_hello(); // rien ne s'exécute encore
println!("Future créé, mais pas encore démarré");
future.await; // maintenant, il s'exécute
println!("Terminé");
}

Sortie :

Future créé, mais pas encore démarré
Bonjour depuis l'intérieur du future !
Terminé
▶ Essayez par vous-même

Voyez un future comme un bon de commande écrit. Écrire le bon ne cuisine pas le plat. Le donner à la cuisine (.await), si.

Jusqu’ici, nous avons attendu une seule chose à la fois. La vraie puissance vient du fait d’attendre plusieurs choses à la fois. tokio::join! exécute plusieurs futures ensemble et attend qu’ils soient tous terminés. Il vous rend leurs résultats sous forme de tuple :

use std::time::Duration;
use tokio::time::sleep;
async fn fetch_user() -> String {
sleep(Duration::from_millis(30)).await; // imaginons que c'est un appel réseau
String::from("Ana")
}
async fn fetch_order_count() -> u32 {
sleep(Duration::from_millis(20)).await;
3
}
#[tokio::main]
async fn main() {
let (user, orders) = tokio::join!(fetch_user(), fetch_order_count());
println!("{} a {} commandes", user, orders);
}

Sortie :

Ana a 3 commandes
▶ Essayez par vous-même

Si vous les attendiez l’un après l’autre, l’attente totale serait d’environ 30 + 20 = 50 ms. Avec join!, les deux attentes ont lieu en même temps : cela prend donc environ 30 ms, le temps du plus lent. Avec de vrais appels réseau, cette différence s’accumule vite.

join! est parfait quand vous avez un nombre fixe de futures. Quand vous avez une liste de travaux, utilisez tokio::spawn. Il confie un future au runtime sous forme de tâche séparée, la démarre immédiatement et vous rend un JoinHandle. Plus tard, vous faites .await sur ce handle pour obtenir le résultat :

use std::time::Duration;
use tokio::time::sleep;
async fn download(id: u64) -> String {
sleep(Duration::from_millis(50 - id * 10)).await;
format!("fichier {} téléchargé", id)
}
#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for id in 1..=3 {
let handle = tokio::spawn(download(id)); // démarre tout de suite
handles.push(handle);
}
for handle in handles {
let result = handle.await.unwrap();
println!("{}", result);
}
}

Sortie :

fichier 1 téléchargé
fichier 2 téléchargé
fichier 3 téléchargé
▶ Essayez par vous-même

Regardez bien : le fichier 3 a l’attente la plus courte, donc c’est lui qui termine en premier. Mais nous affichons les résultats dans l’ordre où nous attendons les handles, donc la sortie est toujours 1, 2, 3. Les trois téléchargements ont quand même eu lieu en même temps.

Pourquoi le .unwrap() ? Attendre un JoinHandle donne un Result, car la tâche a pu paniquer. Si c’est le cas, vous obtenez un Err au lieu de faire planter tout votre programme. Cela ressemble beaucoup à thread::spawn et .join() de la page La concurrence en Rust, et c’est voulu.

Oublier .await. Comme les futures sont paresseux, oublier .await signifie que le travail n’a jamais lieu. Si vous utilisez la valeur, le compilateur vous arrête :

async fn get_number() -> u32 {
5
}
#[tokio::main]
async fn main() {
let n = get_number(); // .await oublié
println!("{}", n + 1);
}
error[E0369]: cannot add `{integer}` to `impl Future<Output = u32>`
--> src/main.rs:8:22
|
8 | println!("{}", n + 1);
| - ^ - {integer}
| |
| impl Future<Output = u32>

Le message (en anglais) dit que n est un future d’un u32, et non un u32. La solution : let n = get_number().await;. Si la fonction ne renvoie rien et que vous l’appelez simplement, comme save_file();, vous n’obtenez qu’un avertissement (warning), qui rappelle qu’un future ne fait rien tant qu’on ne l’attend pas. Lisez donc vos warnings :

warning: unused implementer of `Future` that must be used
|
7 | save_file();
| ^^^^^^^^^^^
|
= note: futures do nothing unless you `.await` or poll them

Utiliser std::thread::sleep dans du code async. Celui-ci compile sans problème, ce qui le rend sournois :

async fn slow_task() {
std::thread::sleep(std::time::Duration::from_secs(1)); // bloque tout le thread !
}

std::thread::sleep gèle le thread entier. Le runtime ne peut plus passer à d’autres tâches, donc tout ce qui tourne sur ce thread s’arrête, comme un serveur qui s’endort debout devant une table. Dans du code async, utilisez toujours tokio::time::sleep(...).await à la place. Même chose pour les autres travaux lents et bloquants, comme lire d’énormes fichiers avec std::fs : tokio en propose des versions async (tokio::fs), ou vous pouvez utiliser tokio::task::spawn_blocking.

Croire que l’async accélère le travail du processeur. L’async aide quand les tâches attendent. Il n’aide pas quand elles sont occupées, par exemple à redimensionner des images ou à faire de gros calculs. Un serveur ne cuisine pas plus vite en prenant plus de commandes. Pour du travail lourd côté processeur, utilisez des threads (ou une crate comme rayon).

Utilisez l’async quand… Utilisez des threads quand…
La plupart du temps est passé à attendre (réseau, base de données, minuteurs) Le travail consiste en de gros calculs
Vous avez besoin de milliers de tâches à la fois (comme un serveur web) Vous n’avez besoin que de quelques traitements en parallèle
Vous utilisez des crates async comme reqwest ou axum Vous voulez éviter d’ajouter une dépendance à un runtime

Vous n’êtes pas obligé de n’en choisir qu’un. Beaucoup de vrais programmes utilisent l’async pour le réseau et des threads pour le travail lourd.

Vos tâches doivent s’échanger des messages ? Tokio a son propre canal, tokio::sync::mpsc. Il fonctionne comme celui de la bibliothèque standard présenté sur la page Les canaux (channels), sauf que vous écrivez rx.recv().await : attendre un message ne bloque donc pas le thread.

La plus grande partie de l’écosystème async de Rust repose sur tokio. Deux crates que vous verrez partout :

  • reqwest (en anglais) : un client HTTP pour appeler des API web. Vous écrivez reqwest::get(url).await pour récupérer une page.
  • axum (en anglais) : un framework web pour construire des serveurs et des API, créé par l’équipe de tokio.

Les deux s’associent très bien avec serde_json pour lire et écrire du JSON.

Écrivez trois fonctions async : make_toast (attend 30 ms), fry_eggs (attend 20 ms) et brew_coffee (attend 10 ms). Chacune renvoie une String avec le nom de l’aliment. Exécutez-les toutes les trois en même temps et affichez ce qui est prêt. Indice : tokio::join! prend plusieurs futures et rend un tuple de leurs résultats.

Voir la solution
use std::time::Duration;
use tokio::time::sleep;
async fn make_toast() -> String {
sleep(Duration::from_millis(30)).await;
String::from("des tartines")
}
async fn fry_eggs() -> String {
sleep(Duration::from_millis(20)).await;
String::from("des œufs")
}
async fn brew_coffee() -> String {
sleep(Duration::from_millis(10)).await;
String::from("un café")
}
#[tokio::main]
async fn main() {
let (toast, eggs, coffee) = tokio::join!(make_toast(), fry_eggs(), brew_coffee());
println!("Petit-déjeuner : {}, {} et {}", toast, eggs, coffee);
}

Sortie :

Petit-déjeuner : des tartines, des œufs et un café
▶ Essayez par vous-même

Le café est prêt en premier, mais le tuple revient toujours dans l’ordre où vous avez listé les futures. Le petit-déjeuner complet prend environ 30 ms (le plat le plus lent), et non 60 ms.

Écrivez async fn square_later(n: u64) -> u64, qui attend 10 * n millisecondes puis renvoie n * n. Dans main, lancez une tâche pour chaque nombre de 1 à 4, puis attendez-les toutes et affichez le total. Indice : ajoutez chaque handle de tokio::spawn(...) dans un Vec, puis bouclez sur les handles en additionnant handle.await.unwrap().

Voir la solution
use std::time::Duration;
use tokio::time::sleep;
async fn square_later(n: u64) -> u64 {
sleep(Duration::from_millis(10 * n)).await;
n * n
}
#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for n in 1..=4 {
handles.push(tokio::spawn(square_later(n)));
}
let mut total = 0;
for handle in handles {
total += handle.await.unwrap();
}
println!("total = {total}");
}

Sortie :

total = 30
▶ Essayez par vous-même

Les quatre tâches démarrent immédiatement et attendent en même temps : cela prend donc environ 40 ms, et non 10 + 20 + 30 + 40 = 100 ms.

  • L’async permet à un seul thread de gérer de nombreuses tâches en passant de l’une à l’autre pendant qu’elles attendent.
  • Rust a besoin d’un runtime pour exécuter du code async. Ajoutez tokio avec cargo add tokio --features full et marquez main avec #[tokio::main].
  • Une async fn renvoie un future. Les futures sont paresseux : rien ne s’exécute tant que vous ne faites pas .await.
  • Utilisez tokio::join! pour attendre plusieurs futures à la fois, et tokio::spawn pour lancer des tâches et obtenir un JoinHandle.
  • N’utilisez jamais std::thread::sleep dans du code async : utilisez tokio::time::sleep(...).await.
  • L’async sert à attendre, pas aux gros calculs. Pour ça, utilisez des threads.

Petit quiz

1. Que se passe-t-il quand vous appelez une async fn sans .await ?

2. Que fait #[tokio::main] ?

3. Deux futures attendent respectivement 30 ms et 20 ms. Combien de temps prend environ tokio::join! sur les deux ?

4. Pourquoi std::thread::sleep pose-t-il problème dans du code async ?

5. Pour quel travail l'async est-il le plus adapté ?

Score : 0 / 5