Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Uno Sguardo Più Da Vicino ai Trait per Async

Nel corso del capitolo, abbiamo utilizzato i trait Future, Pin, Unpin, Stream e StreamExt in vari modi. Finora, però, abbiamo evitato di addentrarci troppo nei dettagli di come funzionano o di come interagiscono, il che va bene per la maggior parte delle volte che li userai nel tuo lavoro quotidiano con Rust. A volte, però, ti capiterà di incontrare situazioni in cui avrai bisogno di comprendere queste cose più in dettaglio. In questa sezione, ci addentreremo il giusto in questi dettagli per aiutarti in quegli scenari, lasciando comunque il vero e proprio approfondimento completo alla documentazione specifica di quello che ti interessa.

Il Trait Future

Iniziamo a dare un’occhiata più da vicino a come funziona il trait Future. Ecco come Rust lo definisce:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

Quella definizione di trait include alcuni nuovi type e anche una sintassi che non abbiamo visto prima d’ora, quindi esaminiamola un pezzo per volta.

Per prima cosa, il type associato Output di Future dice in cosa si risolve la future. Questo è analogo al type associato Item per il trait Iterator. In secondo luogo, Future ha anche il metodo poll, che prende un reference speciale Pin per il suo parametro self e un reference mutabile a un type Context, e restituisce un Poll<Self::Output>. Parleremo più avanti di Pin e Context. Per ora, concentriamoci su cosa restituisce il metodo, il type Poll:

#![allow(unused)]
fn main() {
enum Poll<T> {
    Ready(T),
    Pending,
}
}

Questo type Poll è simile a un Option. Ha una variante che ha un valore, Ready(T), e una che non ce l’ha, Pending (in attesa). Tuttavia, Poll significa qualcosa di molto diverso da Option! La variante Pending indica che la future ha ancora lavoro da fare, quindi il chiamante dovrà controllare di nuovo più tardi. La variante Ready indica che la future ha finito il suo lavoro e il valore T è disponibile.

Nota: Con la maggior parte delle future, il chiamante non dovrebbe chiamare poll di nuovo dopo che la future ha restituito Ready. Molte future andranno in panic se interrogate di nuovo dopo essere diventate pronte. Le future che possono essere interrogate di nuovo lo diranno esplicitamente nella loro documentazione. Questo è simile a come si comporta Iterator::next.

Quando vedi codice che usa await, Rust lo compila dietro le quinte in codice che chiama poll. Se guardi indietro al Listato 17-4, dove abbiamo stampato il titolo della pagina per un singolo URL, Rust lo compila in qualcosa di simile (anche se non esattamente) a questo:

match titolo_pagina(url).poll() {
    Ready(valore) => match titolo_pagina {
        Some(titolo) => println!("Il titolo per {url} era {titolo}"),
        None => println!("{url} non aveva titolo"),
    }
    Pending => {
        // Ma cosa mettiamo qui?
    }
}

Cosa dovremmo fare quando la future è ancora Pending? Abbiamo bisogno di un modo per riprovare, e riprovare, e riprovare, fino a quando la future è finalmente pronta. In altre parole, abbiamo bisogno di un ciclo:

let mut titolo_pagina_fut = titolo_pagina(url);
loop {
    match titolo_pagina_fut.poll() {
        Ready(valore) => match titolo_pagina {
            Some(titolo) => println!("Il titolo per {url} era {titolo}"),
            None => println!("{url} non aveva titolo"),
        }
        Pending => {
            // continua
        }
    }
}

Se Rust lo compilasse esattamente in quel codice, però, ogni await sarebbe bloccante, esattamente l’opposto di ciò che volevamo! Invece, Rust si assicura che il ciclo possa cedere il controllo a qualcosa che può mettere in pausa il lavoro su questa future per lavorare su altre future e poi controllare di nuovo questo più tardi. Come abbiamo visto, quel qualcosa è un runtime async, e questo lavoro di pianificazione e coordinamento è uno dei suoi compiti principali.

In precedenza nel capitolo, abbiamo descritto l’attesa su rx.recv. La chiamata recv restituisce una future, e attendere la future la richiama. Abbiamo notato che un runtime metterà in pausa la future fino a quando non è pronta con Some(messaggio) o None quando il canale si chiude. Ora che comprendi meglio il trait Future, e specificamente Future::poll, possiamo vedere come funziona. Il runtime sa che la future non è pronta quando restituisce Poll::Pending. Al contrario, il runtime sa che la future è pronta e la avanza quando poll restituisce Poll::Ready(Some(messaggio)) o Poll::Ready(None).

