ITSkillsCenter
Développement Web

Tokio en pratique : runtime async Rust pas-à-pas

15 min de lecture

📍 Guide principal : Rust pour le web perf-critique : choix, stack et tutoriels 2026
Cet article approfondit le runtime async vu dans le guide. Pour la vue d’ensemble du choix Rust en production, lire d’abord le guide.

Introduction

Tokio est la couche que vous croisez sans jamais la voir. Quand vous écrivez un handler Actix-web ou Axum, c’est Tokio qui exécute votre async fn. Quand votre service répond à mille requêtes par seconde sans saturer un seul cœur, c’est le scheduler Tokio qui multiplexe ces requêtes sur ses threads. Pour la majorité des services web simples, comprendre Tokio à un niveau superficiel suffit. Mais dès que vous avez besoin de paralléliser des appels en aval, de coordonner plusieurs tâches, de mettre en cache avec invalidation, ou de relier votre service à un consommateur Kafka, Tokio devient une compétence à maîtriser. Ce tutoriel vous fait construire pas à pas un module qui télécharge en parallèle plusieurs URLs avec contrôle du nombre de tâches, timeout, annulation propre, et instrumentation tokio-console.

L’objectif n’est pas la pure théorie. À la fin du tutoriel, vous aurez écrit du code que vous pouvez transposer tel quel dans n’importe quel service Rust qui doit paralléliser des appels API, traiter un flux de messages ou orchestrer des tâches de fond. Vous aurez aussi les réflexes essentiels pour ne pas tomber dans les pièges classiques : blocage du scheduler, fuites de tâches, mauvais choix de Mutex.

Prérequis

  • Rust 1.88 ou plus récent installé via rustup
  • Connaissance de la syntaxe async fn et du concept de Future
  • Un terminal et un éditeur configuré avec rust-analyzer
  • Niveau attendu : intermédiaire — vous devez avoir déjà lu un handler Actix ou Axum
  • Temps estimé : 90 à 120 minutes

Étape 1 — Initialiser le projet et choisir les features

Tokio expose ses fonctionnalités via des features Cargo. En activer trop coûte en temps de compilation et en taille de binaire ; en activer trop peu vous prive de primitives utiles. Le réflexe pour un nouveau projet est de partir de features = ["full"] qui active tout, puis de réduire si vous avez besoin de slimer. Pour ce tutoriel, on ajoute aussi reqwest pour les appels HTTP et tracing pour l’observabilité.

cargo new --bin parallele-rs
cd parallele-rs

Ouvrez Cargo.toml et configurez les dépendances comme suit. tokio en LTS 1.51 garantit le support jusqu’en mars 2027. La feature tracing de Tokio active les spans internes utilisés par tokio-console.

[package]
name = "parallele-rs"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"

