Ein genauerer Blick auf die Traits für Async
Im Laufe des Kapitels haben wir die Traits Future, Stream und StreamExt
auf verschiedene Weise verwendet. Bis jetzt haben wir es jedoch vermieden, zu
sehr ins Detail zu gehen, wie sie funktionieren oder wie sie zusammenpassen.
Wenn wir Rust für den Alltag schreiben, ist das meist ausreichend. Manchmal
stößt du jedoch auf Situationen, in denen du weitergehende Details dieser Traits
verstehen musst, beispielsweise zum Typ Pin und zum Trait Unpin. In diesem
Abschnitt werden wir nur so weit ins Detail gehen, wie es für diese Szenarien
nötig ist, und überlassen die wirklich tiefen Einblicke der weiteren
Dokumentation.
Das Trait Future
Lass uns zunächst einen genaueren Blick darauf werfen, wie das Trait Future
funktioniert. Rust definiert es wie folgt:
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>;
}
Diese Trait-Definition enthält eine Reihe neuer Typen und auch eine Syntax, die wir bisher noch nicht gesehen haben. Gehen wir also die Definition Stück für Stück durch.
Erstens gibt der zugehörige Typ Output von Future an, was das Future
zurückgibt. Dies ist analog zum Typ Item des Traits Iterator. Zweitens hat
Future auch die Methode poll, die eine spezielle Pin-Referenz für ihren
self-Parameter und eine veränderbare Referenz auf einen Context-Typ
entgegennimmt und Poll<Self::Output> zurückgibt. Wir werden gleich ein wenig
mehr über Pin und Context sprechen. Für den Moment wollen wir uns auf das
konzentrieren, was die Methode zurückgibt: Den Typ Poll:
pub enum Poll<T> {
Ready(T),
Pending,
}
Dieser Typ Poll ist Option recht ähnlich: Er hat eine Variante Ready(T),
die einen Wert hat, und eine Variante Pending ohne Wert. Poll bedeutet
jedoch etwas ganz anderes als Option! Die Variante Pending zeigt an, dass
das Future noch Arbeit zu erledigen hat, sodass der Aufrufer später noch einmal
nachsehen muss. Die Variante Ready zeigt an, dass das Future seine Arbeit
beendet hat und der Wert T verfügbar ist.
Hinweis: Es kommt selten vor, dass man
polldirekt aufrufen muss, aber wenn doch, sollte man bedenken, dass man bei den meisten Futurespollnicht erneut aufrufen darf, nachdem das FutureReadyzurückgegeben hat. Viele Futures werden das Programm abbrechen, wenn sie erneut abgefragt werden, obwohl sie bereit sind! Futures, bei denen eine erneute Abfrage sicher ist, werden dies in ihrer Dokumentation explizit erwähnen. Dies ist ähnlich zum Verhalten vonIterator::next!
Rust kompiliert Code mit await unter der Haube zu Code, der poll aufruft.
Wenn du dir Listing 17-4 ansiehst, wo wir den Seitentitel für eine einzelne
URL ausgegeben haben, sobald sie aufgelöst wurde, kompiliert Rust das in etwa
(wenn auch nicht genau) wie folgt:
match page_title(url).poll() {
Ready(page_title) => match page_title {
Some(title) => println!("Der Titel für {url} war {title}"),
None => println!("{url} hatte keinen Titel"),
}
Pending => {
// Aber was kommt hierhin?
}
}
Was sollen wir tun, wenn das Future noch Pending ist? Wir brauchen eine
Möglichkeit, es nochmal zu versuchen und nochmal und nochmal, bis das Future
endlich fertig ist. Mit anderen Worten, wir benötigen eine Schleife:
let mut page_title_fut = page_title(url);
loop {
match page_title_fut.poll() {
Ready(value) => match page_title {
Some(title) => println!("Der Titel für {url} war {title}"),
None => println!("{url} hatte keinen Titel"),
}
Pending => {
// weitermachen
}
}
}
Wenn Rust diesen Code kompilieren würde, würde jedes await blockieren –
genau das Gegenteil von dem, was wir erreichen wollten! Stattdessen sorgt Rust
dafür, dass die Schleife die Kontrolle an etwas abgeben kann, das die Arbeit an
diesem Future unterbrechen und an anderen Futures arbeiten kann, um diese
später wieder zu prüfen. Wie wir bereits gesehen haben, ist dieses „Etwas“ eine
asynchrone Laufzeitumgebung, und diese Planungs- und Koordinierungsarbeit ist
eine der Hauptaufgaben einer Laufzeitumgebung.
Im Abschnitt “Datenaustausch zwischen zwei Aufgaben mit
Nachrichtenübermittlung” haben wir das Warten auf rx.recv
beschrieben. Der Aufruf recv gibt ein Future zurück und zum Warten darauf wird
es abgefragt. Wir haben angemerkt, dass eine Laufzeitumgebung das Future
pausieren wird, bis es entweder mit Some(message) oder None bereit ist, wenn
der Kanal geschlossen wird. Mit unserem tieferen Verständnis des Traits Future
und insbesondere von Future::poll können wir sehen, wie das funktioniert. Die
Laufzeitumgebung weiß, dass das Future nicht bereit ist, wenn es Poll::Pending
zurückgibt. Umgekehrt weiß die Laufzeitumgebung, dass das Future bereit ist
und bevorzugt es, wenn poll den Wert Poll::Ready(Some(message)) oder
Poll::Ready(None) zurückgibt.
Die genauen Details, wie eine Laufzeitumgebung das macht, gehen über den Rahmen dieses Buches hinaus, aber der Schlüssel ist, die grundlegende Mechanik von Futures zu verstehen: Eine Laufzeitumgebung fragt jedes Future ab, für das sie verantwortlich ist, und versetzt das Future zurück in den Schlaf, wenn es noch nicht bereit ist.
Der Typ Pin und das Trait Unpin
In Listing 17-13 haben wir das Makro trpl::join! verwendet, um auf drei
Futures zu warten. Es ist jedoch üblich, eine Kollektion wie einen Vektor zu
verwenden, der eine bestimmte Anzahl von Futures enthält, die erst zur Laufzeit
bekannt sind. Ändern wir Listing 17-13 zum Code in Listing 17-23, der die
drei Futures in einen Vektor einfügt und stattdessen die Funktion
trpl::join_all aufruft, die noch nicht kompiliert werden kann.
Dateiname: src/main.rs
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 vals = vec![
String::from("Hallo"),
String::from("aus"),
String::from("dem"),
String::from("Future"),
];
for val in vals {
tx1.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
};
let rx_fut = async {
while let Some(value) = rx.recv().await {
println!("Erhalten: '{value}'");
}
};
let tx_fut = async move {
// --abschneiden--
let vals = vec![
String::from("Weitere"),
String::from("Nachrichten"),
String::from("für"),
String::from("dich"),
];
for val in vals {
tx.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
};
let futures: Vec<Box<dyn Future<Output = ()>>> =
vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];
trpl::join_all(futures).await;
});
}
Listing 17-23: Warten auf Futures in einer Kollektion
Wir legen jedes Future in eine Box, um es zu Trait-Objekten zu machen, genau
wie wir es im Abschnitt „Fehlerrückgabe aus run” in
Kapitel 12 getan haben. (Wir werden Trait-Objekte in Kapitel 18 ausführlich
behandeln.) Durch die Verwendung von Trait-Objekten können wir jede der von
diesen Typen erzeugten anonymen Futures als denselben Typ behandeln, da sie alle
das Trait Future implementieren.
Das mag überraschend sein. Schließlich gibt keiner der asynchronen Blöcke etwas
zurück, d.h. jeder erzeugt ein Future<Output = ()>. Denke jedoch daran, dass
Future ein Trait ist und dass der Compiler für jeden asynchronen Block eine
eindeutige Aufzählung erstellt, selbst wenn diese identische Ausgabetypen haben.
Genauso wie du nicht zwei verschiedene handgeschriebene Strukturen in einen
Vec einfügen kannst, kannst du auch keine vom Compiler generierten
Aufzählungen mischen.
Dann übergeben wir die Kollektion von Futures an die Funktion trpl::join_all
und warten auf das Ergebnis. Dies lässt sich jedoch nicht kompilieren. Hier ist
der relevante Teil der Fehlermeldung:
error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
--> src/main.rs:48:33
|
48 | trpl::join_all(futures).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
= note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
--> file:///home/.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`
Der Hinweis in dieser Fehlermeldung besagt, dass wir das Makro pin! verwenden
sollten, um die Werte anzuheften (pin), d.h. sie in den Typ Pin einzupacken,
der garantiert, dass die Werte im Speicher nicht verschoben werden. Die
Fehlermeldung besagt, dass das Anheften erforderlich ist, da dyn Future<Output = ()> das Trait Unpin implementieren muss, was derzeit nicht der Fall ist.
Die Funktion trpl::join_all gibt eine Struktur namens JoinAll zurück. Diese
Struktur ist generisch über einen Typ F, der auf die Implementierung des
Traits Future beschränkt ist. Direktes Warten auf ein Future mit await
heftet das Future implizit an. Deshalb müssen wir pin! nicht überall
verwenden, wo wir auf Futures warten wollen.
Allerdings warten wir hier nicht direkt auf ein Future. Stattdessen konstruieren
wir ein neues Future JoinAll, indem wir eine Kollektion von Futures an die
Funktion join_all übergeben. Die Signatur für join_all erfordert, dass der
Typ der Elemente in der Kollektion das Trait Future implementiert. Box<T>
implementiert Future nur, wenn das T, das es umhüllt, ein Future ist, das
das Trait Unpin implementiert.
Das ist eine Menge, die man verarbeiten muss! Um es wirklich zu verstehen,
müssen wir ein wenig tiefer in die Funktionsweise des Traits Future
eintauchen, insbesondere in Bezug auf das Anheften (pinning). Schau dir noch
einmal die Definition des Traits Future an:
use std::pin::Pin;
use std::task::{Context, Poll};
pub trait Future {
type Output;
// Erforderliche Methode
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
Der Parameter cx und sein Typ Context sind der Schlüssel dazu, wie eine
Laufzeitumgebung tatsächlich weiß, wann sie ein bestimmtes Future prüfen muss,
während es immer noch faul ist. Die Details, wie das funktioniert, liegen
jedoch außerhalb des Rahmens dieses Kapitels: Du musst dich im Allgemeinen nur
darum kümmern, wenn du eine eigene Future-Implementierung schreibst. Wir
werden uns stattdessen auf den Typ von self konzentrieren, da dies das erste
Mal ist, dass wir eine Methode sehen, bei der self eine Typ-Annotation hat.
Eine Typ-Annotation für self funktioniert wie Typ-Annotationen für andere
Funktionsparameter, jedoch mit zwei wesentlichen Unterschieden:
- Sie teilt Rust mit, welchen Typ
selfhaben muss, damit die Methode aufgerufen werden kann. - Sie kann nicht einfach irgendein Typ sein. Sie ist beschränkt auf den Typ,
auf dem die Methode implementiert ist, eine Referenz oder ein intelligenter
Zeiger auf diesen Typ oder ein
Pin, das eine Referenz auf diesen Typ enthält.
Wir werden mehr über diese Syntax in Kapitel 18 erfahren. Für den
Moment reicht es zu wissen, dass wir, wenn wir ein Future abfragen wollen, um
zu prüfen, ob es Pending oder Ready(Output) ist, eine mit Pin umhüllte
veränderbare Referenz auf den Typ benötigen.
Pin ist ein Wrapper für zeigerartige Typen wie &, &mut, Box und Rc.
(Technisch gesehen arbeitet Pin mit Typen, die die Traits Deref oder
DerefMut implementieren, aber das ist effektiv gleichbedeutend damit, nur mit
Zeigern zu arbeiten.) Pin ist selbst kein Zeiger und hat kein eigenes
Verhalten wie Rc und Arc mit Referenzzählern; es ist lediglich ein Werkzeug,
das der Compiler verwenden kann, um Einschränkungen bei der Verwendung von
Zeigern zu erzwingen.
Wenn du dich daran erinnerst, dass await in Form von Aufrufen von poll
implementiert ist, erklärt das die Fehlermeldung, die wir oben gesehen haben,
aber die bezog sich auf Unpin, nicht auf Pin. Wie genau verhält sich also
Pin zu Unpin, und warum muss self bei einem Future in einem Pin-Typ
sein, um poll aufzurufen?
Erinnere dich an den Anfang dieses Kapitels: Eine Reihe von await-Punkten in einem Future wird zu einem Zustandsautomaten kompiliert, und der Compiler stellt sicher, dass dieser Zustandsautomat alle normalen Sicherheitsregeln von Rust befolgt, einschließlich Borrowing und Eigentümerschaft (ownership). Damit das funktioniert, prüft Rust, welche Daten zwischen einem await-Punkt und entweder dem nächsten await-Punkt oder dem Ende des asynchronen Blocks benötigt werden. Anschließend wird eine entsprechende Variante in der kompilierten Zustandsmaschine erstellt. Jede Variante erhält den erforderlichen Zugriff auf die Daten, die in diesem Abschnitt des Quellcodes verwendet werden, entweder durch Übernahme des Eigentums an diesen Daten oder durch Erhalt einer veränderbaren oder unveränderbaren Referenz darauf.
So weit, so gut: Wenn wir bei der Eigentümerschaft oder den Referenzen in einem
bestimmten asynchronen Block etwas falsch machen, wird uns der Borrow Checker
dies mitteilen. Wenn wir das Future, das diesem Block entspricht, verschieben
wollen – etwa in einen Vec, um es an join_all zu übergeben –
wird es schwieriger.
Wenn wir ein Future verschieben – sei es durch Verschieben in eine
Datenstruktur, um es als Iterator mit join_all zu verwenden oder durch
Rückgabe aus einer Funktion – bedeutet das eigentlich, dass wir die
Zustandsmaschine verschieben, die Rust für uns erstellt. Und im Gegensatz zu
den meisten anderen Typen in Rust können die Futures, die Rust für async-Blöcke
erzeugt, mit Referenzen auf sich selbst in den Feldern einer beliebigen
Variante enden, wie in der vereinfachten Darstellung in Abbildung 17-4 gezeigt.
Abbildung 17-4: Ein selbstreferenzierender Datentyp
Standardmäßig kann ein Objekt, das eine Referenz auf sich selbst hat, nicht sicher verschoben werden, da Referenzen immer auf die tatsächliche Speicheradresse des Objekts zeigen (siehe Abbildung 17-5). Wenn du die Datenstruktur selbst verschiebst, verweisen diese internen Referenzen weiterhin auf den alten Speicherplatz. Dieser Speicherplatz ist nun jedoch ungültig. Zum einen wird ihr Wert nicht mehr aktualisiert, wenn du Änderungen an der Datenstruktur vornimmst. Zum anderen – und das ist noch wichtiger – kann der Computer diesen Speicherplatz nun für andere Zwecke verwenden! Es könnte sein, dass du später völlig unzusammenhängende Daten liest.
Abbildung 17-5: Das unsichere Ergebnis beim Verschieben eines selbstreferenzierenden Datentyps
Theoretisch könnte der Rust-Compiler versuchen, jede Referenz auf ein Objekt zu aktualisieren, wenn es verschoben wird. Das würde potenziell eine Menge zusätzlicher Performance-Overhead bedeuten, vor allem wenn man bedenkt, dass es ein ganzes Netz von Referenzen geben kann, die aktualisiert werden müssen. Wenn wir stattdessen sicherstellen können, dass die betreffende Datenstruktur nicht im Speicher verschoben wird, müssen wir keine Referenzen aktualisieren. Das ist genau das, was der Borrow Checker von Rust verlangt: In sicherem Code kann man kein Element, auf das aktive Referenzen bestehen, verschieben.
Pin baut darauf auf, um uns genau die Garantie zu geben, die wir brauchen.
Wenn wir einen Wert anheften, indem wir einen Zeiger auf diesen Wert in Pin
einpacken, kann er nicht mehr verschoben werden. Wenn du also
Pin<Box<SomeType>> hast, heftest du eigentlich den Wert SomeType an,
nicht den Zeiger Box. Abbildung 17-6 veranschaulicht dies:
Abbildung 17-6: Anheften einer Box, die auf einen
selbstreferenzierenden Future-Typ zeigt
In der Tat kann der Zeiger in Box immer noch verschoben werden. Denke daran:
Wir wollen sicherstellen, dass die Daten, auf die referenziert wird, an ihrem
Platz bleiben. Wenn ein Zeiger verschoben wird, aber die Daten, auf die er
zeigt, an der gleichen Stelle sind, wie in Abbildung 17-7, gibt es kein
potenzielles Problem. (Schau dir als unabhängige Übung die Dokumentation der
Typen sowie des Moduls std::pin an und versuche herauszufinden, wie du das mit
einem Pin machst, der eine Box umhüllt.) Der Schlüssel ist, dass der
selbstreferenzierende Typ selbst nicht verschoben werden kann, weil er immer
noch angeheftet ist.
Abbildung 17-7: Verschieben einer Box, die auf einen
selbstreferenzierenden Futuretyp zeigt.
Die meisten Typen können jedoch gefahrlos verschoben werden, selbst wenn sie
sich hinter einem Pin-Wrapper befinden. Wir müssen nur über das Anheften
nachdenken, wenn Elemente interne Referenzen haben. Primitive Werte wie Zahlen
und Boolesche Werte sind sicher, weil sie keine internen Referenzen haben.
Genauso wenig wie die meisten Typen, mit denen man normalerweise in Rust
arbeitet. Du kannst zum Beispiel unbesorgt einen Vec verschieben. Nach dem,
was wir bisher gesehen haben, müsste man bei einem Pin<Vec<String>> alles
über die sicheren, aber restriktiven APIs von Pin machen, obwohl ein
Vec<String> immer sicher verschoben werden kann, wenn es keine anderen
Referenzen auf ihn gibt. Wir brauchen eine Möglichkeit, dem Compiler
mitzuteilen, dass es in solchen Fällen in Ordnung ist, Elemente zu verschieben
– und hier kommt Unpin ins Spiel.
Unpin ist ein Marker Trait, ähnlich wie die Traits Send und Sync, die wir
in Kapitel 16 gesehen haben, und es hat keine eigene Funktionalität. Marker
Traits existieren nur, um dem Compiler mitzuteilen, dass es sicher ist, den Typ
zu verwenden, der ein bestimmtes Trait in einem bestimmten Kontext
implementiert. Unpin teilt dem Compiler mit, dass ein gegebener Typ keine
besonderen Garantien aufrechterhalten muss, um den fraglichen Wert zu
verschieben.
Genau wie bei Send und Sync implementiert der Compiler Unpin automatisch
für alle Typen, bei denen er beweisen kann, dass sie sicher sind. Ein
Sonderfall analog zu Send und Sync ist, dass Unpin für einen Typ nicht
implementiert ist. Die Notation hierfür ist impl !Unpin for SomeType, wobei
SomeType der Name eines Typs ist, der diese Garantien aufrechterhalten
muss, um sicher zu sein, wenn ein Zeiger auf diesen Typ in einem Pin
verwendet wird.
Mit anderen Worten, es gibt zwei Dinge über die Beziehung zwischen Pin und
Unpin zu beachten. Erstens ist Unpin der „normale“ Fall und !Unpin der
Spezialfall. Zweitens, ob ein Typ Unpin oder !Unpin implementiert, spielt
nur eine Rolle, wenn man einen angepinnten Zeiger auf diesen Typ wie
Pin<&mut SomeType> verwendet.
Um dies zu verdeutlichen, denke an einen String: Er hat eine Länge und die
Unicode-Zeichen, aus denen er besteht. Wir können einen String in einen Pin
einpacken, wie in Abbildung 17-8. Allerdings implementiert String automatisch
Unpin, wie die meisten anderen Typen in Rust.
Abbildung 17-8: Anheften eines String; die gestrichelte
Linie deutet an, dass String das Trait Unpin implementiert und daher nicht
angeheftet ist.
Infolgedessen können wir Dinge tun, die illegal wären, wenn String stattdessen
!Unpin implementiert hätte, wie zum Beispiel das Ersetzen eines Strings durch
einen anderen an der exakt gleichen Stelle im Speicher, wie in Abbildung 17-9.
Dies verletzt nicht den Pin-Vertrag, weil String keine internen Referenzen
hat, die es unsicher machen, ihn zu verschieben! Das ist genau der Grund, warum
er Unpin und nicht !Unpin implementiert.
Abbildung 17-9: Ersetzen eines String durch einen
völlig anderen String im Speicher.
Jetzt wissen wir genug, um die Fehler zu verstehen, die für den Aufruf
join_all in Listing 17-23 gemeldet wurden. Ursprünglich haben wir versucht,
die von asynchronen Blöcken erzeugten Futures in einen Vec<Box<dyn Future<Output = ()>>> zu verschieben, aber wie wir gesehen haben, können diese
Futures interne Referenzen haben, sodass sie Unpin nicht implementieren.
Sobald wir sie anpinnen, können wir den resultierenden Typ Pin an den Vec
übergeben, in der Gewissheit, dass die zugrunde liegenden Daten in den Futures
nicht verschoben werden. Listing 17-24 zeigt, wie der Code korrigiert werden
kann, indem das Makro pin! an der Stelle aufgerufen wird, an der die drei
Futures definiert sind, und der Trait-Objekttyp angepasst wird.
Dateiname: src/main.rs
use std::pin::{Pin, pin};
// --abschneiden--
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 {
// --abschneiden--
let vals = vec![
String::from("Hallo"),
String::from("aus"),
String::from("dem"),
String::from("Future"),
];
for val in vals {
tx1.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
});
let rx_fut = pin!(async {
// --abschneiden--
while let Some(value) = rx.recv().await {
println!("Erhalten: '{value}'");
}
});
let tx_fut = pin!(async move {
// --abschneiden--
let vals = vec![
String::from("Weitere"),
String::from("Nachrichten"),
String::from("für"),
String::from("dich"),
];
for val in vals {
tx.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
});
let futures: Vec<Pin<&mut dyn Future<Output = ()>>> =
vec![tx1_fut, rx_fut, tx_fut];
trpl::join_all(futures).await;
});
}
Listing 17-24: Die Futures anpinnen, um sie in den Vektor verschieben zu können
Dieses Beispiel lässt sich nun kompilieren und ausführen, und wir könnten zur Laufzeit Futures zum Vektor hinzufügen oder daraus entfernen und auf alle warten.
Pin und Unpin sind vor allem wichtig für die Erstellung von
Low-Level-Bibliotheken und wenn du eine Laufzeitumgebung erstellst, weniger bei
alltäglichem Rust-Code. Wenn du diese Traits in Fehlermeldungen siehst, hast du
jetzt eine bessere Vorstellung davon, wie du deinen Code korrigieren kannst!
Anmerkung: Diese Kombination von
PinundUnpinmacht es möglich, eine ganze Klasse von komplexen Typen sicher in Rust zu implementieren, die sich sonst als schwierig erweisen würden, weil sie selbstreferenzierend sind. Typen, diePinbenötigen, tauchen heute am häufigsten in asynchronem Rust auf, aber hin und wieder sieht man sie auch in anderen Kontexten.Die Besonderheiten der Funktionsweise von
PinundUnpinund die Regeln, die sie einhalten müssen, werden ausführlich in der API-Dokumentation fürstd::pinbehandelt. Wenn du mehr darüber lernen willst, ist das ein guter Ausgangspunkt.Wenn du noch detaillierter verstehen willst, wie die Dinge unter der Haube funktionieren, schaue dir die Kapitel „Under the Hood: Executing Futures and Tasks“ und „Pinning“ im Buch Asynchronous Programming in Rust an.
Das Trait Stream
Nachdem du nun ein tieferes Verständnis für die Traits Future, Pin und
Unpin hast, können wir uns dem Trait Stream zuwenden. Wie du bereits in
diesem Kapitel gelernt hast, sind Streams ähnlich wie asynchrone Iteratoren. Im
Gegensatz zu Iterator und Future hat Stream derzeit keine Definition in
der Standardbibliothek, aber es gibt eine sehr verbreitete Definition in der
Crate futures, die im gesamten Ökosystem verwendet wird.
Schauen wir uns die Definitionen der Traits Iterator und Future an, bevor
wir uns ansehen, wie ein Trait Stream aussehen könnte. Von Iterator haben
wir die Idee einer Sequenz: Seine Methode next liefert eine
Option<Self::Item>. Von Future haben wir die Idee der zeitlichen
Bereitschaft: Seine Methode poll liefert ein Poll<Self::Output>. Um eine
Sequenz von Elementen darzustellen, die im Laufe der Zeit bereit sein werden,
definieren wir ein Trait Stream, das diese Funktionalitäten zusammenführt:
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>>;
}
Das Trait Stream definiert einen zugehörigen Typ namens Item für den Typ der
vom Stream erzeugten Elemente. Dies ist ähnlich wie bei Iterator, wo es
beliebig viele Elemente geben kann, anders als bei Future, wo es immer nur
einen einzigen Output gibt, selbst wenn es der Einheitstyp () ist.
Stream definiert auch eine Methode zum Abrufen dieser Elemente. Wir nennen
sie poll_next, um zu verdeutlichen, dass sie auf die gleiche Weise wie
Future::poll abfragt und eine Sequenz von Elementen auf die gleiche Weise wie
Iterator::next erzeugt. Sein Rückgabetyp kombiniert Poll mit Option. Der
äußere Typ ist Poll, weil er auf Bereitschaft geprüft werden muss, genau wie
ein Future. Der innere Typ ist Option, weil er signalisieren muss, ob es
weitere Nachrichten gibt, genau wie ein Iterator.
Etwas, das dieser Definition sehr ähnlich ist, wird wahrscheinlich Teil der Standardbibliothek von Rust werden. In der Zwischenzeit ist es Teil des Werkzeugkoffers der meisten Laufzeitumgebungen, sodass du dich darauf verlassen kannst, und alles, was wir als nächstes behandeln, allgemein gilt!
Im Beispiel, das wir im Abschnitt „Streams: Sequenz von Futures“
gesehen haben, haben wir allerdings nicht poll_next oder Stream benutzt,
sondern next und StreamExt. Wir könnten direkt mit der poll_next-API
arbeiten, indem wir unsere eigenen Stream-Zustandsautomaten schreiben, genauso
wie wir mit Futures direkt über deren Methode poll arbeiten können. Die
Verwendung von await ist jedoch viel schöner, und das Trait StreamExt stellt
die Methode next bereit, sodass wir Folgendes tun können:
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;
// andere Methoden ...
}
Anmerkung: Die tatsächliche Definition von
StreamExtsieht etwas anders aus, da sie Versionen von Rust unterstützt, die noch keine Verwendung von asynchronen Funktionen in Traits kennen. Infolgedessen sieht sie so aus:fn next(&mut self) -> Next<'_, Self> where Self: Unpin;Der Typ
Nextist einstruct, dasFutureimplementiert, und erlaubt uns, die Lebensdauer der Referenz aufselfmitNext<'_, Self>zu benennen, sodassawaitmit dieser Methode arbeiten kann!
Das Trait StreamExt ist auch die Heimat aller interessanten Methoden, die für
die Verwendung mit Streams zur Verfügung stehen. StreamExt wird automatisch
für jeden Typ implementiert, der Stream implementiert, aber diese Traits
werden separat definiert, um der Rust-Gemeinschaft die Möglichkeit zu geben,
Komfort-APIs zu entwickeln, ohne die grundlegenden Traits zu beeinflussen.
In der Version von StreamExt, die in der Crate trpl verwendet wird,
definiert das Trait nicht nur die Methode next, sondern liefert auch eine
Implementierung von next, die die Details des Aufrufs von Stream::poll_next
korrekt behandelt. Das bedeutet, dass du selbst beim Schreiben deines eigenen
Streaming-Datentyps nur Stream implementieren musst, und dann kann jeder,
der deinen Datentyp verwendet, StreamExt und seine Methoden automatisch mit
ihm verwenden.
Das ist alles, was wir für die tieferen Details zu diesen Traits behandeln werden. Zum Abschluss wollen wir uns ansehen, wie Futures (einschließlich Streams), Aufgaben und Threads zusammenpassen!