Async/await in Rust
Immagina un ristorante. Nella pagina sulla concorrenza hai conosciuto i thread. È come assumere un cameriere per ogni tavolo. Funziona, ma i camerieri costano, e la maggior parte del tempo stanno lì fermi ad aspettare la cucina.
La programmazione asincrona (async) è come avere un solo cameriere che prende tante ordinazioni. Il cameriere prende l’ordine del tavolo 1, lo passa alla cucina e, mentre il piatto cuoce, va al tavolo 2. Nessuno resta con le mani in mano. È questa l’idea dietro async e .await in Rust: mentre un task aspetta (la rete, un file, un timer), un altro task può andare avanti.
L’async dà il meglio quando il tuo programma passa molto tempo ad aspettare: server web, client di API, app di chat, bot. Questa pagina usa tokio, il runtime async più popolare per Rust.
Configurare tokio
Sezione intitolata “Configurare tokio”La libreria standard di Rust ti dà le parole chiave async e .await, ma non il «motore» che esegue davvero il codice asincrono. Quel motore si chiama runtime, e tokio è quello usato dalla maggior parte dei progetti. Aggiungilo a un nuovo progetto:
cargo new async_democd async_democargo add tokio --features fullQuesto aggiunge al tuo Cargo.toml la riga:
[dependencies]tokio = { version = "1", features = ["full"] }La feature full attiva tutto (timer, task, rete e altro). Mentre impari è la scelta più semplice. Anche i pulsanti «Provalo tu» di questa pagina funzionano: il Playground di Rust include già tokio.
Il tuo primo programma async
Sezione intitolata “Il tuo primo programma async”use std::time::Duration;
async fn make_coffee() -> String { tokio::time::sleep(Duration::from_millis(20)).await; String::from("caffè")}
#[tokio::main]async fn main() { println!("Ordino..."); let drink = make_coffee().await; println!("Ecco il tuo {}", drink);}Output:
Ordino...Ecco il tuo caffèQui ci sono tre novità:
async fncrea una funzione che può fermarsi a metà e riprendere più tardi..awaitvuol dire «aspetta che questo finisca e, mentre aspettiamo, lascia girare gli altri task». Puoi usare.awaitsolo dentro una funzioneasync.#[tokio::main]avvia il runtime tokio ed esegue su di esso la tuaasync fn main. Senza,mainnon può essereasync.
tokio::time::sleep è la versione async di «aspetta un attimo». Mette in pausa solo questo task, non l’intero thread.
I future sono pigri
Sezione intitolata “I future sono pigri”Ecco la parte che sorprende quasi tutti. Chiamare una async fn non la esegue. Ti restituisce un future: un valore che significa «lavoro che verrà fatto più tardi». Non succede niente finché non fai .await:
async fn say_hello() { println!("Ciao da dentro il future!");}
#[tokio::main]async fn main() { let future = say_hello(); // non parte ancora niente println!("Future creato, ma non avviato");
future.await; // ora parte println!("Fatto");}Output:
Future creato, ma non avviatoCiao da dentro il future!FattoPensa a un future come a un foglietto con l’ordinazione scritta. Scrivere il foglietto non cucina il piatto. Consegnarlo alla cucina (.await) sì.
Eseguire task contemporaneamente con join!
Sezione intitolata “Eseguire task contemporaneamente con join!”Finora abbiamo aspettato una cosa alla volta. La vera forza sta nell’aspettare più cose insieme. tokio::join! esegue più future contemporaneamente e aspetta che siano finiti tutti. Ti restituisce i loro risultati in una tupla:
use std::time::Duration;use tokio::time::sleep;
async fn fetch_user() -> String { sleep(Duration::from_millis(30)).await; // fingiamo che sia una chiamata di rete String::from("Ana")}
async fn fetch_order_count() -> u32 { sleep(Duration::from_millis(20)).await; 3}
#[tokio::main]async fn main() { let (user, orders) = tokio::join!(fetch_user(), fetch_order_count()); println!("{} ha {} ordini", user, orders);}Output:
Ana ha 3 ordiniSe li aspettassi uno dopo l’altro, l’attesa totale sarebbe di circa 30 + 20 = 50 ms. Con join! le due attese avvengono nello stesso momento, quindi ci vogliono circa 30 ms, il tempo del più lento. Con vere chiamate di rete questa differenza cresce in fretta.
Avviare task con tokio::spawn
Sezione intitolata “Avviare task con tokio::spawn”join! è ottimo quando hai un numero fisso di future. Quando hai un elenco di lavori, usa tokio::spawn. Consegna un future al runtime come task separato, lo avvia subito e ti restituisce un JoinHandle. Più tardi fai .await sull’handle per avere il risultato:
use std::time::Duration;use tokio::time::sleep;
async fn download(id: u64) -> String { sleep(Duration::from_millis(50 - id * 10)).await; format!("file {} scaricato", id)}
#[tokio::main]async fn main() { let mut handles = Vec::new();
for id in 1..=3 { let handle = tokio::spawn(download(id)); // parte subito handles.push(handle); }
for handle in handles { let result = handle.await.unwrap(); println!("{}", result); }}Output:
file 1 scaricatofile 2 scaricatofile 3 scaricatoGuarda bene: il file 3 ha l’attesa più breve, quindi in realtà finisce per primo. Però stampiamo i risultati nell’ordine in cui facciamo await sugli handle, perciò l’output è sempre 1, 2, 3. I tre download sono comunque avvenuti nello stesso momento.
Perché .unwrap()? Fare await su un JoinHandle restituisce un Result, perché il task potrebbe essere andato in panic. In quel caso ottieni un Err invece di far crollare l’intero programma. Assomiglia molto a thread::spawn e .join() della pagina sulla concorrenza, ed è voluto.
Errori comuni
Sezione intitolata “Errori comuni”Dimenticare .await. Visto che i future sono pigri, se dimentichi .await il lavoro non viene mai fatto. Se usi il valore, il compilatore ti ferma:
async fn get_number() -> u32 { 5}
#[tokio::main]async fn main() { let n = get_number(); // .await dimenticato println!("{}", n + 1);}error[E0369]: cannot add `{integer}` to `impl Future<Output = u32>` --> src/main.rs:8:22 |8 | println!("{}", n + 1); | - ^ - {integer} | | | impl Future<Output = u32>Il messaggio (in inglese, come lo stampa rustc) dice che n è un future di un u32, non un u32. La soluzione è let n = get_number().await;. Se la funzione non restituisce niente e ti limiti a chiamarla, come save_file();, ricevi solo un warning, quindi leggi i tuoi warning:
warning: unused implementer of `Future` that must be used |7 | save_file(); | ^^^^^^^^^^^ | = note: futures do nothing unless you `.await` or poll themLa nota dice che i future non fanno niente finché non fai .await o non li interroghi (poll).
Usare std::thread::sleep dentro il codice async. Questo compila senza problemi, ed è per questo che è subdolo:
async fn slow_task() { std::thread::sleep(std::time::Duration::from_secs(1)); // blocca l'intero thread!}std::thread::sleep congela l’intero thread. Il runtime non può passare ad altri task, quindi su quel thread si ferma tutto: come un cameriere che si addormenta in piedi davanti a un tavolo. Dentro il codice async usa sempre tokio::time::sleep(...).await. Lo stesso vale per altri lavori lenti e bloccanti, come leggere file enormi con std::fs: tokio ha le versioni async (tokio::fs), oppure puoi usare tokio::task::spawn_blocking.
Aspettarsi che l’async renda più veloce il lavoro della CPU. L’async aiuta quando i task aspettano. Non aiuta quando i task sono occupati, per esempio a ridimensionare immagini o a macinare numeri. Un cameriere non cucina più in fretta prendendo più ordinazioni. Per il lavoro pesante sulla CPU usa i thread (o un crate come rayon).
Async o thread?
Sezione intitolata “Async o thread?”| Usa async quando… | Usa i thread quando… |
|---|---|
| Si passa quasi tutto il tempo ad aspettare (rete, database, timer) | Il lavoro è un calcolo pesante |
| Ti servono migliaia di task insieme (come in un server web) | Ti servono solo poche cose in parallelo |
| Usi crate async come reqwest o axum | Vuoi evitare di aggiungere un runtime come dipendenza |
Non devi per forza sceglierne uno solo. Molti programmi reali usano l’async per la rete e i thread per il lavoro pesante.
I tuoi task devono scambiarsi messaggi? Tokio ha il suo canale, tokio::sync::mpsc. Funziona come quello standard della pagina sui canali, solo che scrivi rx.recv().await, così l’attesa di un messaggio non blocca il thread.
Cosa ci si costruisce
Sezione intitolata “Cosa ci si costruisce”Quasi tutto l’ecosistema async di Rust è costruito su tokio. Due crate che vedrai ovunque:
- reqwest (in inglese): un client HTTP per chiamare API web. Scrivi
reqwest::get(url).awaitper scaricare una pagina. - axum (in inglese): un framework web per costruire server e API, creato dal team di tokio.
Entrambi si abbinano bene con serde_json per leggere e scrivere JSON.
Pratica
Sezione intitolata “Pratica”1. Prepara la colazione tutta insieme
Sezione intitolata “1. Prepara la colazione tutta insieme”Scrivi tre funzioni async: make_toast (aspetta 30 ms), fry_eggs (aspetta 20 ms) e brew_coffee (aspetta 10 ms). Ognuna restituisce una String con il nome del cibo. Eseguile tutte e tre contemporaneamente e stampa cosa è pronto. Suggerimento: tokio::join! prende più future e restituisce una tupla con i loro risultati.
Mostra la soluzione
use std::time::Duration;use tokio::time::sleep;
async fn make_toast() -> String { sleep(Duration::from_millis(30)).await; String::from("pane tostato")}
async fn fry_eggs() -> String { sleep(Duration::from_millis(20)).await; String::from("uova")}
async fn brew_coffee() -> String { sleep(Duration::from_millis(10)).await; String::from("caffè")}
#[tokio::main]async fn main() { let (toast, eggs, coffee) = tokio::join!(make_toast(), fry_eggs(), brew_coffee()); println!("Colazione: {}, {} e {}", toast, eggs, coffee);}Output:
Colazione: pane tostato, uova e caffèIl caffè è pronto per primo, ma la tupla torna sempre nell’ordine in cui hai elencato i future. L’intera colazione richiede circa 30 ms (il piatto più lento), non 60 ms.
2. Elevare al quadrato in background
Sezione intitolata “2. Elevare al quadrato in background”Scrivi async fn square_later(n: u64) -> u64, che aspetta 10 * n millisecondi e poi restituisce n * n. In main, avvia un task per ogni numero da 1 a 4, poi aspettali tutti e stampa il totale. Suggerimento: metti ogni handle di tokio::spawn(...) in un Vec, poi scorri gli handle e somma handle.await.unwrap().
Mostra la soluzione
use std::time::Duration;use tokio::time::sleep;
async fn square_later(n: u64) -> u64 { sleep(Duration::from_millis(10 * n)).await; n * n}
#[tokio::main]async fn main() { let mut handles = Vec::new(); for n in 1..=4 { handles.push(tokio::spawn(square_later(n))); }
let mut total = 0; for handle in handles { total += handle.await.unwrap(); } println!("totale = {total}");}Output:
totale = 30I quattro task partono subito e aspettano nello stesso momento, quindi ci vogliono circa 40 ms, non 10 + 20 + 30 + 40 = 100 ms.
Riepilogo
Sezione intitolata “Riepilogo”- L’async permette a un solo thread di gestire tanti task, passando dall’uno all’altro mentre aspettano.
- Rust ha bisogno di un runtime per eseguire il codice async. Aggiungi tokio con
cargo add tokio --features fulle segnamaincon#[tokio::main]. - Una
async fnrestituisce un future. I future sono pigri: non parte niente finché non fai.await. - Usa
tokio::join!per aspettare più future insieme etokio::spawnper avviare task e ottenere unJoinHandle. - Non usare mai
std::thread::sleepnel codice async: usatokio::time::sleep(...).await. - L’async serve per aspettare, non per il lavoro pesante sulla CPU. Per quello usa i thread.
Verifica veloce
Sezione intitolata “Verifica veloce”Verifica veloce
1. Che cosa succede se chiami una async fn senza .await?
2. Che cosa fa #[tokio::main]?
3. Due future aspettano 30 ms e 20 ms. Quanto dura, più o meno, tokio::join! su entrambi?
4. Perché std::thread::sleep è un problema dentro il codice async?
5. Per quale lavoro l'async è più adatto?
Punteggio: 0 / 5
Per approfondire
Sezione intitolata “Per approfondire”- Fundamentals of Asynchronous Programming — The Rust Book (in inglese): il capitolo ufficiale su future,
async,.awaite stream. - Il tutorial di tokio (in inglese): una guida pratica che costruisce un piccolo server simile a Redis, tra task, stato condiviso e canali.
- tokio su docs.rs (in inglese): il riferimento delle API per
tokio::spawn,tokio::time,tokio::synce il resto. - Asynchronous Programming in Rust (l’async book) (in inglese): uno sguardo più approfondito su come funzionano future ed executor dietro le quinte.