[dependencies]
tokio = { version = "1.51", features = ["full", "tracing"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls-tls", "json"] }
futures = "0.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
console-subscriber = "0.4"
anyhow = "1"

[profile.release]
lto = "thin"

Lancez cargo build ; le premier compile télécharge plusieurs centaines de crates et prend quelques minutes selon votre machine. C’est le coût d’entrée de Rust pour un projet utilisant un runtime async complet ; les builds suivants seront beaucoup plus rapides grâce au cache de cargo. Une fois le build réussi, l’arborescence cible target/debug/ contient votre binaire vide pour l’instant.

Étape 2 — Première fonction async séquentielle

Avant la concurrence, on écrit la version séquentielle qui télécharge des URLs une par une. Cela donne un point de comparaison lisible et permet de vérifier la chaîne reqwest avant d’ajouter de la complexité. La fonction retourne anyhow::Result pour propager les erreurs sans définir de type d’erreur custom à ce stade.

// src/main.rs
use anyhow::Result;
use std::time::Instant;
use tracing::info;

async fn fetch(client: &reqwest::Client, url: &str) -> Result<(u16, usize)> {
    let resp = client.get(url).send().await?;
    let statut = resp.status().as_u16();
    let corps = resp.bytes().await?;
    Ok((statut, corps.len()))
}

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt::init();
    let urls = vec![
        "https://example.com/",
        "https://www.iana.org/",
        "https://httpbin.org/get",
    ];
    let client = reqwest::Client::new();
    let debut = Instant::now();
    for url in &urls {
        let (s, n) = fetch(&client, url).await?;
        info!(url, statut = s, octets = n, "fetch ok");
    }
    info!(duree_ms = debut.elapsed().as_millis() as u64, "termine");
    Ok(())
}

Trois remarques importantes. #[tokio::main] est une macro qui transforme votre main synchrone en un main qui crée un runtime Tokio multi-threadé et exécute votre code async dedans. Sans cette macro, votre async fn main ne ferait rien parce qu’aucun runtime ne le piloterait. Le tracing_subscriber::fmt::init() active un logger console basique qui affiche les info! avec leurs spans. Lancez avec RUST_LOG=info cargo run ; vous devez voir trois lignes de fetch ok et une ligne de termine. La durée totale tourne autour de la somme des latences individuelles puisqu’on attend chaque réponse avant de lancer la suivante.

Étape 3 — Paralléliser avec join_all

La méthode la plus directe pour paralléliser N tâches indépendantes en Tokio est futures::future::join_all. Vous créez un vec de futures non encore exécutées, et join_all les pilote toutes en parallèle, retournant un vec de résultats dans le même ordre. C’est lisible, sans plomberie, et suffit pour la plupart des cas où le nombre d’URLs est borné.

use futures::future::join_all;

async fn fetch_parallele(client: reqwest::Client, urls: Vec<String>) -> Vec<Result<(u16, usize)>> {
    let futures = urls.iter().map(|u| {
        let c = client.clone();
        let url = u.clone();
        async move { fetch(&c, &url).await }
    });
    join_all(futures).await
}

Le client.clone() est essentiel et bon marché : reqwest::Client partage en interne un pool de connexions via Arc, donc cloner ne duplique pas le pool. Le async move capture les variables clonées dans la future. Quand vous remplacez l’appel séquentiel par fetch_parallele, la durée totale chute à environ la latence de l’URL la plus lente. Le scheduler Tokio multiplexe les attentes I/O sur ses threads, donc même avec un seul cœur CPU, les attentes se chevauchent. Vous devez observer un gain net de 2 à 3x avec trois URLs réactives.

Étape 4 — Limiter la concurrence avec un semaphore

Avec mille URLs, join_all pose un problème : il lance toutes les futures simultanément, ce qui peut saturer la cible et ouvrir trop de connexions TCP. Le pattern correct utilise un tokio::sync::Semaphore qui borne le nombre de tâches concurrentes. Chaque future acquiert un permis avant de démarrer et le relâche en fin d’exécution.

use std::sync::Arc;
use tokio::sync::Semaphore;

async fn fetch_pool(client: reqwest::Client, urls: Vec<String>, max_concurrents: usize) -> Vec<Result<(u16, usize)>> {
    let sem = Arc::new(Semaphore::new(max_concurrents));
    let futures = urls.into_iter().map(|url| {
        let c = client.clone();
        let s = sem.clone();
        async move {
            let _permis = s.acquire().await.unwrap();
            fetch(&c, &url).await
        }
    });
    join_all(futures).await
}

Le let _permis = s.acquire().await.unwrap(); bloque la future jusqu’à obtention d’un permis, et le permis est restitué automatiquement quand _permis sort de portée à la fin du bloc async. Ce pattern fonctionne aussi bien pour limiter les appels HTTP que pour limiter l’accès concurrent à n’importe quelle ressource (base de données, fichier, API externe à quota). Lancez avec mille URLs et un semaphore à 10 ; vous verrez exactement 10 connexions TCP simultanées via netstat -an | grep ESTABLISHED | wc -l sur le serveur cible.

Étape 5 — Ajouter un timeout par tâche

Une URL qui ne répond jamais bloquerait votre tâche indéfiniment. Tokio fournit tokio::time::timeout qui enveloppe une future et la termine avec une erreur si elle dépasse une durée. Combiné avec le pool précédent, vous obtenez un service robuste face aux serveurs lents ou défaillants.

use std::time::Duration;
use tokio::time::timeout;

async fn fetch_avec_timeout(client: &reqwest::Client, url: &str, max: Duration) -> Result<(u16, usize)> {
    match timeout(max, fetch(client, url)).await {
        Ok(res) => res,
        Err(_) => Err(anyhow::anyhow!("timeout apres {:?}", max)),
    }
}

Notez la différence entre le timeout de Tokio et celui de reqwest. Le timeout reqwest s’applique à une requête HTTP individuelle (DNS + connect + read). Le timeout Tokio s’applique à n’importe quelle future, ce qui inclut une chaîne de plusieurs opérations. Pour un service production-ready, on pose souvent les deux : un timeout reqwest fin pour l’I/O et un timeout Tokio plus large comme garde-fou global. Lancez votre service avec un timeout de 5 secondes ; les URLs lentes apparaîtront avec une erreur explicite plutôt que de bloquer le pool.

Étape 6 — Annulation propagée par token

Pour stopper proprement une opération en cours (par exemple quand l’utilisateur ferme sa connexion HTTP), Tokio fournit CancellationToken dans la crate tokio-util. Le token est cloné et passé aux tâches enfants ; appeler cancel() sur le parent propage l’annulation à tous les clones, qui peuvent la détecter via cancelled().await.

use tokio_util::sync::CancellationToken;

async fn fetch_annulable(client: &reqwest::Client, url: &str, token: CancellationToken) -> Result<(u16, usize)> {
    tokio::select! {
        res = fetch(client, url) => res,
        _ = token.cancelled() => Err(anyhow::anyhow!("annule")),
    }
}

Ajoutez tokio-util = { version = "0.7", features = ["rt"] } à Cargo.toml avant d’utiliser ce code. La macro tokio::select! attend simultanément sur plusieurs futures et exécute le bloc associé à la première qui se termine. Ici, soit la requête HTTP termine et son résultat est retourné, soit le token est annulé et on retourne une erreur d’annulation. Le pattern est essentiel quand votre tâche orchestre plusieurs sous-tâches qu’il faut couper en cascade. Pour valider le mécanisme, écrivez un test qui annule le token après 100ms et vérifie que la fonction retourne en moins de 200ms.

Étape 7 — Spawn de tâches de fond et JoinHandle

Au-delà du pattern join_all, on a souvent besoin de lancer une tâche en arrière-plan sans bloquer le code appelant. tokio::spawn crée une tâche indépendante qui s’exécute en parallèle, et retourne un JoinHandle qu’on peut éventuellement attendre plus tard pour récupérer le résultat. C’est le mécanisme central pour les workers de fond, les consommateurs Kafka, les flushers de cache.

async fn lancer_journalisation(intervalle: Duration) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        let mut tick = tokio::time::interval(intervalle);
        loop {
            tick.tick().await;
            tracing::info!("vivant");
        }
    })
}

