In Rust una async fn non esegue niente finché qualcuno non la interroga: capire Future,
Pin e Waker con un mini-runtime di trenta righe, e poi usare Tokio per spawn,
select!, canali, semafori e cancellazione.
Una funzione async non fa nulla da sola
In JavaScript o in Python, chiamare una funzione asincrona la mette subito in moto sull'event loop del
linguaggio. In Rust no: una async fn restituisce un valore, un Future, e quel valore è
inerte finché un esecutore non lo interroga. La documentazione di std::future::Future
lo dice in una riga: i future da soli sono inerti e devono essere interrogati (polled) attivamente perché
il calcolo avanzi. Rust non ha un runtime asincrono nella libreria standard: ne scegli uno, per esempio
Tokio.
Il costo di questo disegno si vede nel codice: devi capire tre pezzi che altrove restano nascosti.
Future::poll è il metodo che fa avanzare il calcolo; Waker è il modo in cui un future dice
all'esecutore «richiamami»; Pin garantisce che il future non venga spostato in memoria durante
l'esecuzione. Il vantaggio è che non c'è un runtime nascosto né un garbage collector: i future sono valori
normali, che puoi creare, comporre e scartare senza costi di scheduling che non hai scelto tu. Per il confronto
con la JVM, vedi l'articolo sui virtual thread.
Una dichiarazione di onestà: su questa macchina non c'è il compilatore Rust (rustc
e cargo assenti), e non ho installato toolchain. Nessun listato Rust di questo articolo è stato compilato
o eseguito: ogni uscita è marcata «dedotta, non eseguita». Il codice segue le API documentate di
std, Tokio e tokio-util consultate il 2026-11-12; compilalo prima di fidarti.
Future, poll e Waker: un mini-runtime in trenta righe
Il modo più rapido per capire l'asincrono in Rust è scrivere l'esecutore più piccolo possibile. Il listato definisce
un Future a mano (ContoAllaRovescia, che risponde «non ancora» tre volte), un
Waker che risveglia il thread corrente, e una funzione block_on che interroga il future in un
ciclo. Tutto usa solo la libreria standard.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use std::thread::{self, Thread};
// Un Future scritto a mano: risponde "non ancora" tre volte, poi "pronto".
struct ContoAllaRovescia {
restanti: u32,
}
impl Future for ContoAllaRovescia {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.restanti == 0 {
Poll::Ready("partito")
} else {
println!("poll: mancano {}", self.restanti);
self.restanti -= 1;
cx.waker().wake_by_ref(); // "richiamami": senza questa riga nessuno ripollerebbe
Poll::Pending
}
}
}
// Il Waker del nostro mini-runtime: risveglia il thread che sta facendo park().
struct Sveglia(Thread);
impl Wake for Sveglia {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
fn block_on<F: Future>(fut: F) -> F::Output {
let mut fut = Box::pin(fut);
let waker = Waker::from(Arc::new(Sveglia(thread::current())));
let mut cx = Context::from_waker(&waker);
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(valore) => return valore,
Poll::Pending => thread::park(), // dorme finche' qualcuno chiama wake()
}
}
}
async fn saluta() -> &'static str {
println!("dentro saluta()");
"ciao"
}
fn main() {
let f = saluta(); // non stampa nulla: il Future e' solo creato
println!("future creato");
println!("{}", block_on(f)); // ora viene pollato
println!("{}", block_on(ContoAllaRovescia { restanti: 3 }));
}
// Output dedotto, non eseguito:
// future creato
// dentro saluta()
// ciao
// poll: mancano 3
// poll: mancano 2
// poll: mancano 1
// partito
poll restituisce Poll::Pending o Poll::Ready(valore). Quando risponde
Pending il future ha un obbligo: assicurarsi che qualcuno chiami wake quando ha
senso riprovare. Qui lo fa subito con cx.waker().wake_by_ref(), la stessa scorciatoia usata
dall'esempio Delay del tutorial di Tokio; un vero timer lo chiamerebbe da un altro thread alla scadenza.
Senza quella riga block_on andrebbe in thread::park() e non si risveglierebbe mai, perché
nessuno gli avrebbe detto di riprovare. Il Waker è costruito dal trait Wake (stabile da Rust 1.51),
il cui unico metodo obbligatorio riceve Arc<Self>.
L'uscita attesa, dedotta dalla semantica e non osservata, ha un ordine istruttivo. «future creato» esce
prima di «dentro saluta()», perché saluta() ha solo costruito il future; il corpo gira
alla prima interrogazione di block_on. Poi tre righe «poll: mancano N», una per ogni
Pending, e infine «partito». È la differenza con altri linguaggi: il lavoro parte quando lo decidi tu,
non quando scrivi la chiamata.
Che cosa genera il compilatore da una async fn
Una async fn viene compilata in una macchina a stati: un enum con una variante per ogni punto di
.await, che conserva le variabili locali vive attraverso quel punto. Il tutorial di Tokio lo mostra
scrivendo a mano un MainFuture con stati State0, State1 e
Terminated, e osserva che «i future di Rust sono macchine a stati». poll avanza lo stato
il più possibile: se può completare restituisce Ready, altrimenti Pending. Chiamare
poll dopo il completamento è un errore, per questo la variante finale va in panic.
Questo spiega il termine «zero-cost» che si legge ovunque: il future è un valore sullo stack (o dove lo metti
tu), la sua dimensione è nota a tempo di compilazione e non serve allocare per crearlo. L'affermazione va
letta con prudenza: non è gratis ogni uso (se metti un future in un Box o lo passi a
tokio::spawn si alloca), e non ho misurato dimensioni o tempi. Il guadagno concreto è che puoi
scegliere dove allocare.
Pin: perché un future non deve spostarsi
Se la macchina a stati tiene un riferimento a una sua variabile locale attraverso un .await, quel
riferimento punta dentro il future stesso. Spostare il future in memoria lascerebbe il riferimento appeso.
Per questo poll prende self: Pin<&mut Self>: la documentazione di
std::pin descrive il pinning come lo stato in cui il codice sicuro non può spostare il valore, a meno
che il tipo implementi Unpin. Il listato è l'esempio da manuale, riscritto con una correzione rispetto a
versioni diffuse.
use std::marker::PhantomPinned;
use std::pin::Pin;
struct AutoRiferita {
testo: String,
vista: *const str, // punta DENTRO `testo`: se il valore si sposta, resta appeso
_pin: PhantomPinned, // rende il tipo !Unpin: una volta pinnato non si sposta piu'
}
impl AutoRiferita {
fn new(testo: &str) -> Pin<Box<Self>> {
let mut boxed = Box::pin(AutoRiferita {
testo: testo.to_string(),
vista: "" as *const str,
_pin: PhantomPinned,
});
let vista: *const str = boxed.testo.as_str();
// SAFETY: aggiorniamo un campo senza spostare il valore.
unsafe { boxed.as_mut().get_unchecked_mut().vista = vista; }
boxed
}
fn leggi(self: Pin<&Self>) -> &str {
// SAFETY: `vista` punta a `testo`, che non puo' muoversi perche' e' pinnato.
unsafe { &*self.vista }
}
}
fn main() {
let a = AutoRiferita::new("ciao");
println!("{}", a.as_ref().leggi());
}
// Output dedotto, non eseguito:
// ciao
PhantomPinned rende la struct !Unpin: senza, Pin non offrirebbe alcuna garanzia
perché il tipo potrebbe sempre essere spostato in sicurezza. Il puntatore vista viene impostato dopo
Box::pin, quando l'indirizzo è ormai fisso. Un dettaglio che gli esempi in circolazione sbagliano:
&boxed.data as *const str non compila, perché non si può convertire con as un
&String in *const str; serve passare per as_str() e lasciar fare la
coercizione, come qui. Nella pratica non scrivi mai una struttura simile: lo fa il compilatore per le tue
async fn. Ti serve sapere Pin quando metti un future in un Box
(Box::pin), quando lo tieni sullo stack (macro std::pin::pin!) o quando usi
select! su riferimenti.
Tokio: spawn, select! e timeout
Tokio è l'esecutore: il tutorial ufficiale spiega che le funzioni asincrone vanno passate a
tokio::spawn o essere la main annotata con #[tokio::main], e che in entrambi i
casi il future più esterno viene consegnato all'esecutore, che chiama Future::poll su di esso. Il
listato prova i due strumenti che userai di più: spawn, il cui JoinHandle è a sua volta un
future, e select!, che attende più rami e ne completa uno solo.
use std::time::Duration;
use tokio::time::sleep;
async fn primario() -> &'static str {
sleep(Duration::from_millis(300)).await;
"primario"
}
async fn secondario() -> &'static str {
sleep(Duration::from_millis(100)).await;
"secondario"
}
#[tokio::main]
async fn main() {
// spawn: il future parte subito su un thread del runtime; JoinHandle e' a sua volta un future
let handle = tokio::spawn(async { 6 * 7 });
println!("spawn: {}", handle.await.unwrap());
// select!: vince il primo branch che finisce, gli altri vengono lasciati cadere (drop)
tokio::select! {
r = primario() => println!("vince {r}"),
r = secondario() => println!("vince {r}"),
_ = sleep(Duration::from_secs(5)) => println!("timeout"),
}
}
// Output dedotto, non eseguito:
// spawn: 42
// vince secondario
Il select! ha tre rami: primario (300 ms), secondario (100 ms) e un timeout di
cinque secondi. Vince il secondario, e gli altri due vengono lasciati cadere (drop). Questa è la
cancellazione in Rust: la guida di Tokio dice che si cancella distruggendo il future, e che
siccome i future sono pigri l'operazione semplicemente non prosegue. Non c'è un'eccezione iniettata come in Python:
se il future ha acquisito risorse, è il suo Drop a rilasciarle. Il Cargo.toml richiede
le feature giuste, e un dettaglio: la macro #[tokio::main] serve macros e
rt-multi-thread.
[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
tokio-util = "0.7"
Questo blocco è uno schema da adattare al tuo progetto (nome del package e edizione le aggiunge
cargo new). Le feature sono opt-in: se ne dimentichi una, il compilatore ti segnala che la macro
o il modulo non esistono, non un errore a runtime.
Canali limitati e semafori: dare un limite alla concorrenza
tokio::spawn non pone limiti: migliaia di task partono tutti insieme. Due strumenti li governano.
Il canale mpsc::channel(n) è limitato: quando contiene n messaggi, send().await
sospende il produttore, ed è la forma più semplice di contropressione (tema dell'articolo su
backpressure e flow control). Il
Semaphore limita invece quanti task stanno contemporaneamente in una sezione.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, Semaphore};
use tokio::time::sleep;
#[tokio::main]
async fn main() {
// Canale limitato a 2: send().await sospende il produttore se la coda e' piena
let (tx, mut rx) = mpsc::channel::<u32>(2);
let produttore = tokio::spawn(async move {
for i in 0..5 {
tx.send(i).await.unwrap();
}
}); // `tx` viene rilasciato qui: il canale si chiude e recv() restituira' None
while let Some(v) = rx.recv().await {
sleep(Duration::from_millis(10)).await; // consumatore lento
println!("ricevuto {v}");
}
produttore.await.unwrap();
// Semaforo: al massimo 3 task dentro la sezione critica asincrona
let sem = Arc::new(Semaphore::new(3));
let attivi = Arc::new(AtomicUsize::new(0));
let picco = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..20 {
let (sem, attivi, picco) = (sem.clone(), attivi.clone(), picco.clone());
handles.push(tokio::spawn(async move {
let _permesso = sem.acquire().await.unwrap(); // vive fino a fine task
let ora = attivi.fetch_add(1, Ordering::SeqCst) + 1;
picco.fetch_max(ora, Ordering::SeqCst);
sleep(Duration::from_millis(20)).await;
attivi.fetch_sub(1, Ordering::SeqCst);
}));
}
for h in handles {
h.await.unwrap();
}
println!("picco <= 3: {}", picco.load(Ordering::SeqCst) <= 3);
}
// Output dedotto, non eseguito:
// ricevuto 0
// ricevuto 1
// ricevuto 2
// ricevuto 3
// ricevuto 4
// picco <= 3: true
Nella prima parte il produttore invia cinque valori in un canale da due posti, mentre il consumatore, lento
di 10 ms, li prende uno alla volta; recv() restituisce None quando tutti i
Sender sono stati rilasciati, e qui tx viene rilasciato alla fine del blocco
async move. Nella seconda, venti task chiedono un permesso da tre; _permesso (con il
nome, non solo _, altrimenti verrebbe rilasciato subito) vive fino alla fine del task. Il contatore
del picco lo verifica: dedotto, non è mai oltre tre.
Cancellazione cooperativa: CancellationToken
Per annullare un task dall'esterno, serve un segnale che il task possa attendere. In Tokio lo fornisce
CancellationToken, che però non sta nel crate tokio: è in
tokio_util::sync, e importarlo da tokio::sync è un errore frequente che il compilatore
rifiuta. La documentazione di tokio-util lo descrive come un
token per segnalare una richiesta di annullamento a uno o più task: ogni task attende il future restituito da
cancelled(), e cancel() lo risolve.
use std::time::Duration;
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
async fn lavoro(token: CancellationToken) -> Option<&'static str> {
tokio::select! {
_ = sleep(Duration::from_secs(10)) => Some("finito"),
_ = token.cancelled() => {
println!("lavoro: annullato, pulizia");
None
}
}
}
#[tokio::main]
async fn main() {
let token = CancellationToken::new();
let figlio = token.child_token(); // annullare il padre annulla anche il figlio
let handle = tokio::spawn(lavoro(figlio));
sleep(Duration::from_millis(100)).await;
token.cancel();
println!("risultato: {:?}", handle.await.unwrap());
}
// Output dedotto, non eseguito:
// lavoro: annullato, pulizia
// risultato: None
Il task lavoro mette in select! il suo lavoro e token.cancelled(). Il token
figlio (child_token) viene annullato quando si annulla il padre, quindi con un solo
cancel() si fermano tutti i task derivati. L'uscita dedotta: il messaggio di pulizia, poi
risultato: None. Rispetto a Go, dove il context è un parametro standard, qui il token è
una convenzione: nulla impedisce a un task di ignorarlo, e il compilatore non lo controlla.
La trappola della cancellation safety
Siccome select! lascia cadere i rami perdenti, ogni .await nei rami può essere
interrotto a metà. La documentazione di select! lo chiama cancellation safety: descrive
che cosa succede se un future viene distrutto prima di completare, ed elenca i metodi sicuri, tra cui
mpsc::Receiver::recv. Il caso tipico è un ciclo loop { select! { ... } } in cui uno dei rami
è un'operazione che consuma dati parzialmente: se perde la gara, i dati letti vanno persi. Prima di usare un
future in select! dentro un ciclo, controlla la sezione «Cancellation safety» della sua documentazione.
Se vuoi riusare un future tra più iterazioni, passalo per riferimento mutabile dopo averlo pinnato
(&mut fut con Box::pin o pin!): è il caso in cui Pin
riappare nel codice di ogni giorno. Il tema di far convivere cancellazione e pulizia ricorre nella serie: in
Go si risolve con context, in
Python con CancelledError.
Che cosa sai fare adesso
Sai spiegare perché una async fn non fa nulla finché non viene interrogata, che cosa chiedono
poll e Waker a un future, perché serve Pin e dove incontrarlo, e come lanciare,
limitare e annullare task con Tokio. Sai anche che i listati di questo articolo non sono stati compilati, e
che il passo successivo utile è farlo.
Il prossimo articolo cambia prospettiva: dopo i modelli, i guasti. Si parla di race condition e deadlock e di come riconoscerli. Per il quadro in cui il modello async di Rust si colloca accanto agli altri, vedi il confronto fra i modelli di concorrenza.
Fonti
- std::future::Future (documentazione di Rust) - i future sono inerti e vanno interrogati, contratto di poll e Waker, consultata il 2026-11-12
- std::pin (documentazione di Rust) - cosa significa pinning, Unpin, esempio di struct auto-referenziale, consultata il 2026-11-12
- std::task::Wake (documentazione di Rust) - il trait con wake(self: Arc<Self>) e wake_by_ref, stabile dalla 1.51, consultata il 2026-11-12
- Async in depth (tutorial di Tokio) - i future sono macchine a stati, l'esecutore chiama poll sul future più esterno, il Delay che si risveglia da solo, consultata il 2026-11-12
- Select (tutorial di Tokio) - il ramo che non completa viene distrutto, la cancellazione si ottiene distruggendo il future, consultata il 2026-11-12
- tokio::select! (docs.rs) - sezione Cancellation safety e metodi sicuri, consultata il 2026-11-12
- tokio_util::sync::CancellationToken (docs.rs) - il token vive in tokio-util, cancelled(), cancel() e child_token, consultata il 2026-11-12
Nessuna cifra di prestazioni è riportata in questo articolo: non ho potuto misurare nulla in Rust sulla mia macchina.

Commenti
Caricamento commenti...
Accedi per commentare