Async Rust hat den MVP-Status nie verlassen
(tweedegolf.nl)- Async Rust ermöglicht executor-unabhängigen Code, der sowohl auf Servern als auch auf Mikrocontrollern laufen kann, aber durch die vom Compiler erzeugten Zustandsmaschinen fällt insbesondere im Embedded-Bereich die Zunahme der Binärgröße deutlich auf
- Schon ein einfaches Beispiel wie
bar()mit zweiawait-Stellen erzeugt 360 Zeilen MIR und die ZuständeUnresumed,Returned,Panicked,Suspend0,Suspend1, während die synchrone Version nur 23 Zeilen benötigt - Wenn beim erneuten
polleines bereits abgeschlossenen Future stattpaniceinfachPoll::Pendingzurückgegeben wird, lässt sich der Vertrag ohne unsafe Verhalten erfüllen; in Experimenten sank die Binärgröße von Embedded-Firmware um 2 % bis 5 % - Selbst
async { 5 }ohneawaiterzeugt derzeit eine Zustandsmaschine mit standardmäßig drei Zuständen, aber eine Optimierung auf stetsPoll::Ready(5)reduziert die Größe von Embedded-Binaries um 0,2 % - Das vorgeschlagene Project Goal soll im Compiler an der Entfernung von Panics nach Abschluss im Release-Modus, am Wegfall der Zustandsmaschine für Async-Blöcke ohne
await, am Inlining von Futures mit nur einemawaitund am Zusammenfalten identischer Zustände arbeiten
Das Problem der Compiler-seitigen Aufblähung in Async Rust
- Async Rust ermöglicht executor-unabhängigen Code, der gleichzeitig auf Servern und Mikrocontrollern laufen kann, doch auf kleinen Mikrocontrollern fällt die zunehmende Binärgröße besonders stark ins Gewicht
- Der Rust-Blog stellte async/await als Zero-Cost-Abstraktion vor, tatsächlich erzeugt async aber viel Aufblähung; dasselbe Problem gibt es auch auf Desktop und Server, dort fällt es wegen größerer Speicher- und Rechenressourcen jedoch weniger auf
- Nach Workarounds, um Aufblähung beim Schreiben von async-Code zu vermeiden, wurde nun ein Project Goal eingereicht, um das Problem im Compiler zu lösen
- Nicht Teil des Umfangs ist das Problem, dass Futures unnötig groß werden und zu viel kopiert wird
- Dieses Problem ist bereits bekannt, und ein PR, der einen Teil davon adressiert, ist offen: https://github.com/rust-lang/rust/pull/135527
Aufbau der erzeugten Futures
- Der Beispielcode lässt
foo()async { 5 }zurückgeben, undbar()führtfoo().await + foo().awaitaus- Godbolt-Beispiel: godbolt
barhat zweiawait-Stellen, daher braucht die Zustandsmaschine mindestens zwei Zustände, tatsächlich werden jedoch mehr erzeugt- Der Rust-Compiler kann MIR in mehreren Passes dumpen; der Pass
coroutine_resumeist der letzte async-spezifische MIR-Pass- Async bleibt in MIR erhalten, aber nicht mehr in LLVM IR, daher findet die Umwandlung von async in eine Zustandsmaschine im MIR-Pass statt
- Die Funktion
barerzeugt 360 Zeilen MIR, während die synchrone Version nur 23 Zeilen benötigt - Das vom Compiler ausgegebene
CoroutineLayoutist faktisch eine Menge von Zuständen in Enum-FormUnresumed: StartzustandReturned: abgeschlossener ZustandPanicked: Zustand nach einer PanicSuspend0: ersteawait-Stelle, speichert dasfoo-FutureSuspend1: zweiteawait-Stelle, speichert das erste Ergebnis und das zweitefoo-Future
Future::pollist eine sichere Funktion, daher darf ein erneuter Aufruf nach Abschluss des Future kein UB auslösen- Aktuell wird nach
Suspend1Readyzurückgegeben und das Future in den ZustandReturnedversetzt - Wird in diesem Zustand erneut
pollaufgerufen, tritt eine Panic auf
- Aktuell wird nach
- Der Zustand
Panickedscheint dazu zu dienen, ein erneutespolleines Future zu verhindern, nachdem eine Panic innerhalb einer async-Funktion percatch_unwindabgefangen wurde- Nach einer Panic kann sich das Future in einem unvollständigen Zustand befinden, daher könnte erneutes
pollzu UB führen - Dieser Mechanismus ist Mutex Poisoning sehr ähnlich
- Für diese Interpretation des Zustands
Panickedgibt es schwer belastbare Dokumentation; die Sicherheit dafür liegt bei etwa 90 %
- Nach einer Panic kann sich das Future in einem unvollständigen Zustand befinden, daher könnte erneutes
Muss poll nach Abschluss wirklich panicen?
- Ein Future im Zustand
Returnedpanicet derzeit, aber das ist nicht zwingend erforderlich- Erforderlich ist nur, dass kein UB entsteht
- Eine Panic ist vergleichsweise teuer und fügt einen Pfad mit Nebenwirkungen hinzu, der sich nur schwer wegoptimieren lässt
- Wenn ein abgeschlossenes Future bei erneutem
polleinfachPoll::Pendingzurückgibt, lässt sich der Vertrag des TypsFutureohne unsafe Verhalten erfüllen - In einem Experiment mit entsprechend geändertem Compiler wurde bei async-Embedded-Firmware eine 2 % bis 5 % kleinere Binärgröße gemessen
- Vorgeschlagen ist, dieses Verhalten wie
overflow-checks = falsebei Integer-Overflow über einen Schalter anzubieten- In Debug-Builds würde weiterhin gepanict, um fehlerhaftes Verhalten sofort sichtbar zu machen
- In Release-Builds ließen sich kleinere Futures erzielen
- Bei Verwendung von
panic=abortkönnte sich eventuell sogar der ZustandPanickedselbst entfernen lassen; die Auswirkungen müssen noch genauer geprüft werden
Auch ohne await wird immer eine Zustandsmaschine erzeugt
foo()gibt nurasync { 5 }zurück, daher wäre die optimale manuelle Implementierung ein Future ohne Zustand, das immerPoll::Ready(5)zurückgibt- Im vom Compiler erzeugten MIR existieren jedoch weiterhin die drei Standardzustände
Unresumed,Returned,Panicked- Beim
pollwird der Discriminant des aktuellen Zustands geprüft und verzweigt - Erfolgt nach Abschluss ein weiteres
poll, kommt es zur Panic mit`async fn` resumed after completion
- Beim
- In diesem Fall lässt sich optimieren, indem gar keine Zustandsmaschine erzeugt und stattdessen jedes Mal
Poll::Ready(5)zurückgegeben wird - Eine experimentelle Implementierung im Compiler reduzierte die Größe von Embedded-Binaries um 0,2 %
- Die Einsparung ist nicht groß, aber da die Optimierung einfach ist, könnte sie sich dennoch lohnen
- Die Optimierung ändert das Verhalten leicht, betroffen wären aber nur Executor, die sich nicht an den Vertrag halten
- Der aktuelle Compiler panicet bei späterem
poll - Nach der Optimierung würde das Future immer
Readyzurückgeben
- Der aktuelle Compiler panicet bei späterem
LLVM allein reicht nicht aus
- Selbst wenn die MIR-Ausgabe ineffizient ist, kann LLVM sie manchmal vollständig bereinigen, aber nur unter eingeschränkten Bedingungen
- Das Future muss hinreichend einfach sein
opt-level=3muss verwendet werden
- Werden Futures komplexer, kann LLVM sie nicht mehr vollständig entfernen, und in idiomatischem async Rust wachsen Komplexität und Schachtelungstiefe schnell
- In Umgebungen wie Embedded oder wasm, in denen oft auf Größe optimiert wird, kann LLVM dies nicht alles wegoptimieren
- Godbolt-Beispiel: https://godbolt.org/z/58ahb3nne
- In der erzeugten Assemblerausgabe erkennt LLVM, dass
foo5 zurückgibt, kann die Antwort vonbaraber nicht zu 10 optimieren - Auch der Aufruf der
poll-Funktion vonfoobleibt erhalten - Grund dafür sind potenzielle Panic-Pfade, die der Compiler nicht vollständig auflösen kann
- LLVM weiß nicht, dass
footatsächlich nur einmal aufgerufen wird und keine Panic auslöst
- In der erzeugten Assemblerausgabe erkennt LLVM, dass
- Wenn die Panic-Verzweigung im IR auskommentiert wird, optimiert LLVM deutlich besser: https://godbolt.org/z/38KqjsY8E
- Statt auf nachträgliche Optimierungen durch LLVM zu hoffen, sollte der Compiler LLVM besseren Input liefern
Future-Inlining funktioniert nicht gut
- Inlining ist wichtig, weil es nachfolgende Optimierungspässe ermöglicht, doch die erzeugten Rust-Futures werden derzeit in frühen Phasen nicht inlined
- Erst nachdem jedes Future seine Implementierung erhalten hat, bekommen LLVM und Linker eine Chance zum Inlining, doch wegen der vorherigen Probleme ist dieser Zeitpunkt zu spät
- Die direkteste Inlining-Gelegenheit ist ein
bar(), das lediglichfoo(blah).awaitausführt- Das ist ein Muster, das häufig auftritt, wenn per Trait abstrahiert wird
- Der aktuelle Compiler erzeugt dafür eine Zustandsmaschine für
barund ruft darin die Zustandsmaschine vonfooauf - Effizienter wäre es, wenn
barselbst dasfoo-Future wäre
- Mit Preamble und Postamble wird es komplexer
- Beispiel:
bar(input)erzeugt überinput > 10einblah, awaited dannfoo(blah)und wendet auf das Ergebnis* 2an - Das ist typisch, wenn async-Funktionen in eine andere Signatur transformiert werden, besonders in Trait-Implementierungen
- Beispiel:
- Auch diese Form von
barbenötigt keinen eigenen async-Zustand- Über die einzelne
await-Stelle hinaus muss außer den vonfooeingefangenen Werten nichts erhalten bleiben - Allerdings kann
bardann nicht einfach direktfooselbst sein, sondern den Großteil seines Zustands anfoodelegieren
- Über die einzelne
- In einer manuellen Implementierung könnte
BarFutdie ZuständeUnresumed { input }undInlined { foo: FooFut }haben- Beim ersten
pollwird die Preamble ausgeführt,foo(blah)erzeugt und in den ZustandInlinedgewechselt - Danach wird auf das Ergebnis von
foo.poll(cx)die Postamble angewendet
- Beim ersten
- Würde sich Code bis zur ersten
await-Stelle vorab ausführen lassen, könnte auch der ZustandUnresumedentfallen, aber es ist garantiert, dass ein Future vor dem erstenpollnichts tut, daher ist das nicht veränderbar - Wenn sich Eigenschaften eines gerade gepollten Future abfragen ließen, wären weitere Inlining-Optimierungen möglich
- Wüsste man etwa, dass ein Future beim ersten
pollimmerreadyzurückgibt, müsste das aufrufende Future für dieseawait-Stelle keinen eigenen Zustand anlegen - Wendet man solche Optimierungen rekursiv an, ließen sich viele Futures zu deutlich einfacheren Zustandsmaschinen zusammenfalten
- Wüsste man etwa, dass ein Future beim ersten
- In der aktuellen Struktur von
rustcscheint das nicht möglich zu sein, weil jeder async-Block separat transformiert wird und die relevanten Daten danach nicht erhalten bleiben - Future-Inlining wurde bislang noch nicht experimentell umgesetzt, dürfte aber Binärgröße und Performance deutlich verbessern
Identische Zustände zusammenfalten
- Für jede
await-Stelle in einem async-Block erhält die Zustandsmaschine einen zusätzlichen Zustand - Der folgende Code ist natürlich, erzeugt aber zwei identische Zustände, weil in beiden Verzweigungen dieselbe async-Funktion awaited wird
CommandId::A => send_response(123).awaitCommandId::B => send_response(456).await
- In diesem Fall enthält das
CoroutineLayoutjeweils_s0und_s1, die denselben Coroutine-Typ vonsend_responsespeichern, und es entstehen die beiden ZuständeSuspend0undSuspend1 - Die MIR dieser Funktion umfasst 456 Zeilen, viele Basic Blocks sind dabei faktisch Duplikate
- Refaktoriert man den Code manuell so, dass zunächst nur der Antwortwert berechnet und danach einmal
send_response(response).awaitaufgerufen wird, verschwinden die doppelten ZuständeCommandId::Awird zu123CommandId::Bwird zu456- Danach folgt
send_response(response).await
- Nach dem Refactoring enthält das
CoroutineLayoutnur noch ein gespeichertes Future und nur noch einen ZustandSuspend0 - Die gesamte MIR-Länge sinkt auf 302 Zeilen, und die Duplikate verschwinden
- Daher erscheint ein Optimierungspass nützlich, der identische Codepfade und Zustände erkennt und zu einem zusammenfaltet
- Diese Optimierung dürfte sich gut mit einem Future-Inlining-Pass kombinieren lassen
Experiment-Links und weitere Benchmarks
- Werden beide Experimente gemeinsam angewendet, ergibt sich in einem synthetischen x86-Benchmark mit dem Executor
smolein Performancegewinn von rund 3 % - No panics in poll after ready: https://github.com/rust-lang/rust/compare/main...diondokter:rust:resume-pending
- No await, no statemachine: https://github.com/rust-lang/rust/compare/main...diondokter:rust:no-statemachine-when-no-await
Bitte um Unterstützung für das Project Goal
- Diese Arbeit wurde als Project Goal eingereicht, um sie im Compiler voranzutreiben: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
- Ohne Finanzierung ist es schwierig, größere Teile der Arbeit umzusetzen; daher wird partielle oder vollständige Unterstützung von Unternehmen oder Organisationen benötigt, die von diesem Vorhaben profitieren würden
- Kontakt:
dion@tweedegolf.com - Umfang der Arbeiten und benötigte Finanzierung sind flexibel, aber mit 30.000 € ließe sich vermutlich das Ganze oder ein erheblicher Teil davon umsetzen
1 Kommentare
Lobste.rs-Kommentare
War ein deutlich konstruktiverer Beitrag, als ich nur anhand des Titels erwartet hatte
Hoffentlich bekommt jemand, der daran arbeiten will, die nötige Unterstützung
Gut zu sehen, dass dieses Problem angegangen wird. Ich habe schon ein paar Mal Beiträge gesehen, die meinten, dass rustc LLVM zu viel Code übergibt und dann erwartet, dass der Optimierer alles richtet; dieser hier fordert insbesondere auch finanzielle Unterstützung für die Arbeit daran
Mein Gott, ich war dumm
Ich hatte async immer für grundsätzlich „aufgebläht“ gehalten, weil es in irgendeiner Form Laufzeitumgebung, Task-Tracking und Polling zur Prüfung auf Abschluss braucht. Dieser Overhead ist ja nicht null
Ich bin davon ausgegangen, dass sich die hier gemeinte „Zero-Cost-Abstraktion“ auf das Sprachfeature bezieht und getrennt von der zusätzlich eingebauten Runtime zu betrachten ist
Ich bin nicht einmal auf die Idee gekommen, mir anzusehen, was rustc überhaupt ausgibt, bevor es an LLVM geht
Für Leute, die mit async Rust nicht vertraut sind:
Das stimmt wirklich. Selbst ein verschachtelter Baum aus async-Aufrufen wird nach maximaler Optimierung zu einer einzigen Struktur mit interner Zustandsmaschine zusammengefaltet. Wirklich clever gemacht
Wenn man diesen Fall in einem Release-Build erreicht, entsteht dann eine Art Deadlock? Oder könnte es auch zu Leaks kommen, weil Tasks auf etwas warten, das immer
Pendingbleibt?Mit
.awaitkann man nicht falsch pollenEin paar Gedanken dazu:
panic=unwind. Außer bei einigen Test-Harnesses habe ich kaum Vorteile gesehen, die die Kosten gegenüberpanic=abortaufwiegen. Selbst bei Test-Harnesses könnte man unter Linux vielleicht eine ähnliche Wahl ermöglichen, indem man auf obskure Weiseclonenutzt und den Ausführungs-Thread mitwaitstatt mitpthread_joinbehandelt. Aber da kann ich auch falschliegenIst der Link bei sonst noch jemandem gerade kaputt? Bearbeitung: Der Blogpost ist etwa eine halbe Sekunde sichtbar und springt dann auf eine 404-Seite
Bearbeitung 2: Ich bin in die Liste der Blogposts gegangen und habe ein bisschen herumgeklickt; selbst wenn ich den Beitrag dort öffne, lande ich auf einer 404-Seite. Wie kann man einen Blog, der statisch ist oder es zumindest sein sollte, so kaputtmachen?
Ich habe übrigens offenbar dieselben Reproduktionsschritte ausprobiert und bei mir kam überhaupt kein 404 vor. Getestet auf Handy und Desktop, mit aktiviertem und deaktiviertem JavaScript. Das deutet darauf hin, dass das beobachtete Verhalten komplizierter gewesen sein könnte, als es auf den ersten Blick aussah