Programmation asynchrone#
Le chapitre 19 a introduit les threads du système d’exploitation pour exécuter du code en parallèle. Les threads conviennent bien aux tâches liées au processeur (CPU-bound), mais ils imposent un coût non négligeable : chaque thread possède sa propre pile (souvent plusieurs megaoctets), et les changements de contexte sollicitent le noyau. Lorsqu’un programme doit gérer des centaines, voire des milliers d’opérations liées aux entrées-sorties (I/O-bound) — requêtes HTTP, accès disque, connexions réseau — la création d’un thread par opération devient rapidement impraticable. La programmation asynchrone offre une alternative : elle permet à un petit nombre de threads de multiplexer un grand nombre de tâches, en suspendant celles qui attendent une ressource externe et en reprenant leur exécution dès que la ressource est disponible. Rust intègre cette capacité directement dans le langage via les mots-clés async et await, tout en déléguant l’ordonnancement à un runtime externe.
Motivation : I/O-bound contre CPU-bound#
Définition 119 (Tâche I/O-bound et CPU-bound)
Une tâche est dite I/O-bound lorsque son temps d’exécution est dominé par l’attente de ressources externes (réseau, fichier, base de données). Elle est dite CPU-bound lorsque le temps est dominé par le calcul pur (compression, rendu, cryptographie).
Pour les tâches CPU-bound, les threads système (chapitre 19) restent le mécanisme le plus direct : chaque thread exploite un coeur du processeur. Pour les tâches I/O-bound, en revanche, un thread qui attend une réponse réseau gaspille de la mémoire sans effectuer de travail utile. La programmation asynchrone résout ce problème : au lieu de bloquer un thread entier, la tache cède le controle au runtime, qui peut alors exécuter une autre tâche sur le même thread.
Remarque 97
L’asynchronisme ne remplace pas les threads : les deux approches sont complémentaires. Pour des charges mixtes, il est courant de combiner un runtime asynchrone (pour les E/S) avec un pool de threads (pour le calcul). Le crate tokio fournit notamment tokio::task::spawn_blocking pour envoyer du travail CPU-bound vers un thread dédié sans bloquer le runtime.
async / await#
```{prf:definition} Fonction asynchrone et .await
:label: def-async-await
Le mot-clé async transforme une fonction ou un bloc en un future — une valeur qui représente un calcul dont le résultat n’est pas encore disponible. L’opérateur .await suspend l’exécution de la tâche courante jusqu’à ce que le future produise sa valeur. Une fonction déclarée async fn f() -> T renvoie en réalité un type anonyme implémentant Future<Output = T>.
// Déclaration d'une fonction asynchrone
async fn recuperer_donnees(url: &str) -> String {
// Simulons une requête réseau
let reponse = reqwest::get(url).await.unwrap();
reponse.text().await.unwrap()
}
// Appel dans un contexte asynchrone
async fn traiter() {
let contenu = recuperer_donnees("https://example.com").await;
println!("Reçu : {} octets", contenu.len());
}
Proposition 36 (Paresse des futures)
En Rust, les futures sont paresseux (lazy) : appeler une fonction async ne démarre pas son exécution. Le future retourné est inerte tant qu’il n’est pas passé à un exécuteur (runtime) ou attendu avec .await. Ce comportement diffère de langages comme JavaScript ou Python où les tâches démarrent immédiatement.
Exemple 108 (Bloc async)
Un bloc async { ... } crée un future anonyme en ligne, utile pour capturer des variables locales sans définir une fonction séparée.
async fn exemple_bloc() {
let prefixe = String::from("Résultat");
let futur = async {
// Le bloc capture `prefixe` par référence
format!("{} : {}", prefixe, 42)
};
let valeur = futur.await;
println!("{}", valeur);
}
Le trait Future#
Le trait Future, defini dans la bibliothèque standard, est le fondement du système asynchrone de Rust. Toute valeur produite par async implémente ce trait (chapitre 13 pour les traits en général).
Définition 120 (Trait Future)
Le trait std::future::Future est défini comme suit :
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
La methode poll est appelée par le runtime pour faire progresser le future. Elle renvoie :
Poll::Ready(valeur)si le calcul est terminé ;Poll::Pendingsi le calcul n’est pas encore achevé.
Définition 121 (Pin<T>)
Pin<&mut Self> garantit que le future ne sera pas deplacé en mémoire entre deux appels à poll. Cette contrainte est nécessaire car les futures générés par le compilateur peuvent contenir des auto-références — des pointeurs internes vers leurs propres champs. Déplacer une telle structure invaliderait ces références. Le type Pin (chapitre 18 pour les pointeurs intelligents) encode cette garantie dans le système de types.
Remarque 98
En pratique, on n’implémente presque jamais Future manuellement. Le compilateur transforme chaque fonction async en une machine à états qui implémente Future. L’opérateur .await correspond aux points de suspension de cette machine. Le runtime se charge d’appeler poll au bon moment, guidé par un système de notifications (wakers) : lorsqu’une ressource externe est prête, le waker signale au runtime de rappeler poll sur le future concerné.
Runtimes asynchrones#
Définition 122 (Runtime asynchrone)
Un runtime asynchrone (ou exécuteur) est le composant qui orchestre l’exécution des futures. Il gère la boucle d’événements, appelle poll sur les futures en attente, et réagit aux notifications des wakers. Contrairement à d’autres langages (Go, JavaScript), Rust n’inclut pas de runtime dans la bibliothèque standard : le langage fournit les primitives (async, await, Future), et l’écosystème fournit les exécuteurs.
Proposition 37 (Séparation langage / runtime)
La décision de ne pas intégrer de runtime dans std est délibérée. Elle permet :
de choisir un exécuteur adapté au contexte (serveur, système embarqué, WebAssembly) ;
d’eviter d’imposer un modèle d’exécution unique ;
de maintenir un
no_stdminimal pour les environnements contraints.
Les deux runtimes les plus utilisés dans l’écosystème sont :
Runtime |
Crate |
Caractéristiques principales |
|---|---|---|
Tokio |
|
Multi-thread, le plus répandu, écosystème riche |
async-std |
|
API proche de |
Remarque 99
Dans la suite de ce chapitre, nous utilisons Tokio, qui est le runtime de référence dans l’écosystème Rust. La plupart des crates asynchrones (reqwest, sqlx, tonic, axum) sont conçus pour fonctionner avec Tokio.
Tokio#
Configuration#
Pour utiliser Tokio, on ajoute la dépendance dans Cargo.toml :
[dependencies]
tokio = { version = "1", features = ["full"] }
La feature "full" active toutes les fonctionnalités (runtime multi-thread, E/S réseau, temporisateurs, etc.).
#[tokio::main]#
Définition 123 (Macro tokio::main)
La macro attribut #[tokio::main] transforme une fonction main asynchrone en une fonction synchrone qui initialise le runtime Tokio et exécute le future correspondant.
#[tokio::main]
async fn main() {
println!("Bonjour depuis Tokio !");
let resultat = calcul_async().await;
println!("Résultat : {}", resultat);
}
async fn calcul_async() -> i32 {
// Simule un délai d'attente
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
42
}
Remarque 100
La macro #[tokio::main] est équivalente à :
fn main() {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async {
// corps de la fonction async main
});
}
Cette transformation rend explicite le fait que le runtime est créé à l’entrée du programme.
tokio::spawn#
Définition 124 (Tâche asynchrone)
La fonction tokio::spawn lance un future en tant que tâche (task) indépendante sur le runtime. Elle retourne un JoinHandle<T> que l’on peut .await pour obtenir le résultat. Les tâches sont l’équivalent asynchrone des threads (chapitre 19) : elles s’exécutent de manière concurrente, potentiellement sur différents threads du pool de Tokio.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let tache1 = tokio::spawn(async {
sleep(Duration::from_millis(200)).await;
"tâche 1 terminée"
});
let tache2 = tokio::spawn(async {
sleep(Duration::from_millis(100)).await;
"tâche 2 terminée"
});
// Les deux tâches s'exécutent en concurrence
let r1 = tache1.await.unwrap();
let r2 = tache2.await.unwrap();
println!("{}, {}", r1, r2);
}
Proposition 38 (Contrainte “static sur les tâches)
Le future passé à tokio::spawn doit satisfaire la borne 'static : il ne peut pas emprunter de données à la tâche parente. Cette contrainte existe parce que la tâche peut survivre à la portée qui l’a créée. Pour partager des données, on utilise Arc (chapitre 18) ou le passage par valeur via move sur les fermetures (chapitre 17).
join! et select!#
Execution concurrente avec join!#
Définition 125 (Macro join!)
La macro tokio::join! attend tous les futures passés en argument de manière concurrente, et renvoie un tuple de leurs résultats. Contrairement à l’attente séquentielle (.await l’un après l’autre), join! permet aux futures de progresser simultanément.
use tokio::time::{sleep, Duration};
async fn requete_utilisateur() -> String {
sleep(Duration::from_millis(150)).await;
String::from("Alice")
}
async fn requete_commandes() -> Vec<String> {
sleep(Duration::from_millis(200)).await;
vec![String::from("commande-001"), String::from("commande-002")]
}
#[tokio::main]
async fn main() {
// Les deux requêtes s'exécutent en concurrence (~200ms au total, pas 350ms)
let (utilisateur, commandes) = tokio::join!(
requete_utilisateur(),
requete_commandes()
);
println!("Utilisateur : {}", utilisateur);
println!("Commandes : {:?}", commandes);
}
Course entre futures avec select!#
Définition 126 (Macro select!)
La macro tokio::select! attend le premier future qui se termine parmi plusieurs branches, et exécute le code associé. Les autres futures sont abandonnés. Cette macro est utile pour implémenter des délais d’attente (timeouts), des annulations, ou pour réagir au premier évènement disponible.
use tokio::time::{sleep, Duration};
async fn operation_lente() -> &'static str {
sleep(Duration::from_secs(10)).await;
"resultat"
}
#[tokio::main]
async fn main() {
tokio::select! {
resultat = operation_lente() => {
println!("Opération terminée : {}", resultat);
}
_ = sleep(Duration::from_secs(2)) => {
println!("Délai dépassé, opération annulée");
}
}
}
Remarque 101
Lorsqu’une branche de select! est choisie, les futures des autres branches sont abandonnés (dropped). Le code asynchrone doit donc être conçu pour supporter l’annulation à tout point de suspension (.await). En particulier, les ressources protégées par des verrous asynchrones (tokio::sync::Mutex) doivent être libérées correctement même en cas d’annulation.
Streams : itération asynchrone#
Définition 127 (Stream)
Un stream est l’équivalent asynchrone d’un itérateur (chapitre 16). Alors qu’un Iterator produit des valeurs de manière synchrone via next(), un Stream produit des valeurs de manière asynchrone : chaque appel à next() retourne un future qui résoudra en Some(valeur) ou None.
Le trait Stream est défini dans le crate futures (et réexporte par tokio-stream) :
pub trait Stream {
type Item;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>>;
}
Exemple 109 (Utilisation d’un stream avec tokio)
Le crate tokio-stream fournit des adaptateurs pour créer et transformer des streams. L’exemple suivant illustre un stream qui produit des valeurs à intervalles réguliers.
use tokio_stream::StreamExt; // pour la méthode next()
use tokio::time::{interval, Duration};
use tokio_stream::wrappers::IntervalStream;
#[tokio::main]
async fn main() {
let flux = IntervalStream::new(interval(Duration::from_millis(500)));
// On prend les 5 premières valeurs du stream
let mut flux = flux.take(5);
let mut compteur = 0;
while let Some(_instant) = flux.next().await {
compteur += 1;
println!("Tick {}", compteur);
}
println!("Stream terminé");
}
Remarque 102
Le trait Stream n’est pas encore dans la bibliothèque standard (à la date de rédaction), bien qu’il soit en discussion. En attendant, les crates futures et tokio-stream en fournissent une implémentation stable. Les adaptateurs (map, filter, take, merge, etc.) rappellent ceux des itérateurs synchrones, offrant une interface familière pour le traitement de données asynchrone.
Résumé#
Le tableau suivant récapitule les éléments clés de la programmation asynchrone en Rust :
Concept |
Rôle |
|---|---|
|
Crée un future paresseux |
|
Suspend la tâche et attend le résultat du future |
|
Trait central, methode |
|
Empêche le déplacement d’un future auto-référent |
Runtime (Tokio) |
Orchestre l’exécution des futures |
|
Lance une tâche concurrente |
|
Attend tous les futures en concurrence |
|
Attend le premier future terminé |
|
Itérateur asynchrone |
La programmation asynchrone en Rust combine la sécurité mémoire garantie par le compilateur (possession, emprunts, durées de vie) avec un modèle de concurrence performant et sans ramasse-miettes. Le coût d’abstraction est nul à l’exécution (zero-cost abstraction) : le compilateur transforme le code async/await en machines à états optimisées, sans allocations cachées. Ce modèle exige un apprentissage initial — comprendre les futures, le Pin, le rôle du runtime — mais il offre en retour un contrôle fin sur la concurrence, la composition et l’annulation des tâches asynchrones.