Trois subtilités. La tâche tourne tant que personne ne l’arrête ; pour l’arrêter proprement, on couple souvent à un CancellationToken écouté dans un tokio::select!. Si vous laissez tomber le JoinHandle sans l’attendre, la tâche continue de tourner mais vous perdez la possibilité de récupérer son résultat ; ce n’est pas une fuite, c’est juste un détachement. Enfin, tokio::time::interval est préférable à tokio::time::sleep en boucle parce qu’il maintient la cadence même si une itération prend plus de temps que prévu.

Étape 8 — Mutex async vs Mutex synchrone

Le piège classique en Rust async est l’usage de std::sync::Mutex dans du code async. Ce mutex bloque le thread Tokio courant pendant l’attente du lock, ce qui peut bloquer toutes les autres tâches assignées à ce thread, voire provoquer un deadlock si la tâche qui détient le lock est elle-même en attente d’un thread libre. La règle absolue : dans du code async, utilisez tokio::sync::Mutex.

use std::sync::Arc;
use tokio::sync::Mutex;

#[derive(Default)]
struct Compteur { valeur: u64 }

async fn incrementer(c: Arc<Mutex<Compteur>>) {
    let mut g = c.lock().await;
    g.valeur += 1;
}

Le code ci-dessus tient au-delà du runtime : g.lock().await ne bloque pas le thread Tokio mais yield au scheduler le temps d’obtenir le lock, ce qui permet à d’autres tâches de progresser. Une exception : si le critical section est très court (quelques nanosecondes), std::sync::Mutex peut être plus performant. Mais dans le doute, restez sur tokio::sync::Mutex en async ; le coût supplémentaire est marginal et vous évitez des bugs subtils.

Étape 9 — Ne pas bloquer le scheduler avec spawn_blocking

Tokio est conçu pour de l’I/O. Si votre handler doit calculer un hash bcrypt, parser un gros XML ou compresser un fichier, vous bloquez le thread Tokio pendant ce temps et empêchez les autres tâches de progresser. Le réflexe correct est tokio::task::spawn_blocking qui exécute la fonction sur un pool de threads dédiés au blocant, sans impacter le scheduler async.

