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.

In Pocket speichern vorlesen Druckansicht 7 Kommentare lesen
Lesezeit: 8 Min.
Von
  • Stefan Baumgartner
Inhaltsverzeichnis

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?

Ferris Talks – die Kolumne für Rustaceans

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.

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.

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.

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.

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.

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.

Ferris Talk – Neuigkeiten zu Rust. Kolumnist:
Stefan Baumgartner, Dynatrace.at, Rust Meetup Linz, Autor der Ferris Talks, der Kolumne über die Programmiersprache Rust bei Heise Developer

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)