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.

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é.
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 :
// core::future::Future
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}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 :
# 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 :
#[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 :
use tokio::task::JoinHandle;
#[tokio::main]
async fn main() {
// Spawn two independent tasks
let handle_a: JoinHandle<u32> = tokio::spawn(async {
expensive_computation("dataset_a").await
});
let handle_b: JoinHandle<u32> = 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.
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 :
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<String> {
sleep(Duration::from_millis(200)).await;
vec!["Alice".into(), "Bob".into()]
}
async fn fetch_orders() -> Vec<String> {
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 :
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.
Prêt à réussir tes entretiens Rust ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
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 :
use std::io;
#[derive(Debug)]
enum AppError {
Network(reqwest::Error),
Parse(serde_json::Error),
Io(io::Error),
}
async fn load_config(url: &str) -> Result<Config, AppError> {
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é :
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
// Bounded channel with capacity 32
let (tx, mut rx) = mpsc::channel::<String>(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 :
use std::sync::Arc;
use tokio::sync::Semaphore;
async fn fetch_all(urls: Vec<String>, max_concurrent: usize) -> Vec<Result<String, String>> {
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.
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é :
#[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.
Prêt à réussir tes entretiens Rust ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
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
spawnpour les tâches indépendantes,join!pour la complétion parallèle etselect!pour la mise en concurrence - La contrainte
Send + 'staticsur 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é Semaphoreet les canaux bornés fournissent la contre-pression, empêchant l'épuisement des ressources en productionspawn_blockingcomble le fossé entre l'I/O asynchrone et le calcul lié au CPU- La maîtrise de ces primitives — le trait Future, le runtime Tokio et les règles de propriété — libère le potentiel de Rust pour construire des services à haut débit et faible latence
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Tags
Partager
Articles similaires

Ownership et Borrowing en Rust : le guide complet pour les entretiens techniques
Comprendre en profondeur l'ownership, le borrowing, les lifetimes et le borrow checker en Rust. Un guide essentiel pour se preparer aux entretiens techniques systemes.

Ownership et Borrowing en Rust : Guide complet
Maîtrisez le système d'ownership et borrowing de Rust. Comprendre les règles de propriété, références, lifetimes et les patterns avancés de gestion mémoire.

Questions d'entretien Rust : Guide complet 2026
Les 25 questions d'entretien Rust les plus fréquentes. Ownership, borrowing, lifetimes, traits, async et concurrence avec réponses détaillées et exemples de code.