Async/Await in Rust: Tokio, Futures en asynchrone concurrency uitgelegd
Uitgebreide gids over Rust async/await met Tokio runtime, Future trait, task spawning, gestructureerde concurrency en praktijkpatronen voor het bouwen van hoogperformante asynchrone applicaties.

Asynchrone programmeren in Rust volgt een fundamenteel ander ontwerpprincipe dan in de meeste talen: futures zijn lui, wat betekent dat ze geen werk verrichten totdat ze actief gepolld worden door een runtime. Dit zero-cost abstraction-model garandeert dat ontwikkelaars alleen betalen voor wat ze gebruiken, zonder verborgen allocaties of impliciete threadpools. Tokio vormt de meest gebruikte async runtime in het Rust-ecosysteem en biedt een rijke toolset voor het bouwen van hoogperformante netwerkapplicaties, van eenvoudige HTTP-clients tot complexe gedistribueerde systemen.
Een Future in Rust doet niets totdat deze gepolld wordt. Dit betekent dat async fn fetch_data() aanroepen geen netwerkverzoek initieert—pas wanneer .await wordt aangeroepen (of een runtime expliciet de future pollt), begint de uitvoering. Dit voorkomt onbedoelde side-effects en geeft ontwikkelaars volledige controle over executie-timing.
Hoe Rust Futures verschillen van andere talen
Het Future-trait in Rust definieert een contract voor asynchrone berekeningen via een poll-gebaseerde interface:
// core::future::Future
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}Dit verschilt radicaal van JavaScript Promises of Python coroutines. In JavaScript start een Promise direct bij creatie en begint de executor onmiddellijk met werk. Rust's benadering daarentegen vereist expliciete polling: de runtime roept poll() herhaaldelijk aan totdat de future Poll::Ready(value) retourneert. Tussen polls registreert de future zichzelf bij de Waker in de Context, waardoor de runtime efficiënt weet wanneer verdere vooruitgang mogelijk is.
Deze architectuur elimineert heap-allocaties voor simpele futures en maakt zero-cost combinators mogelijk. Een gekettende sequentie van .map(), .and_then(), en .or_else() compileert tot strak machine code zonder runtime overhead. De trade-off is complexiteit: ontwikkelaars moeten concepten zoals pinning en self-referential types begrijpen wanneer ze handmatig Future-implementaties schrijven.
In de praktijk schrijft men zelden poll() handmatig. De async/await syntaxis genereert state machines die het Future-trait automatisch implementeren, waarbij lokale variabelen in de future-struct bewaard blijven tussen suspend-punten. Dit combineert de ergonomie van synchrone code met de prestaties van event-driven architecturen.
Tokio instellen als async runtime
Tokio biedt de scheduler, I/O-drivers en synchronisatieprimitieven die nodig zijn om Rust futures uit te voeren. Installatie begint met het toevoegen van de crate aan Cargo.toml:
# Cargo.toml
[dependencies]
tokio = { version = "2", features = ["rt-multi-thread", "macros", "net", "time"] }De feature flags bepalen welke componenten gecompileerd worden. rt-multi-thread activeert de multi-threaded work-stealing scheduler, macros biedt #[tokio::main] en #[tokio::test], terwijl net en time respectievelijk TCP/UDP-ondersteuning en timers toevoegen. Voor embedded systemen kan rt (single-threaded) volstaan om binary size te reduceren.
Een minimaal async programma ziet er als volgt uit:
#[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")
}De #[tokio::main] macro transformeert de async main-functie naar een synchrone entry point die een Tokio runtime initialiseert. Onder de motorkap creëert dit een Runtime, spawnt de main-future, en blokkeert totdat deze compleet is. Voor meer controle kan men de runtime handmatig bouwen met Runtime::new() en block_on().
Task spawning en gestructureerde concurrency
Tokio's spawn() functie creëert een nieuwe asynchrone task die onafhankelijk op de runtime draait. In tegenstelling tot OS threads zijn tasks lightweight: duizenden kunnen op enkele worker threads draaien dankzij cooperative multitasking bij .await punten.
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
}Beide tasks starten gelijktijdig en de totale executietijd bedraagt ongeveer 1 seconde in plaats van 2. De JoinHandle fungeert als bewijs van ownership: awaiten consumes de handle en retourneert een Result<T, JoinError>. Indien de task panicked, propageert de JoinError de panic naar de aanroeper.
tokio::spawn vereist dat de future Send + 'static is. Send garandeert thread-safety (de task kan naar een andere worker thread migreren), terwijl 'static betekent dat de future geen referenties naar stack data mag bevatten. Om data te delen, gebruik Arc of kopieer waarden in de async block. Voor tasks die niet Send hoeven te zijn, biedt tokio::task::spawn_local een alternatief op single-threaded runtimes.
Gestructureerde concurrency komt tot uitdrukking wanneer parent tasks de levensduur van child tasks beheren. In plaats van fire-and-forget spawning moeten handles altijd ge-await worden of expliciet geannuleerd via abort(). Dit voorkomt resource leaks en maakt error propagation voorspelbaar.
Meerdere Futures combineren met tokio::join! en tokio::select!
tokio::join! wacht op meerdere futures en retourneert hun resultaten als tuple, waarbij alle futures concurrent draaien:
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
}De totale executietijd wordt bepaald door de langzaamste future (200ms), niet de som van alle durations. Indien één future panicked, paniceert join! ook, maar alle andere futures blijven draaien tot completie.
tokio::select! implementeert het eerste-klaar-wint patroon, nuttig voor timeouts, fallback mechanismes, of race-condities:
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()
}De cache-check voltooit eerst, dus de database-query wordt gecanceld. Dit cancellation mechanisme is immediate: de future stopt bij het volgende .await punt en dropped alle resources.
Klaar om je Rust gesprekken te halen?
Oefen met onze interactieve simulatoren, flashcards en technische tests.
Foutafhandeling in async Rust
Async functies propageren errors via Result<T, E> net als synchrone code, maar composition vereist aandacht voor context-behoud. Het ? operator werkt identiek in async contexten, waarbij .await? zowel async execution als error propagation combineert:
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,
}Elke .await introduceert een suspend-punt waar de future gepauzeerd kan worden. Als een error optreedt, stopt de executie en wordt de call stack afgewikkeld zoals bij synchrone code. Intermediate state (zoals de response variabele) blijft bewaard in de future-struct totdat deze dropped wordt.
Channels voor asynchrone communicatie tussen tasks
Tokio's channel primitieven faciliteren message passing tussen concurrente tasks zonder shared memory. De mpsc (multi-producer, single-consumer) channel ondersteunt bounded capaciteit om backpressure te implementeren:
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();
}Wanneer de channel vol is, blokkeert .send().await totdat ruimte beschikbaar komt—dit voorkomt ongelimiteerde geheugengroei bij snelle producers en trage consumers. Unbounded channels (mpsc::unbounded_channel) bestaan ook, maar moeten zorgvuldig gebruikt worden om memory leaks te vermijden.
Voor broadcast scenarios (één producer, meerdere consumers) biedt tokio::sync::broadcast een alternatief waarbij elke receiver een kopie van elk bericht ontvangt. Voor one-shot communication dient tokio::sync::oneshot.
Gebruik mpsc voor work queues en pipelines, broadcast voor pub/sub patronen, oneshot voor async RPC-achtige communicatie, en watch wanneer multiple readers de meest recente versie van een waarde moeten observeren zonder historische berichten. Elke primitive heeft specifieke trade-offs in geheugengebruik en contention karakteristieken.
Praktijkpatroon: gelijktijdige HTTP-verzoeken met rate limiting
Production systemen vereisen vaak het tegelijkertijd fetchen van meerdere resources terwijl concurrency limits gerespecteerd worden. Semaphores bieden een elegant mechanisme voor dit patroon:
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
}Elke task acquireert een permit voordat een request gestart wordt. De semaphore limiteert hoeveel permits simultaan uitgegeven kunnen worden. Wanneer alle permits in gebruik zijn, blokkeert .acquire().await totdat een andere task zijn permit released door deze te droppen. Dit zorgt dat nooit meer dan N requests tegelijk actief zijn, ongeacht hoeveel URLs in de lijst staan.
Pin en Unpin: waarom async Rust ze nodig heeft
Futures in Rust kunnen self-referential zijn: lokale variabelen binnen een async fn moeten bewaard blijven tussen .await punten, en pointers naar die variabelen kunnen in de future-struct opgeslagen worden. Indien de future verplaatst wordt in geheugen, worden deze pointers invalid.
Pin<P> lost dit op door te garanderen dat de onderliggende waarde niet meer verplaatst wordt zodra gepinned. Het Future::poll signature vereist Pin<&mut Self> om te verzekeren dat de future stabiel blijft in geheugen tijdens polling.
De meeste types zijn Unpin, wat betekent dat ze veilig verplaatst kunnen worden zelfs wanneer gepinned—voor deze types is Pin effectief een no-op. Handgeschreven futures die referenties naar hun eigen velden bevatten implementeren expliciet !Unpin via PhantomPinned.
In praktijk hoeven ontwikkelaars zelden direct met Pin te werken wanneer ze async/await gebruiken. De compiler genereert correct gepinde state machines automatisch. Pinning komt aan bod bij library-niveau APIs zoals Stream::poll_next of wanneer men futures op de stack pinned met pin!() macro.
Prestatiekenmerken en wanneer async te gebruiken
Async Rust excelleert in I/O-bound workloads: webservers, database clients, message brokers, en andere applicaties waar tasks vaak wachten op externe events. De zero-allocation future model betekent dat miljoenen concurrente connections mogelijk zijn op bescheiden hardware.
Voor CPU-intensive werk biedt async echter geen voordelen en introduceert zelfs overhead. Tokio's scheduler is geoptimaliseerd voor cooperative multitasking bij .await punten; lange synchrone berekeningen blokkeren de worker thread en verhinderen andere tasks om vooruitgang te boeken. De oplossing is spawn_blocking voor CPU-bound operaties:
#[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())
}spawn_blocking voert de closure uit op een dedicated threadpool die gescheiden is van de async executor. Dit voorkomt dat lange berekeningen de event loop blokkeren, terwijl integratie met async code naadloos blijft via de ge-await-bare JoinHandle.
De beslissing tussen sync en async hangt af van de use case. Voor CLI tools, batch processors, of applicaties met enkele concurrente connections biedt synchrone code eenvoudiger debugging en kortere compile times. Voor services die duizenden simultane clients bedienen, real-time data pipelines, of microservices architectures wordt async Rust de standaard keuze.
Begin met oefenen!
Test je kennis met onze gespreksimulatoren en technische tests.
Conclusie
Rust's async ecosysteem combineert system-niveau performance met high-level ergonomie door futures als zero-cost abstractions te behandelen. De belangrijkste inzichten:
- Lazy evaluation: Futures starten niet automatisch; expliciete polling via
.awaitofspawn()is vereist - Tokio runtime: Biedt scheduler, I/O drivers en synchronisatieprimitieven voor productie-klare async applicaties
- Concurrency primitieven:
join!,select!, channels en semaphores dekken de meeste patterns zonder unsafe code - Error propagation: Standaard
Result<T, E>werkt naadloos met async/await syntax - Pin mechanisme: Garandeert memory safety voor self-referential futures zonder runtime cost
- Hybrid execution:
spawn_blockingintegreert CPU-bound werk in async contexten zonder de event loop te blokkeren
Tags
Delen
Gerelateerde artikelen

Rust Ownership en Borrowing: De Gids Die Alles Ontraadselt
Beheers Rust ownership en borrowing met praktische voorbeelden. Begrijp move-semantiek, referenties, lifetimes en de borrow checker om veilige, performante Rust-code te schrijven.

Ownership en Borrowing in Rust: Volledige Gids
Beheers het ownership- en borrowing-systeem van Rust. Eigendomsregels, referenties, lifetimes en geavanceerde patronen voor geheugenbeheer.

Rust Sollicitatievragen: Complete Gids 2026
De 25 meest gestelde Rust-sollicitatievragen. Ownership, borrowing, lifetimes, traits, async en concurrency met uitgebreide antwoorden en codevoorbeelden.