Uno Sguardo Più Da Vicino ai Trait per Async
Nel corso del capitolo, abbiamo utilizzato i trait Future, 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 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() { pub 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 (occupata). 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: È raro dover chiamare
polldirettamente, ma se devi, tieni a mente che con la maggior parte delle future, il chiamante non dovrebbe chiamarepolldi 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. Ritornando per un attimo 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.
Nella sezione “Inviare Dati Tra Due Task Usando il Passaggio di
Messaggi”, 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 è pronta o con Some(messaggio) o con 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.
Il Type Pin e il Trait Unpin
Nel Listato 17-13 abbiamo usato la macro trpl::join! per unire e aspettare tre
future. È tuttavia comune avere una collezione come un vettore che contiene un
certo numero di future non conoscibile se non durante l’esecuzione del
programma. Apportiamo delle modifiche al Listato 17-13 per mettere le tre
future in un vettore e chiamare la funzione trpl::join_all al posto della
macro, cosa che per ora non si compilerà.
extern crate trpl; // necessario per test mdbook
use std::time::Duration;
fn main() {
trpl::block_on(async {
let (tx, mut rx) = trpl::channel();
let tx1 = tx.clone();
let tx1_fut = async move {
let valori = vec![
String::from("ciao"),
String::from("dalla"),
String::from("future"),
String::from("!!!"),
];
for val in valori {
tx1.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
};
let rx_fut = async {
while let Some(valore) = rx.recv().await {
println!("ricevuto '{valore}'");
}
};
let tx_fut = async move {
// --taglio--
let valori = vec![
String::from("altri"),
String::from("messaggi"),
String::from("per"),
String::from("te"),
];
for val in valori {
tx.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
};
let future: Vec<Box<dyn Future<Output = ()>>> =
vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];
trpl::join_all(future).await;
});
}
Incapsuliamo ciascuna future in una Box rendendole oggetti trait, proprio
come abbiamo fatto nella sezione “Restituire Errori dalla Funzione
esegui” del Capitolo 12. (Parleremo degli oggetti trait
in dettaglio nel Capitolo 18.) Usare oggetti trait ci permette di trattare
ciascuna delle future anonime prodotte da questi type come fossero il
medesimo type, perché tutti implementano il trait Future.
Questo potrebbe essere sorprendente. Dopotutto, nessuno dei blocchi async
restituisce nulla, quindi ciascuno produce un Future<Output = ()>. Ricorda che
Future è un trait, e che il compilatore crea una enum univoca per ogni
blocco async anche se hanno type di output identici. Non puoi mettere due
struct scritte a mano diverse in un Vec, e la stessa regola si applica alle
enum diverse generate dal compilatore.
Passiamo quindi la collezione di future alla funzione trpl::join_all e
aspettiamo il risultato. Tuttavia, questa modifica non viene compilata; ecco la
parte rilevante dei messaggi di errore:
error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
--> src/main.rs:51:32
|
51 | trpl::join_all(future).await;
| ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
|
= note: consider using the `pin!` macro
consider using `Box::pin` if you need to access the pinned value outside of the current scope
La nota in questo messaggio di errore ci dice che dovremmo usare la macro pin!
per fissare i valori, il che significa incapsularli nel type Pin che
garantisce il fatto che questi valori non vengano spostati nella memoria. Il
messaggio di errore dice che il pinning è richiesto perché dyn Future<Output = ()> deve implementare il trait Unpin, cosa che al momento non fa.
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 di quale type deve essere
selfaffinché 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
Pinche incapsula un reference a quel type.
Esploreremo maggiormente 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
reference e puntatori intelligenti.) Pin di per sé 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, o prendendo possesso di quei dati o ottenendo un reference mutabile o immutabile ad essi.
Finora, tutto bene: se commettiamo errori riguardo alla ownership o ai
reference 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 può capitare abbiano 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 operazioni impattando le 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 a ciò che serve il borrow checker di Rust: 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
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
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
vedi 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-23. 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 automaticamente Unpin. Una volta fissate, poi possiamo
passare il risultante type Pin nel Vec, certi che i dati sottostanti nelle
future non verranno spostati. Il Listato 17-24 mostra come sistemare il
codice chiamando la macro pin! dove ognuna delle tre future è definita e
sistemare il type dell’oggetto trait.
extern crate trpl; // necessario per test mdbook use std::pin::{Pin, pin}; // --taglio-- use std::time::Duration; fn main() { trpl::block_on(async { let (tx, mut rx) = trpl::channel(); let tx1 = tx.clone(); let tx1_fut = pin!(async move { // --taglio-- let valori = vec![ String::from("ciao"), String::from("dalla"), String::from("future"), String::from("!!!"), ]; for val in valori { tx1.send(val).unwrap(); trpl::sleep(Duration::from_secs(1)).await; } }); let rx_fut = pin!(async { // --taglio-- while let Some(valore) = rx.recv().await { println!("ricevuto '{valore}'"); } }); let tx_fut = pin!(async move { // --taglio-- let valori = vec![ String::from("altri"), String::from("messaggi"), String::from("per"), String::from("te"), ]; for val in valori { tx.send(val).unwrap(); trpl::sleep(Duration::from_secs(1)).await; } }); let future: Vec<Pin<&mut dyn Future<Output = ()>>> = vec![tx1_fut, rx_fut, tx_fut]; trpl::join_all(future).await; }); }
pin! per consentire alle future di essere spostate nel vettoreQuesto esempio ora si compila ed esegue, e possiamo aggiungere o togliere future dal vettore durante l’esecuzione aspettandole tutte.
Pin e Unpin sono principalmente importanti per costruire librerie di basso
livello, o quando stai costruendo un vero e proprio runtime, piuttosto che per
il codice Rust quotidiano. Tuttavia, quando vedi questo trait nei messaggi di
errore, ora avrai un’idea migliore di come correggere il tuo codice!
Nota: Questa combinazione di
PineUnpinrende 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 richiedonoPinsi 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
PineUnpin, 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 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 unirne le caratteristiche. 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 diverso da 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 “Stream: Future in
Sequenza”, 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è unastructche implementaFuturee ci consente di nominare la lifetime del reference aselfconNext<'_, Self>, in modo cheawaitpossa 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!