I dettagli esatti di come un runtime faccia ciò vanno oltre lo scopo di questo libro, ma la chiave è vedere i meccanismi di base delle future: un runtime interroga ogni future di cui è responsabile, rimettendo la future a dormire quando non è ancora pronta.

I Trait Pin e Unpin

Quando abbiamo introdotto l’idea di pinning (fissare) nel Listato 17-16, ci siamo imbattuti in un messaggio di errore molto complicato. Ecco la parte rilevante di esso di nuovo:

error[E0277]: `{async block@src/main.rs:10:23: 10:33}` cannot be unpinned
  --> src/main.rs:48:32
   |
48 |         trpl::join_all(future).await;
   |                                ^^^^^ the trait `Unpin` is not implemented for `{async block@src/main.rs:10:23: 10:33}`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<{async block@src/main.rs:10:23: 10:33}>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> /home/utente/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

Questo messaggio di errore ci dice non solo che dobbiamo fissare i valori, ma anche perché il pinning è richiesto. La funzione trpl::join_all restituisce una struct chiamata JoinAll. Quella struct è generica su un type F, che è vincolato a implementare il trait Future. Attendere direttamente una future con await blocca implicitamente la future. Ecco perché non abbiamo bisogno di usare pin! ovunque vogliamo attendere le future.

Tuttavia, qui non stiamo attendendo direttamente una future. Invece, costruiamo un nuova future, JoinAll, passando una collezione di future alla funzione join_all. La firma per join_all richiede che i type degli elementi nella collezione implementino tutti il trait Future, e Box<T> implementa Future solo se il T che incapsula è una future che implementa il trait Unpin.

Sono un sacco di informazioni da assorbire! Per capire davvero, approfondiamo un po’ di più come funziona effettivamente il trait Future, in particolare riguardo al pinning.

Guarda di nuovo la definizione del trait Future:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    // Metodo richiesto
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

Il parametro cx e il suo type Context sono la chiave per capire come un runtime sa effettivamente quando controllare una data future pur rimanendo lazy. Ancora una volta, i dettagli di come ciò funzioni vanno oltre lo scopo di questo capitolo, e generalmente devi pensare a questo solo quando scrivi un’implementazione personalizzata di Future. Ci concentreremo invece sul type per self, poiché è la prima volta che vediamo un metodo in cui self ha un’annotazione di type. Un’annotazione di type per self funziona come le annotazioni di type per altri parametri di funzione, ma con due differenze chiave:

  • Indica a Rust quale type deve essere self affinché il metodo possa essere chiamato.
  • Non può essere semplicemente qualsiasi type. È limitato al type su cui il metodo è implementato, a un reference o a un puntatore intelligente a quel type, o a un Pin che incapsula un reference a quel type.

Vedremo di più su questa sintassi nel Capitolo 18. Per ora, è sufficiente sapere che se vogliamo interrogare una future per controllare se è Pending o Ready(Output), abbiamo bisogno di un reference mutabile al type incapsulato in Pin.

Pin è un wrapper per type simili a puntatori come &, &mut, Box e Rc. (Tecnicamente, Pin funziona con type che implementano i trait Deref o DerefMut, ma questo è effettivamente equivalente a lavorare solo con puntatori.) Pin non è un puntatore e non ha alcun comportamento proprio, come invece Rc e Arc fanno con il conteggio dei reference; è puramente uno strumento che il compilatore può utilizzare per imporre vincoli sull’uso dei puntatori.

Ricordando che await è implementato in termini di chiamate a poll, iniziamo a capire il messaggio di errore che abbiamo visto in precedenza, ma quello era in termini di Unpin, non di Pin. Quindi, come si relazionano esattamente Pin e Unpin, e perché il Future ha bisogno che self sia in un type Pin per chiamare poll?

Come menzionato in precedenza nel capitolo, una serie di punti di attesa in una future viene compilata in una macchina a stati, e il compilatore si assicura che quella macchina a stati segua tutte le normali regole di sicurezza di Rust, inclusi il prestito e la ownership. Per far funzionare tutto ciò, Rust guarda quali dati sono necessari tra un punto di attesa e l’altro, o tra il punto di attesa e la fine del blocco async. Crea quindi una variante corrispondente nella macchina a stati compilata. Ogni variante ottiene l’accesso di cui ha bisogno ai dati che verranno utilizzati in quella sezione del codice sorgente, sia prendendo possesso di quei dati sia ottenendo un reference mutabile o immutabile ad essi.

Finora, tutto bene: se commettiamo errori riguardo alla ownership o ai riferimenti in un dato blocco async, il borrow checker ce lo dirà. Quando vogliamo spostare la future che corrisponde a quel blocco, come spostarla in un Vec da passare a join_all, le cose diventano più complicate.

Quando spostiamo una future, sia mettendola in una struttura dati da utilizzare come iteratore con join_all o restituendola da una funzione, significa effettivamente spostare la macchina a stati che Rust crea per noi. E a differenza della maggior parte degli altri type in Rust, le future che Rust crea per i blocchi async possono finire con riferimenti a se stesse nei campi di una data variante, come mostrato nell’illustrazione semplificata nella Figura 17-4.

Una tabella a colonna singola e
tre righe che rappresenta una `future`, fut1, che ha valori di dati 0 e 1 nelle
prime due righe e una freccia che punta dalla terza riga di nuovo alla seconda
riga, rappresentando un riferimento interno all’interno della `future`.

Figura 17-4: Un tipo di dato auto-referenziale.

Per impostazione predefinita, però, qualsiasi oggetto che ha un riferimento a se stesso è insicuro da spostare, perché i riferimenti puntano sempre all’indirizzo di memoria effettivo di ciò a cui si riferiscono (vedi Figura 17-5). Se sposti la struttura dati stessa, quei riferimenti interni rimarranno puntati alla vecchia posizione. Tuttavia, quella posizione di memoria è ora non valida. Per un verso, il suo valore non verrà aggiornato quando apporti modifiche alla struttura dati. Per un altro e più importante motivo, il computer è ora libero di riutilizzare quella memoria per altri scopi! Potresti finire per leggere dati completamente non correlati in seguito.

Due tabelle, che raffigurano
due `future`, fut1 e fut2, ciascuna delle quali ha una colonna e tre righe,
rappresentando il risultato di aver spostato una `future` da fut1 a fut2. La
prima, fut1, è grigia, con un punto interrogativo in ciascun indice,
rappresentando una memoria sconosciuta. La seconda, fut2, ha 0 e 1 nella prima e
nella seconda riga e una freccia che punta dalla sua terza riga di nuovo alla
seconda riga di fut1, rappresentando un puntatore che fa riferimento alla
vecchia posizione in memoria della `future` prima che fosse spostata.

Figura 17-5: Il risultato non sicuro di spostare un tipo di dato auto-referenziale

Teoricamente, il compilatore Rust potrebbe cercare di aggiornare ogni riferimento a un oggetto ogni volta che viene spostato, ma ciò potrebbe aggiungere un notevole sovraccarico di prestazioni, specialmente se un’intera rete di riferimenti deve essere aggiornata. Se potessimo invece assicurarci che la struttura dati in questione non si muova in memoria, non dovremmo aggiornare alcun riferimento. Questo è esattamente ciò che il borrow checker di Rust richiede: nel codice sicuro, impedisce di spostare qualsiasi elemento con un riferimento attivo.

Pin si basa su questo per darci la garanzia esatta di cui abbiamo bisogno. Quando fissiamo un valore incapsulando un puntatore a quel valore in Pin, non può più muoversi. Quindi, se hai Pin<Box<QualcheType>>, in realtà fissi il valore QualcheType, non il puntatore Box. La Figura 17-6 illustra questo processo.

Tre scatole disposte
affiancate. La prima è etichettata “Pin”, la seconda “b1”, e la terza “pinned”.
All’interno di “pinned” c’è una tabella etichettata “fut”, con una singola
colonna; rappresenta una `future` con celle per ciascuna parte della struttura
dati. La sua prima cella ha il valore “0”, la sua seconda cella ha una freccia
che esce da essa e punta alla quarta e ultima cella, che ha il valore “1”, e la
terza cella ha linee tratteggiate e un’ellissi per indicare che potrebbero
esserci altre parti nella struttura dati. Insieme, la tabella “fut” rappresenta
una `future` che è auto-referenziale. Una freccia esce dalla scatola etichettata
“Pin”, passa attraverso la scatola etichettata “b1” e termina all’interno della
scatola “pinned” nella tabella “fut”.

Figura 17-6: Pinning di una Box che punta a un type di future auto-referenziale.

In effetti, il puntatore Box può ancora muoversi liberamente. Ricorda: ci interessa assicurarci che i dati a cui si fa riferimento rimangano al loro posto. Se un puntatore si muove, ma i dati a cui punta sono nello stesso posto, come nella Figura 17-7, non c’è alcun problema potenziale. Come esercizio indipendente, dai un’occhiata alla documentazione per i type così come a quella del modulo std::pin e prova a capire come faresti questo con un Pin che incapsula una Box. La chiave è che il type auto-referenziale stesso non può muoversi, perché è ancora fissato.

Quattro scatole disposte in tre
colonne approssimative, identiche al diagramma precedente con una modifica alla
seconda colonna. Ora ci sono due scatole nella seconda colonna, etichettate “b1”
e “b2”, “b1” è grigia, e la freccia da “Pin” passa attraverso “b2” invece di
“b1”, indicando che il puntatore si è spostato da “b1” a “b2”, ma i dati in
“pinned” non si sono mossi.

Figura 17-7: Spostare una Box che punta a un type di future auto-referenziale.

Tuttavia, la maggior parte dei type è perfettamente sicura da spostare, anche se sono incapsulati da Pin. Dobbiamo pensare al pinning solo quando gli elementi hanno reference interni. I valori primitivi come numeri e booleani sono sicuri perché ovviamente non hanno reference interni. Né la maggior parte dei type con cui normalmente lavori in Rust. Puoi spostare un Vec, ad esempio, senza preoccuparti. Dato ciò che abbiamo visto finora, se hai un Pin<Vec<String>>, dovresti fare tutto tramite le API sicure ma restrittive fornite da Pin, anche se un Vec<String> è sempre sicuro da spostare se non ci sono altri riferimenti ad esso. Abbiamo bisogno di un modo per dire al compilatore che va bene spostare gli elementi in casi come questo, ed è qui che entra in gioco Unpin.

Unpin è un trait marcatore, simile ai trait Send e Sync che abbiamo visto nel Capitolo 16, e quindi non ha funzionalità propria. I trait marcatori esistono solo per dire al compilatore che è sicuro utilizzare il type che implementa un dato trait in un contesto particolare. Unpin informa il compilatore che un dato type non ha bisogno di verificare alcuna garanzia sul fatto che il valore in questione possa essere spostato in sicurezza.

Proprio come per Send e Sync, il compilatore implementa automaticamente Unpin per tutti i type per i quali può dimostrare che è sicuro. Un caso speciale, di nuovo simile a Send e Sync, è dove Unpin non è implementato per un type. La notazione per questo è impl !Unpin for QualcheType, dove QualcheType è il nome di un type che deve mantenere quelle garanzie per essere sicuro ogni volta che un puntatore a quel type viene utilizzato in un Pin.

In altre parole, ci sono due cose da tenere a mente riguardo alla relazione tra Pin e Unpin. Prima di tutto, Unpin è il caso “normale”, e !Unpin è il caso speciale. In secondo luogo, se un type implementa Unpin o !Unpin importa solo quando stai usando un puntatore fissato a quel type come Pin<&mut QualcheType>.

Per andare nel concreto, pensa a una String: ha una lunghezza e i caratteri Unicode che la compongono. Possiamo incapsulare una String in Pin, come visto nella Figura 17-8. Tuttavia, String implementa automaticamente Unpin, così come la maggior parte degli altri type in Rust.

Flusso di lavoro concorrente

Figura 17-8: Pinning di una String; la linea tratteggiata indica che la String implementa il trait Unpin, e quindi non è fissata.

Di conseguenza, possiamo fare cose che sarebbero illegali se String implementasse !Unpin, come sostituire una stringa con un’altra nella stessa posizione in memoria, come nella Figura 17-9. Questo non viola il contratto di Pin, perché String non ha riferimenti interni che la rendano insicura da spostare! È proprio per questo che implementa Unpin piuttosto che !Unpin.

Flusso di lavoro concorrente

Figura 17-9: Sostituzione della String con un’altra String completamente diversa in memoria.

Ora sappiamo abbastanza per comprendere gli errori segnalati per quella chiamata a join_all dal Listato 17-16. Inizialmente abbiamo cercato di spostare le future prodotte dai blocchi async in un Vec<Box<dyn Future<Output = ()>>>, ma come abbiamo visto, quelle future possono avere riferimenti interni, quindi non implementano Unpin. Devono essere fissate, e poi possiamo passare il type Pin nel Vec, certi che i dati sottostanti nelle future non verranno spostati.

Pin e Unpin sono principalmente importanti per costruire librerie di basso livello, o quando stai costruendo un runtime stesso, piuttosto che per il codice Rust quotidiano. Tuttavia, quando vedi questi trait nei messaggi di errore, ora avrai un’idea migliore di come correggere il tuo codice!

Nota: Questa combinazione di Pin e Unpin rende possibile implementare in modo sicuro un’intera classe di type complessi in Rust che altrimenti risulterebbero difficili a causa della loro auto-referenzialità. I type che richiedono Pin si presentano più comunemente nella programmazione asincrona di Rust, ma di tanto in tanto potresti vederli anche in altri contesti.

I dettagli specifici su come funzionano Pin e Unpin, e le regole che devono rispettare, sono trattati ampiamente nella documentazione API per std::pin, quindi se sei interessato a saperne di più, quello è un ottimo punto di partenza.

Se vuoi capire come funzionano le cose sotto il cofano in modo ancora più dettagliato, consulta i capitoli su gestione dell’esecuzione e pinning di Asynchronous Programming in Rust.

Il Trait Stream

Ora che hai una comprensione più profonda dei trait Future, Pin e Unpin, possiamo rivolgere la nostra attenzione al trait Stream. Come hai appreso in precedenza nel capitolo, gli stream sono simili agli iteratori asincroni. A differenza di Iterator e Future, tuttavia, Stream non ha una definizione nella libreria standard al momento della scrittura, ma c’è una definizione molto comune dal crate futures utilizzata in tutto l’ecosistema.

Rivediamo le definizioni dei trait Iterator e Future prima di vedere come un trait Stream potrebbe unirli. Da Iterator, abbiamo l’idea di una sequenza: il suo metodo next fornisce un Option<Self::Item>. Da Future, abbiamo l’idea di prontezza nel tempo: il suo metodo poll fornisce un Poll<Self::Output>. Per rappresentare una sequenza di elementi che diventano pronti nel tempo, definiamo un trait Stream che mette insieme queste caratteristiche:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;

    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}
}

Il trait Stream definisce un type associato chiamato Item per il type degli elementi prodotti dallo stream. Questo è simile a Iterator, dove possono esserci da zero a molti elementi, e a differenza di Future, dove c’è sempre un singolo Output, anche se è il type unitario ().

Stream definisce anche un metodo per ottenere quegli elementi. Lo chiamiamo poll_next, per chiarire che interroga nello stesso modo in cui fa Future::poll e produce una sequenza di elementi nello stesso modo in cui fa Iterator::next. Il suo type di ritorno combina Poll con Option. Il type esterno è Poll, perché deve essere controllato per prontezza, proprio come una future. Il type interno è Option, perché deve segnalare se ci sono altri messaggi, proprio come fa un iteratore.

Qualcosa di molto simile a questa definizione diverrà probabilmente parte della libreria standard di Rust in futuro. Nel frattempo, fa parte dell’arsenale della maggior parte dei runtime, quindi puoi fare affidamento su di essa, e tutto ciò che copriremo successivamente dovrebbe generalmente applicarsi!

Nell’esempio che abbiamo visto nella sezione sugli stream, però, non abbiamo usato poll_next o Stream, ma invece abbiamo usato next e StreamExt. Potremmo lavorare direttamente in termini dell’API poll_next scrivendo a mano le nostre macchine a stati Stream, ovviamente, proprio come potremmo lavorare con le future direttamente tramite il loro metodo poll. Tuttavia, usare await è molto più piacevole, e il trait StreamExt fornisce il metodo next in modo da poter fare proprio questo:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;
    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Self::Item>>;
}

trait StreamExt: Stream {
    async fn next(&mut self) -> Option<Self::Item>
    where
        Self: Unpin;

    // altri metodi...
}
}

Nota: La definizione effettiva che abbiamo utilizzato in precedenza nel capitolo appare leggermente diversa da questa, perché supporta versioni di Rust che non supportavano ancora l’uso di funzioni async nei trait. Di conseguenza, appare in questo modo:

fn next(&mut self) -> Next<'_, Self> where Self: Unpin;

Quel type Next è una struct che implementa Future e ci consente di nominare la lifetime del reference a self con Next<'_, Self>, in modo che await possa funzionare con questo metodo.

Il trait StreamExt è anche la casa di tutti i metodi interessanti disponibili per l’uso con gli stream. StreamExt è implementato automaticamente per ogni type che implementa Stream, ma questi trait sono definiti separatamente per consentire alla comunità di aggiungere API extra senza influenzare il trait fondamentale.

Nella versione di StreamExt utilizzata nel crate trpl, il trait non solo definisce il metodo next, ma fornisce anche un’implementazione predefinita di next che gestisce correttamente i dettagli della chiamata a Stream::poll_next. Questo significa che anche quando hai bisogno di scrivere il tuo type di stream, devi solo implementare Stream, e poi chiunque utilizzi il tuo type può utilizzare StreamExt e i suoi metodi con esso automaticamente.

Questo è tutto ciò che tratteremo per i dettagli di basso livello su questi trait. Per concludere, vedremo come future (inclusi gli stream), task e thread si integrano tutti insieme!