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.
Installer tokio
Section intitulée « Installer tokio »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 :
cargo new async_democd async_democargo add tokio --features fullCela 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.
Votre premier programme async
Section intitulée « Votre premier programme async »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éTrois nouveautés ici :
async fncrée une fonction qui peut se mettre en pause en plein milieu et reprendre plus tard..awaitsignifie « attends que ce soit fini, et pendant qu’on attend, laisse les autres tâches s’exécuter ». Vous ne pouvez utiliser.awaitqu’à l’intérieur d’une fonctionasync.#[tokio::main]démarre le runtime tokio et y exécute votreasync fn main. Sans lui,mainne peut pas êtreasync.
tokio::time::sleep est la version async de « attendre un peu ». Elle ne met en pause que cette tâche, pas tout le thread.
Les futures sont paresseux
Section intitulée « Les futures sont paresseux »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éVoyez un future comme un bon de commande écrit. Écrire le bon ne cuisine pas le plat. Le donner à la cuisine (.await), si.
Exécuter des tâches en même temps avec join!
Section intitulée « Exécuter des tâches en même temps avec join! »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 commandesSi 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.
Lancer des tâches avec tokio::spawn
Section intitulée « Lancer des tâches avec tokio::spawn »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é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.
Erreurs courantes
Section intitulée « Erreurs courantes »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 themUtiliser 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).
Async ou threads ?
Section intitulée « Async ou threads ? »| 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.
Ce qu’on construit avec
Section intitulée « Ce qu’on construit avec »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).awaitpour 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.
Exercices
Section intitulée « Exercices »1. Préparer le petit-déjeuner d’un coup
Section intitulée « 1. Préparer le petit-déjeuner d’un coup »É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é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.
2. Calculer des carrés en arrière-plan
Section intitulée « 2. Calculer des carrés en arrière-plan »É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 = 30Les 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.
Récapitulatif
Section intitulée « Récapitulatif »- 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 fullet marquezmainavec#[tokio::main]. - Une
async fnrenvoie 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, ettokio::spawnpour lancer des tâches et obtenir unJoinHandle. - N’utilisez jamais
std::thread::sleepdans du code async : utiliseztokio::time::sleep(...).await. - L’async sert à attendre, pas aux gros calculs. Pour ça, utilisez des threads.
Petit quiz
Section intitulée « Petit quiz »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
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Fundamentals of Asynchronous Programming — The Rust Book (en anglais) : le chapitre officiel sur les futures,
async,.awaitet les streams. - Le tutoriel tokio (en anglais) : un guide pratique qui construit un petit serveur façon Redis, et couvre les tâches, l’état partagé et les canaux.
- tokio sur docs.rs (en anglais) : la référence de l’API pour
tokio::spawn,tokio::time,tokio::syncet le reste. - Asynchronous Programming in Rust (le livre sur l’async) (en anglais) : un regard plus approfondi sur le fonctionnement interne des futures et des exécuteurs.