backend / rust
Async/await avec Tokio
Explication
Ce que vous allez apprendre
- Comprendre pourquoi l'async convient mieux que les threads pour de l'I/O massive
- Écrire une fonction
async fnet comprendre le concept de Future paresseux - Paralléliser plusieurs opérations async avec
tokio::join! - Lancer une tâche indépendante avec
tokio::spawn - Attendre la première future terminée parmi plusieurs avec
tokio::select!
Dans quel contexte ?
Un serveur doit gérer 50 000 connexions simultanées, chacune passant le plus clair de son temps à attendre une réponse réseau plutôt qu'à calculer. Lancer 50 000 threads OS pour ça saturerait la mémoire de la machine (chaque thread coûte plusieurs Mo). L'async, avec Tokio, permet de gérer ce même volume avec une poignée de threads réels, car les tâches en attente d'I/O ne consomment quasiment aucune ressource tant qu'elles n'ont rien à faire.
D'abord, il faut choisir le bon outil selon la nature du travail
Les threads (vus dans la leçon précédente) conviennent bien au calcul intensif qui utilise réellement le CPU en continu. L'async, lui, brille pour l'I/O — réseau, disque, base de données — où la majeure partie du temps est passée à attendre une réponse externe plutôt qu'à calculer activement.
async fn recuperer_donnees(id: u32) -> String ne s'exécute pas immédiatement à l'appel : elle retourne un Future, une valeur paresseuse qui décrit un calcul à faire plus tard. Rien ne se passe tant que ce Future n'est pas .awaité ou confié à un runtime comme Tokio.
Une fois ce principe de paresse compris, une différence cruciale entre séquentiel et concurrent apparaît
Enchaîner trois .await l'un après l'autre exécute les trois opérations séquentiellement (le temps total est la somme des trois). tokio::join! exécute ces mêmes opérations en parallèle (le temps total devient proche du maximum des trois, pas leur somme) — une différence de performance considérable pour des opérations d'I/O indépendantes.
| Pattern | Exécution | Temps total pour 3 opérations de 100ms |
|---|---|---|
.await séquentiels | L'un après l'autre | ~300ms |
tokio::join! | En parallèle | ~100ms |
tokio::spawn | Tâche indépendante, non bloquante | Ne bloque pas l'appelant |
Prérequis
Il faut avoir compris select sur des channels en concept (même si Rust n'a pas encore été vu avec channels sync dans ce cours) et le fonctionnement général des Futures/paresse (proche des itérateurs paresseux vus précédemment).
Il reste un point d'entrée obligatoire pour utiliser Tokio
#[tokio::main] async fn main() transforme la fonction main classique en point d'entrée d'un runtime async complet, qui orchestre l'exécution de toutes les tâches asynchrones du programme sur un pool de threads dédié.
Piège fréquent
Écrire une fonction async fn puis oublier de l'.awaiter (ou de la confier à tokio::spawn) ne fait strictement rien exécuter : le compilateur émet généralement un avertissement ("unused implementer of Future"), mais un développeur pressé peut facilement le manquer et se demander pourquoi son code "ne fait rien".
Enfin, tokio::select! généralise l'idée vue avec select en concurrence classique
tokio::select! attend la première parmi plusieurs futures à se terminer, exactement comme le select sur des channels dans d'autres modèles de concurrence. C'est le pattern standard pour implémenter un timeout sur une opération async, en combinant l'opération réelle avec un sleep en parallèle.
Bonne pratique
Ne mélange jamais du code bloquant (calcul CPU intensif, I/O synchrone) directement dans une fonction async sans passer par tokio::task::spawn_blocking : bloquer le thread d'exécution d'une tâche async empêche le runtime Tokio de progresser sur les autres tâches en attente, dégradant les performances de tout le programme.
Maintenant que tu maîtrises deux modèles de concurrence complémentaires, la prochaine leçon revient à l'organisation du code à plus grande échelle avec les modules et la structure d'un crate.
Commandes & code
Async/await avec Tokio
Pour l'I/O massivement concurrente (réseau, fichiers), l'async est plus léger que des threads OS.
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }use std::time::Duration;
use tokio::time::sleep;
// async fn retourne un Future : rien ne s'execute tant qu'il n'est pas "await"
async fn recuperer_donnees(id: u32) -> String {
sleep(Duration::from_millis(100)).await; // suspend SANS bloquer le thread
format!("donnees-{}", id)
}
// #[tokio::main] transforme main() en runtime async
#[tokio::main]
async fn main() {
let resultat = recuperer_donnees(1).await;
println!("{}", resultat);
// Sequentiel : 300ms au total (100ms x 3, l'un apres l'autre)
let debut = std::time::Instant::now();
let a = recuperer_donnees(1).await;
let b = recuperer_donnees(2).await;
let c = recuperer_donnees(3).await;
println!("{} {} {} en {:?}", a, b, c, debut.elapsed());
// Concurrent : join! execute les futures en parallele, ~100ms au total
let debut2 = std::time::Instant::now();
let (a2, b2, c2) = tokio::join!(
recuperer_donnees(4),
recuperer_donnees(5),
recuperer_donnees(6),
);
println!("{} {} {} en {:?}", a2, b2, c2, debut2.elapsed());
// spawn : lance une tache async independante sur le pool de threads Tokio
let handle = tokio::spawn(async {
sleep(Duration::from_millis(50)).await;
"tache independante terminee"
});
let resultat_spawn = handle.await.unwrap();
println!("{}", resultat_spawn);
// select! : attend la premiere future qui se termine (comme select en Go)
tokio::select! {
res = recuperer_donnees(7) => println!("recu en premier: {}", res),
_ = sleep(Duration::from_millis(200)) => println!("timeout"),
}
// Un nombre eleve de taches async concurrentes (bien plus leger que des threads OS)
let mut taches = vec![];
for i in 0..100 {
taches.push(tokio::spawn(async move {
sleep(Duration::from_millis(10)).await;
i * i
}));
}
let mut total = 0;
for t in taches {
total += t.await.unwrap();
}
println!("somme des carres: {}", total);
}Résumé
async fnproduit unFutureparesseux : rien ne s'exécute avant.await.tokio::join!parallélise plusieurs futures sur un seul thread (concurrence coopérative, pas parallélisme forcé).tokio::spawnrépartit une tâche sur le pool de threads du runtime Tokio.- Des dizaines de milliers de tâches async coexistent bien plus légèrement que le même nombre de threads OS.
Exercices pratiques
Mission : une API qui met 900ms a repondre alors qu'elle pourrait en mettre 300
Objectif : Diagnostiquer un enchainement sequentiel de futures independantes et le paralleliser, puis choisir entre async et threads selon la nature du travail.
Contexte
Un endpoint d'API doit recuperer, pour un utilisateur donne, son profil, ses commandes recentes et ses notifications, via trois appels a des services distants qui prennent chacun environ 300ms. Un dev a ecrit :
async fn charger_dashboard(id: u32) -> (String, String, String) {
let profil = recuperer_profil(id).await;
let commandes = recuperer_commandes(id).await;
let notifs = recuperer_notifications(id).await;
(profil, commandes, notifs)
}Le monitoring montre que cet endpoint met systematiquement environ 900ms a repondre, ce qui degrade l'experience utilisateur. Les trois appels sont totalement independants les uns des autres (aucun n'a besoin du resultat d'un autre).