async fn calculer_hash(donnees: Vec<u8>) -> Result<String> {
    let h = tokio::task::spawn_blocking(move || {
        // calcul lourd qui peut prendre 100ms+
        format!("{:x}", md5::compute(&donnees))
    }).await?;
    Ok(h)
}

Pour cet exemple, ajoutez md5 = "0.7" à votre Cargo.toml. La règle pratique est : toute fonction qui peut prendre plus de quelques dizaines de microsecondes en CPU pur doit aller dans spawn_blocking. C’est moins automatique qu’en Go où le scheduler peut préempter une goroutine, donc la discipline du développeur est essentielle. Pour identifier les blocages réels, l’outil tokio-console les rend visibles graphiquement — c’est l’objet de l’étape suivante.

Étape 10 — Instrumenter avec tokio-console

tokio-console est un outil unique dans l’écosystème backend. Il affiche en temps réel l’état de toutes vos tâches Tokio, leurs latences, leurs blocages, leurs ressources async. Pour l’activer, on remplace le subscriber tracing par console_subscriber et on lance le binaire avec une feature spéciale activée.

// au début de main(), avant tout code
console_subscriber::init();

Lancez le binaire avec RUSTFLAGS="--cfg tokio_unstable" cargo run. Dans un autre terminal, installez le client console avec cargo install tokio-console puis lancez tokio-console. Vous voyez apparaître un tableau de bord interactif listant toutes les tâches actives, avec leur état, leur durée d’exécution et les ressources qu’elles attendent. C’est l’outil idéal pour identifier une tâche qui bloque le scheduler ou un Mutex qui n’est jamais relâché. En production, on n’active tokio_unstable que sur des serveurs de debug ou en pre-prod, pas en prod stable.

Erreurs fréquentes

Erreur Cause Solution
Le programme se termine immédiatement sans rien faire Pas de #[tokio::main] ou pas d’.await sur la future principale Vérifier que main est bien async et que les futures sont awaited
Service figé sous charge alors qu’il y a des cœurs libres Bloc CPU dans un handler sans spawn_blocking Identifier le code coupable avec tokio-console et migrer vers spawn_blocking
Deadlock invisible Utilisation de std::sync::Mutex dans du code async Remplacer par tokio::sync::Mutex
Tâches qui s’accumulent sans jamais terminer Pas d’écoute de l’annulation, pas de timeout Ajouter tokio::select! avec branche d’annulation
error: there is no reactor running Code Tokio appelé en dehors d’un runtime Démarrer manuellement un Runtime ou utiliser #[tokio::main]

Tutoriels frères

Pour aller plus loin

FAQ

Quand utiliser tokio::spawn plutôt que join_all ?
spawn détache une tâche qui peut survivre à son créateur, idéal pour des workers de fond. join_all attend toutes les tâches avant de revenir, idéal pour des opérations parallèles dont vous avez besoin avant de continuer.

Combien de tâches Tokio peut-on lancer ?
Plusieurs centaines de milliers sans difficulté. Le coût d’une tâche Tokio est proche de celui d’une goroutine Go : quelques kilo-octets et un peu de scheduling.

Faut-il toujours utiliser le runtime multi-threadé ?
Le runtime current_thread (single-threaded) est plus prévisible pour de petites applications ou des outils CLI. Le runtime multi-thread distribue automatiquement les tâches sur tous les cœurs CPU, indispensable pour un service web sous charge.

Pourquoi mes tâches ne progressent-elles pas ?
Souvent parce qu’une tâche bloque le scheduler avec du CPU pur ou un Mutex synchrone. Lancer tokio-console révèle immédiatement le coupable. Sinon, vérifier qu’il n’y a pas de boucle infinie sans .await, qui empêche le yield au scheduler.

Comment combiner Tokio avec du code synchrone existant ?
spawn_blocking dans un sens (sync depuis async), Handle::current().block_on() dans l’autre (async depuis sync). Pour des projets mixtes complexes, structurer le code en deux moitiés bien séparées clarifie l’architecture.

Tokio convient-il aux applications de bureau ou jeux ?
Pas spécialement. Tokio est optimisé pour de l’I/O massif. Pour des applications interactives ou des boucles de jeu, des runtimes plus simples comme smol ou un loop dédié sont préférables.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité