Salta al contenuto principale

06 - Rust Async e Tokio: Future, Pin, Waker e il Modello Zero-Cost Concurrency

Una async fn in Rust non fa nulla finché qualcuno non la interroga: Future, Pin e Waker con un mini-runtime, poi Tokio con select!, canali e cancellazione.

Cosa ne pensi di questo articolo?

06 - Rust Async e Tokio: Future, Pin, Waker e il Modello Zero-Cost Concurrency
ConcurrencyFoto:Daniil KomovPexels

In Breve

Una async fn in Rust non fa nulla finché qualcuno non la interroga: Future, Pin e Waker con un mini-runtime, poi Tokio con select!, canali e cancellazione.

Concurrency 13 min

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.

rust async tokio future pin waker select! async rust zero-cost

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

Nessuna cifra di prestazioni è riportata in questo articolo: non ho potuto misurare nulla in Rust sulla mia macchina.

Articolo precedente ← Java Virtual Threads (Loom)
Prossimo nella serie Race Condition e Deadlock →

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti