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 restituitoReady
. 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 comportaIterator::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.
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.
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.
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.
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.
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
.
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
eUnpin
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 richiedonoPin
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
eUnpin
, e le regole che devono rispettare, sono trattati ampiamente nella documentazione API perstd::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
è unastruct
che implementaFuture
e ci consente di nominare la lifetime del reference aself
conNext<'_, Self>
, in modo cheawait
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!