Ferris Talk #14: Rust bekommt asynchrone Methoden in Traits
Asynchrone Programmierung ist in Rust nach wie vor eine Baustelle. Eine Neuerung hat das Zeug, sie deutlich voranzubringen.
- Stefan Baumgartner
Seit wenigen Monaten erlaubt die Nightly Version von Rust, asynchrone Methoden für Traits zu definieren. Vorher waren dafür zusätzliche Bibliotheken erforderlich. Die Neuerung ist ein wichtiger Meilenstein für das lang erwartete Async-Featureset von Rust. Doch welche Gründe gab es für die Wartezeit, und was muss noch passieren bis asynchrone Methoden in Traits endlich in der stabilen Version landen?
Syntaxzucker für Futures
Auf der Suche nach dem Warum müssen wir bei den Grundprinzipien der asynchronen Programmierung in Rust anfangen. Die generelle Funktionsweise hat Ferris Talk # 4 beschrieben. Eine kurze Zusammenfassung:
Rust hat keine integrierte asynchrone Laufzeitumgebung, sondern setzt auf Bibliotheken. Die Sprache bietet aber sprachliche Konstrukte und Traits, mit denen die Libraries arbeiten können.
Zu den Traits gehört mit Future
die Abstraktion eines Wertes, der irgendwann in der Zukunft ankommt. Funktionen, die eine Future zurückgeben, werden nicht sofort ausgeführt, sondern Anwendungen müssen sie entweder explizit an eine asynchrone Laufzeitumgebung senden oder innerhalb einer asynchronen Ausführung mit .await
erwarten.
Future
ist ein Trait, der den zu erwartenden Wert mittels assoziiertem Typ Output definiert. Auf die Weise lassen sich Rückgabewerte beschreiben.
trait Future {
type Output;
fn poll(self: Pin<&mut Self>,
cx: &mut Context<'_>) ->
Poll<Self::Output>;
}
Über die Methode poll
kann die Runtime herausfinden, ob es schon einen Wert gibt, oder ob sie die gleiche Future
später erneut überprüfen soll.
Die Schnittstelle einer asynchronen Funktion, die Daten in einen String Buffer liest und die Anzahl der gelesenen Bytes per Result
zurückgibt, könnte folgendermaßen aussehen:
fn read_to_string(buf: &mut String) ->
impl Future<Output = Result<usize>>;
Dabei nutzt Rust die impl-Trait-Syntax, die beschreibt, dass die Rückgabe den Trait Future
implementieren muss und ein Result
-Enum mit einer usize
im Ok
-Wert als Output enthält.
Dieses Konstrukt lässt sich über syntaktischen Zucker abkürzen, indem das Schlüsselwort async
vor der Funktion steht. Das Codestück von vorhin, wird damit in einer schöneren Schreibweise zur asynchronen Funktion:
async fn read_to_string(buf: &mut String)
-> Result<usize>;
Der Rust-Compiler übersetzt diese Syntax in die komplexere Variante, die mit Futures hantiert und dadurch kompatibel mit asynchronen Laufzeitumgebungen ist.
Typauflösung für Traits
Wie geht man mit Traits um? Ein kleiner Disclaimer vorweg: Die gezeigten Auflösungen des syntaktischen Zuckers können in Details von der Realität abweichen, illustrieren aber die wichtigen Punkte.
Folgendes Beispiel definiert die schreibende Schnittstelle für eine Chat-Applikation, in der sich unabhängig von der tatsächlichen Implementierung Daten senden lassen.
pub trait ChatSink {
type Item: Clone;
async fn send_msg(&mut self,
msg: Self::Item) ->
Result<(), ChatCommErr>;
}
Das Übertragen in Code mit Futures offenbart ein paar Schwachstellen.
Schritt 1: Entfernen des asynchronen Schlüsselworts und Ersetzen durch eine Future.
pub trait ChatSink {
type Item: Clone;
fn send_msg(&mut self, msg: Self::Item) ->
impl Future<Output = Result<(), ChatCommErr>>;
}
Obwohl der Code auf den ersten Blick brauchbar wirkt, akzeptiert ihn der Rust-Compiler nicht. In Traits sind aktuell noch keine impl-Traits in Rückgabepositionen erlaubt. In Nightly Rust ist das zwar ein weiteres Feature, das sich bequem mit einem Makro aktivieren lässt, allerdings zum nächsten Problem führt: impl-Trait in Methoden ist ebenfalls Syntaxzucker. In Wirklichkeit erzeugt der Compiler einen assoziierten Typ:
pub trait ChatSink {
type Item: Clone;
type $: Future<Output = Result<(), ChatCommErr>>;
fn send_msg(&mut self, msg: Self::Item) -> Self::$;
}
Das sieht nicht allzu aufregend aus, wirft aber Licht auf eine wichtige Sache, die bisher außen vor blieb: Die Lebensdauer (Lifetime) der Future.
Generische Lifetime-Parameter für Futures
Hier ist eine kleine Entschuldigung angebracht: Der Einfachheit halber blieb bisher ein wichtiges Detail unerwähnt, das allerdings für das komplette Bild asynchroner Methoden in Traits wichtig ist.
Anders als andere impl-Trait-Auflösungen muss eine Future die Lifetime-Parameter aller Eingabeparameter mitnehmen. Das heißt, aus
async fn read_to_string(buf: &mut String)
-> Result<usize>;
wird schließlich
fn read_to_string<'a>(buf: &'a mut String) ->
impl Future<Output = Result<usize>> + 'a;
Der Grund dafür erschließt sich bei einem Blick darauf, wie Futures intern abgebildet werden: Sie führen keinen Code aus, sondern übergeben nur die Möglichkeit, Code von einer asynchronen Laufzeitumgebung ausführen zu lassen.
Asynchrone Funktionen erstellen solche Futures und benötigen daher Referenzen auf alle Eingabeparameter. Diese Referenzen müssen gemäß dem Ownership-Prinzip von Rust genauso lange vorhanden sein wie die Future, da Letztere sonst mit Daten arbeitet, die potenziell nicht mehr existieren.
Um diese Verbindung sicherzustellen, zieht man den Lifetime-Parameter mit. Die Future darf nicht länger existieren als alle Referenzen, die als Eingabeparameter mitkommen. Der Rust-Compiler ermöglicht dank des Schlüsselwortes async
die einfachere Schreibweise.
Die Lifetime-Parameter stellen allerdings den Trait mit asynchronen Methoden vor eine Herausforderung. Die äquivalente Schreibweise für den ChatSink
-Trait sieht folgendermaßen aus:
pub trait ChatSink {
type Item: Clone;
type $<'m>: Future<Output = Result<(),
ChatCommErr>> + 'm;
fn send_msg(&mut self, msg: Self::Item)
-> Self::$<'_>;
}
Der Code löst die Lifetime für die Future via generischem Lifetime-Parameter im assoziiertem Typ. Generische Parameter für assoziierte Typen, existieren allerdings erst seit Ende 2022 in Rust Stable.
Tatsächlich kippen GATs ( Generic Associated Types) den ersten von vielen Dominosteinen, die asynchrone Methoden in Traits möglich machen.
Limitierungen der ersten Implementierung
Die erste Version der asynchronen Methoden ist nun in Nightly gelandet und lässt sich mit dem zugehörigen feature
-Makro als Einleitung ausprobieren:
#![feature(async_fn_in_trait)]
pub trait ChatSink {
type Item: Clone;
async fn send_msg(&mut self, msg: Self::Item) ->
Result<(), ChatCommErr>;
}
Allerdings zeigen sich potenziell einige Einschränkungen. Zum einen ist es aktuell nicht erlaubt, der generierten Future zusätzliche Typparameter wie Send
zu übergeben, die beispielsweise sicherstellen, dass mehrere Traits problemlos dieselbe Future bearbeiten können. Das ist eine Anforderung, die in fortgeschrittenen Szenarien öfter auftaucht.
Zum anderen können asynchrone Methoden in Traits keine Trait-Objekte annehmen, womit der dynamische Dispatch unmöglich ist. Abhilfe schafft weiterhin das Crate async_trait
, das den Vorgang per Makro vornimmt. So wird aus
#[async_trait]
pub trait ChatSink {
type Item: Clone;
async fn send_msg(&mut self, msg: Self::Item) ->
Result<(), ChatCommErr>;
}
eine Version mit Trait-Objekten und Daten auf dem Heap:
pub trait ChatSink {
type Item: Clone;
fn send_msg(&mut self, msg: Self::Item) ->
Pin<Box<dyn Future<Output = Result<(),
ChatCommErr>>>>;
}
Mehr Informationen zu Traits und Trait-Objekten finden sich in Ferris Talk #2.
Für beide Problemstellungen existieren Design-Dokumente und Ideen, um sie zu lösen. Aktuell evaluiert das Rust-Team, ob die Limitierungen Blocker für das Stabilisieren der asynchronen Methoden sind.
Der Schmetterlingseffekt
Genauso wie GATs den Anstoß gegeben haben, asynchrone Methoden in Traits zu entwickeln, wird das neue Feature eine Revolution in der asynchronen Programmierung in Rust anstoßen. Aktuell gibt es einige Stolpersteine unter anderem beim Implementieren von Services für Webanwendungen über Tower. Die Umsetzung benötigt entweder viel Aufwand im Design von Futures oder das Struct BoxFuture
, das mit Trait-Objekten arbeitet, um asynchrone Middleware-Schichten zu beschreiben. In Zukunft könnten sich solche Konstrukte simpler beschreiben lassen.
Des Weiteren kann das Rust-Team nun direkt an der Definition asynchroner Traits arbeiten, um unter anderem APIs für das Lesen und Schreiben von Dateien oder Netzwerkverbindungen zu definieren, die Runtimes wie Tokio implementieren können. Dadurch gelingt ein wichtiger Schritt zur Normalisierung asynchroner Laufzeitumgebungen und zu einem Weg für laufzeitunabhängige asynchrone Bibliotheken.
Die Async-Arbeitsgruppe bei Rust hat noch viele weitere Ideen. Nick Cameron, leitender Ingenieur und Rust-Experte bei Microsoft, stellte bei dem vom Autor mitveranstalteten Meetup Rust Linz im April 2022 einige dieser Entwicklungen vor.
Empfohlener redaktioneller Inhalt
Mit Ihrer Zustimmmung wird hier ein externes YouTube-Video (Google Ireland Limited) geladen.
Ich bin damit einverstanden, dass mir externe Inhalte angezeigt werden. Damit können personenbezogene Daten an Drittplattformen (Google Ireland Limited) übermittelt werden. Mehr dazu in unserer Datenschutzerklärung.
Zu guter Letzt sind asynchrone Methoden in Traits allerdings ein echtes Lehrstück in Sachen Rust RFCs und Designdokumente.
Das Zusammenspiel der einzelnen Teams und Working Groups ist von vielen Diskussionen geprägt, aber es funktioniert. Sprachinnovation geschieht, wenn die Anwendungsfälle gut durchdacht und definiert sind. Auch wenn einige Dinge etwas länger dauern, gibt es neue Annehmlichkeiten, ohne mit altem Code zu brechen.
Strahlende Zukunft
Wir sehen jetzt, dass die "Shiny Future" der GATs, die Jack Huey beschrieben hat, in einigen Projekten begonnen hat. Asynchrone Methoden lösen mit einem geringeren Featureset viele Probleme, auf die man früher oder später stößt. Wir können uns auf die Fortsetzung freuen.
Beim Aufarbeiten des Themas haben einige Ressourcen geholfen, die der Autor für tiefere Informationen zum Thema empfiehlt.
- Async fn in trait MVP comes to nightly
- Static async fn in traits
- Lifetime capture in the anonymous future
- impl Trait Explainer
- Return position impl Trait in traits
Stefan Baumgartner
lebt und arbeitet als Softwarearchitekt und Entwickler bei Dynatrace im österreichischen Linz mit Schwerpunkt auf Webentwicklung, Serverless und Cloud-basierte Architekturen.
Für den Smashing Magazine Verlag veröffentlichte er 2020 mit “TypeScript in 50 Lessons” sein zweites Buch, seine Onlinepräsenz fettblog.eu enthält Artikel, Tutorials und Guides zum Thema TypeScript, Rust, React, und Software Engineering im Allgemeinen.
Stefan organisiert Meetups und Konferenzen, wie Stahlstadt.js, die DevOne, ScriptConf, Rust Meetup Linz, und das legendäre Technologieplauscherl. Außerdem ist er regelmäßig Gastgeber im Working Draft, dem deutschsprachigen Podcast über Webtechnologien. Wenn noch ein wenig Freizeit bleibt, genießt er italienische Pasta, belgisches Bier und britischen Rock.
(rme)