Salta ai contenuti

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.

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:

Terminal window
cargo new async_demo
cd async_demo
cargo add tokio --features full

Questo 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.

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è
▶ Provalo tu

Qui ci sono tre novità:

  • async fn crea una funzione che può fermarsi a metà e riprendere più tardi.
  • .await vuol dire «aspetta che questo finisca e, mentre aspettiamo, lascia girare gli altri task». Puoi usare .await solo dentro una funzione async.
  • #[tokio::main] avvia il runtime tokio ed esegue su di esso la tua async fn main. Senza, main non può essere async.

tokio::time::sleep è la versione async di «aspetta un attimo». Mette in pausa solo questo task, non l’intero thread.

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 avviato
Ciao da dentro il future!
Fatto
▶ Provalo tu

Pensa a un future come a un foglietto con l’ordinazione scritta. Scrivere il foglietto non cucina il piatto. Consegnarlo alla cucina (.await) sì.

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 ordini
▶ Provalo tu

Se 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.

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 scaricato
file 2 scaricato
file 3 scaricato
▶ Provalo tu

Guarda 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.

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 them

La 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).

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.

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).await per 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.

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è
▶ Provalo tu

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.

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 = 30
▶ Provalo tu

I quattro task partono subito e aspettano nello stesso momento, quindi ci vogliono circa 40 ms, non 10 + 20 + 30 + 40 = 100 ms.

  • 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 full e segna main con #[tokio::main].
  • Una async fn restituisce un future. I future sono pigri: non parte niente finché non fai .await.
  • Usa tokio::join! per aspettare più future insieme e tokio::spawn per avviare task e ottenere un JoinHandle.
  • Non usare mai std::thread::sleep nel codice async: usa tokio::time::sleep(...).await.
  • L’async serve per aspettare, non per il lavoro pesante sulla CPU. Per quello usa i thread.

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