# Async/Await en Rust : Tokio, Futures et concurrence asynchrone en détail > Plongée approfondie dans async/await en Rust : le runtime Tokio, le trait Future, le lancement de tâches, la concurrence structurée et les patterns concrets pour construire des applications asynchrones performantes. - Published: 2026-04-30 - Updated: 2026-04-30 - Author: SharpSkill - Tags: rust, async, tokio, futures, concurrency - Reading time: 12 min --- Le système async/await de Rust offre une programmation asynchrone à coût zéro, mais contrairement à des langages comme JavaScript ou Python, Rust ne fournit pas de runtime intégré. Le trait `Future`, les mots-clés `async`/`await` et un exécuteur externe comme Tokio forment un système à trois couches qui donne un contrôle total sur la façon dont le travail concurrent est planifié et exécuté. > **Concept clé** > > Une `async fn` en Rust renvoie une machine à états qui implémente `Future`. Rien ne s'exécute tant qu'un exécuteur (comme Tokio) ne sonde pas cette future. Ce modèle d'évaluation paresseuse élimine les allocations cachées et fournit au compilateur suffisamment d'informations pour optimiser de manière agressive. ## En quoi les Futures Rust diffèrent des autres langages En JavaScript, appeler une fonction `async` lance immédiatement l'exécution et renvoie une `Promise`. En Rust, appeler une `async fn` ne fait rien — cela construit une valeur `Future` qui reste inactive jusqu'à ce qu'elle soit sondée. Cette distinction a des conséquences pratiques majeures. Le trait `Future` définit une seule méthode : ```rust // core::future::Future trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll; } ``` `Poll::Ready(value)` signale l'achèvement. `Poll::Pending` indique à l'exécuteur de garer la tâche et de la réveiller plus tard via le `Waker` stocké dans `Context`. L'exécuteur ne fait jamais d'attente active — il repose sur des mécanismes au niveau de l'OS (epoll sous Linux, kqueue sous macOS) pour savoir quand une opération d'I/O est prête. Ce modèle basé sur le sondage signifie que les futures Rust sont des machines à états compilées au moment de la construction, et non des chaînes de callbacks allouées sur le tas. Le compilateur transforme chaque point `.await` en un variant d'état, produisant du code qui rivalise avec des machines à états écrites manuellement en termes de performance. ## Configurer Tokio comme runtime asynchrone Tokio est le runtime asynchrone le plus largement adopté dans l'écosystème Rust. En 2026, Tokio 2.0 a introduit un ordonnanceur amélioré avec vol de travail et une roue de temporisation hiérarchique qui réduisent la surcharge d'environ 40 % par rapport aux versions antérieures. Une configuration minimale nécessite deux éléments dans `Cargo.toml` : ```toml # Cargo.toml [dependencies] tokio = { version = "2", features = ["rt-multi-thread", "macros", "net", "time"] } ``` La macro `#[tokio::main]` transforme la fonction `main` en un point d'entrée asynchrone : ```rust // main.rs #[tokio::main] async fn main() { let result = fetch_data().await; println!("Got: {result}"); } async fn fetch_data() -> String { // Simulate async I/O tokio::time::sleep(std::time::Duration::from_millis(100)).await; String::from("data loaded") } ``` En coulisses, `#[tokio::main]` se développe en un appel `Runtime::new()` avec un ordonnanceur multi-threadé. Pour les applications ne nécessitant qu'un seul thread (outils CLI, scripts), `#[tokio::main(flavor = "current_thread")]` évite la surcharge de la synchronisation entre threads. ## Lancement de tâches et concurrence structurée Les appels `.await` séquentiels s'exécutent l'un après l'autre. Pour exécuter du travail de manière concurrente, Tokio fournit `tokio::spawn`, qui planifie une future sur le pool de threads du runtime : ```rust // concurrent_tasks.rs use tokio::task::JoinHandle; #[tokio::main] async fn main() { // Spawn two independent tasks let handle_a: JoinHandle = tokio::spawn(async { expensive_computation("dataset_a").await }); let handle_b: JoinHandle = tokio::spawn(async { expensive_computation("dataset_b").await }); // Await both results let (result_a, result_b) = ( handle_a.await.expect("task A panicked"), handle_b.await.expect("task B panicked"), ); println!("Results: {result_a}, {result_b}"); } async fn expensive_computation(name: &str) -> u32 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; println!("{name} done"); 42 } ``` Les deux tâches s'exécutent en parallèle sur les threads disponibles. Le `JoinHandle` retourné par `spawn` agit comme une future qui se résout lorsque la tâche lancée se termine. Une contrainte fondamentale : `tokio::spawn` exige que la future soit `'static` — elle ne peut pas emprunter de variables locales. Cela force un transfert de propriété explicite, ce qui empêche les courses de données à la compilation. > **Exigence Send + 'static** > > Les tâches lancées doivent être `Send + 'static`. Les données partagées entre les points `.await` à l'intérieur d'une tâche lancée doivent être soit `Clone`ées dans la tâche, soit enveloppées dans un `Arc`. Tenter d'emprunter des références locales à la pile au-delà de la frontière d'un spawn déclenche une erreur de compilation. ## Joindre plusieurs Futures avec tokio::join! et tokio::select! Pour un nombre fixe d'opérations concurrentes, `tokio::join!` exécute toutes les futures jusqu'à leur complétion et renvoie un tuple de résultats : ```rust // join_example.rs use tokio::time::{sleep, Duration}; #[tokio::main] async fn main() { // All three run concurrently, total time ~200ms (not 600ms) let (users, orders, inventory) = tokio::join!( fetch_users(), fetch_orders(), fetch_inventory() ); println!("Users: {}, Orders: {}, Stock: {}", users.len(), orders.len(), inventory); } async fn fetch_users() -> Vec { sleep(Duration::from_millis(200)).await; vec!["Alice".into(), "Bob".into()] } async fn fetch_orders() -> Vec { sleep(Duration::from_millis(150)).await; vec!["ORD-001".into()] } async fn fetch_inventory() -> u32 { sleep(Duration::from_millis(100)).await; 84 } ``` Contrairement à `spawn`, `join!` ne nécessite pas `'static` — les futures empruntent librement depuis la portée englobante. Cela en fait le choix privilégié lorsque toutes les branches doivent se terminer. `tokio::select!` attend la *première* future à se terminer et annule les autres : ```rust // select_example.rs use tokio::time::{sleep, Duration}; #[tokio::main] async fn main() { tokio::select! { val = fetch_from_cache() => { println!("Cache hit: {val}"); } val = fetch_from_database() => { println!("DB result: {val}"); } } } async fn fetch_from_cache() -> String { sleep(Duration::from_millis(5)).await; "cached_value".into() } async fn fetch_from_database() -> String { sleep(Duration::from_millis(50)).await; "db_value".into() } ``` La branche perdante est détruite, ce qui exécute les destructeurs et libère les ressources. Ce pattern est essentiel pour implémenter des délais d'attente, l'annulation et la mise en concurrence entre sources de données. ## Gestion des erreurs en Rust asynchrone Les fonctions asynchrones se composent naturellement avec le type `Result` de Rust. L'opérateur `?` fonctionne à l'intérieur d'une `async fn` exactement comme dans du code synchrone : ```rust // error_handling.rs use std::io; #[derive(Debug)] enum AppError { Network(reqwest::Error), Parse(serde_json::Error), Io(io::Error), } async fn load_config(url: &str) -> Result { let response = reqwest::get(url) .await .map_err(AppError::Network)?; let text = response.text() .await .map_err(AppError::Network)?; let config: Config = serde_json::from_str(&text) .map_err(AppError::Parse)?; Ok(config) } #[derive(serde::Deserialize)] struct Config { db_url: String, port: u16, } ``` Chaque point `.await` est un point de suspension potentiel et un site d'erreur potentiel. Mapper les erreurs explicitement garde le flux de contrôle asynchrone lisible et évite le « callback hell » que l'on retrouve dans d'autres modèles asynchrones. ## Canaux pour la communication asynchrone entre tâches Tokio fournit des canaux compatibles avec l'asynchrone qui permettent aux tâches lancées de communiquer sans état mutable partagé : ```rust // channel_example.rs use tokio::sync::mpsc; #[tokio::main] async fn main() { // Bounded channel with capacity 32 let (tx, mut rx) = mpsc::channel::(32); // Producer task let producer = tokio::spawn(async move { for i in 0..5 { tx.send(format!("message-{i}")).await.unwrap(); tokio::time::sleep(std::time::Duration::from_millis(10)).await; } // tx dropped here, closing the channel }); // Consumer reads until channel closes while let Some(msg) = rx.recv().await { println!("Received: {msg}"); } producer.await.unwrap(); } ``` `mpsc::channel` crée un canal multi-producteurs, mono-consommateur. Pour les scénarios à producteur unique, `oneshot::channel` propose une option plus légère. Pour diffuser vers plusieurs consommateurs, `broadcast::channel` clone les messages vers chaque récepteur actif. La capacité bornée (32 dans cet exemple) applique une contre-pression : lorsque le tampon se remplit, `send().await` suspend le producteur jusqu'à ce que le consommateur rattrape. Cela empêche une croissance mémoire illimitée dans les pipelines à haut débit. ## Pattern concret : requêtes HTTP concurrentes avec limitation de débit Un scénario courant en production consiste à effectuer de nombreuses requêtes HTTP de manière concurrente tout en respectant les limites de débit. `tokio::sync::Semaphore` contrôle le degré de parallélisme : ```rust // rate_limited_fetcher.rs use std::sync::Arc; use tokio::sync::Semaphore; async fn fetch_all(urls: Vec, max_concurrent: usize) -> Vec> { let semaphore = Arc::new(Semaphore::new(max_concurrent)); let mut handles = Vec::new(); for url in urls { let sem = Arc::clone(&semaphore); let handle = tokio::spawn(async move { // Acquire permit before making request let _permit = sem.acquire().await.unwrap(); reqwest::get(&url) .await .map(|r| r.status().to_string()) .map_err(|e| e.to_string()) // permit dropped here, allowing next task to proceed }); handles.push(handle); } let mut results = Vec::new(); for handle in handles { results.push(handle.await.unwrap()); } results } ``` Le sémaphore limite les requêtes actives à `max_concurrent`. Toutes les tâches sont lancées immédiatement, mais seules N exécutent leur appel HTTP à un instant donné. Lorsqu'une tâche se termine et libère son permis, une autre tâche se réveille et continue. > **Choisir la bonne primitive de concurrence** > > `tokio::join!` convient à un ensemble fixe et connu de futures. `tokio::spawn` + `Semaphore` convient à une collection dynamique et potentiellement large. `select!` convient aux scénarios de course ou de délai d'attente. Choisir la bonne primitive évite à la fois la sous-utilisation et l'épuisement des ressources. ## Pin et Unpin : pourquoi le Rust asynchrone en a besoin Le type `Pin` apparaît dans la signature de `Future::poll` pour une raison précise. Les machines à états asynchrones peuvent contenir des champs auto-référentiels — une référence dans un état pointant vers des données stockées dans une autre partie de la même structure. Déplacer la structure en mémoire invaliderait cette référence. `Pin<&mut Self>` garantit que la future ne sera pas déplacée après le début du sondage. La plupart des futures écrites par les développeurs sont `Unpin` (déplaçables), et le compilateur gère l'épinglage automatiquement. La gestion manuelle de `Pin` ne devient nécessaire que lors de l'implémentation de types `Future` personnalisés ou du travail avec des patterns `async_stream`. Pour l'utilisation quotidienne du Rust asynchrone, l'essentiel à retenir : `Box::pin(future)` convertit n'importe quelle future en une forme épinglée et allouée sur le tas qui peut être stockée dans des collections ou retournée depuis des méthodes de trait. ## Caractéristiques de performance et quand utiliser l'asynchrone Le Rust asynchrone brille pour les charges de travail liées aux I/O : serveurs HTTP, clients de bases de données, courtiers de messages, observateurs de fichiers. Chaque tâche n'utilise que quelques centaines d'octets de pile (contre 8 Mo par défaut pour les threads OS), ce qui rend possible l'exécution de centaines de milliers de tâches concurrentes sur une seule machine. Pour le travail lié au CPU, `tokio::task::spawn_blocking` décharge le calcul vers un pool de threads dédié, empêchant l'exécuteur asynchrone d'être affamé : ```rust // spawn_blocking_example.rs #[tokio::main] async fn main() { let hash = tokio::task::spawn_blocking(|| { // CPU-intensive work runs on a blocking thread compute_hash(b"large dataset") }) .await .unwrap(); println!("Hash: {hash}"); } fn compute_hash(data: &[u8]) -> String { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); data.hash(&mut hasher); format!("{:x}", hasher.finish()) } ``` Combiner `spawn_blocking` avec de l'I/O asynchrone maintient la boucle d'événements réactive tout en exploitant tous les cœurs CPU pour les calculs lourds. ## Conclusion - Les futures Rust sont des machines à états paresseuses — rien ne s'exécute tant qu'un exécuteur ne les sonde pas, donnant un contrôle total sur l'ordonnancement et l'utilisation des ressources - Tokio 2.0 fournit le runtime, avec `spawn` pour les tâches indépendantes, `join!` pour la complétion parallèle et `select!` pour la mise en concurrence - La contrainte `Send + 'static` sur les tâches lancées détecte les courses de données à la compilation, échangeant un peu de friction ergonomique contre une sécurité thread garantie - Les canaux (`mpsc`, `oneshot`, `broadcast`) découplent la communication entre tâches sans état mutable partagé - `Semaphore` et les canaux bornés fournissent la contre-pression, empêchant l'épuisement des ressources en production - `spawn_blocking` comble le fossé entre l'I/O asynchrone et le calcul lié au CPU - La maîtrise de ces primitives — [le trait Future](https://doc.rust-lang.org/std/future/trait.Future.html), [le runtime Tokio](https://tokio.rs/) et les règles de propriété — libère le potentiel de Rust pour construire des services à haut débit et faible latence --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/rust/rust-async-await-tokio-futures-concurrency