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
2 Kommentare
Hacker-News-Kommentare
Ich stimme zu, dass der Titel etwas übertrieben ist, aber der Text ist gut geschrieben und bringt den Kern gut rüber
Ich habe noch nicht genug Erfahrung, um zu Rust-Async eine starke Meinung zu haben, aber ein paar Dinge sind mir aufgefallen
Ein Vorteil ist, dass man eine explizite Runtime haben kann. Statt das ganze Projekt mit Async zu „verseuchen“, kann man standardmäßig synchron bleiben und die Runtime nur an den Ein-/Ausgabe-„Grenzen“ verwenden
Für ein Projekt, an dem ich arbeite, hat das gut gepasst, und es wirkt auch ziemlich ähnlich zu der Strategie, die Zig bei Ein-/Ausgabecode verfolgt. In diesem Fall war das Problem der Funktionsfarben weitgehend gelöst, und weil wir E/A- und CPU-zentrierten Code strikt trennen mussten, fühlte sich eine explizite E/A-Runtime natürlich an
Der Nachteil ist, dass das gesamte Ökosystem zu stark von tokio abzuhängen scheint. Das ist ähnlich, als wäre Javas GC optional, aber in der Praxis würde jeder dieselbe Drittanbieter-GC-Runtime verwenden und jede Bibliothek, die man einbindet, würde einem diese Runtime aufzwingen. Solche zentralen Abhängigkeiten sind nicht gesund
Die Anforderungen an Async-Runtimes auf Workstation-Prozessoren und in Umgebungen wie dem RP2040 sind sehr unterschiedlich. Trotzdem kann man das Backend austauschen, sodass Async-E/A-Code für kleine ARM-M0-Mikrocontroller mit einer Embedded-fokussierten Runtime wie embassy fast genauso aussieht wie Code in anderen Umgebungen
Weil dieselben Traits und Interfaces verwendet werden, muss man sich weniger um Runtime-Details kümmern. Im Vergleich zu einem kleinen RTOS oder einer selbstgebauten Async-Umgebung ist das ziemlich gut
Was man beim Schreiben von Async-Code mit embassy lernt, lässt sich auch in andere Bereiche mitnehmen
tokio ist gut gepflegt, auch wenn es kein Teil der Standardbibliothek ist, daher wirkt der aktuelle Zustand auf mich in Ordnung. Eher hätte ich Sorge, dass es schwieriger würde, andere Executor zu verwenden, wenn es in die Standardbibliothek käme, und dass die Portierung der Standardbibliothek auf andere Plattformen dadurch ebenfalls schwerer würde
Natürlich kann diese Sorge auch unbegründet sein
Logging wurde inzwischen weitgehend durch slf4j konsolidiert, aber es gibt immer noch Bibliotheken, die etwas anderes verwenden, und bei gemeinsamen Utilities war es zuerst Apache Commons und heute oft Guava
Bei JSON hat sich Jackson teilweise durchgesetzt, aber Gson und Simple-json sind ebenfalls verbreitet, und bei Nullability-Annotationen ging es von inoffiziellen Distributionen des nie formalisierten JSR-305 über das checker framework bis neuerdings zu JSpecify
Solche grundlegenden Bausteine sollte die Sprache bereitstellen, damit man Fragmentierung und eine faktische Vielzahl konkurrierender Standardbibliotheken vermeidet
Bibliotheken Executor-unabhängig zu schreiben, ist nicht besonders schwer, aber es erfordert ständige Aufmerksamkeit, und das wird in großen Teilen der Community nicht immer eingehalten
Hervorragender Artikel. Ich mag solche tiefen Optimierungsanalysen und hoffe, dass auch die Projektziele gut vorankommen
Ich hatte oft das Gefühl, dass Compiler in „kleinen“ Fällen nicht viel Aufwand in Optimierung stecken
Der Titel ist allerdings deutlich dramatischer als der Inhalt. „Async Rust Optimizations the Compiler Still Misses“ hätte mich auch zum Klicken gebracht
Man kann Async jetzt in Traits und Closures verwenden, aber das sind Updates des Typsystems und keine Änderungen an der Async-Maschinerie selbst. Waker sind auch etwas einfacher geworden, aber das ist eher eine Verbesserung auf der std/core-Seite
Soweit ich weiß, haben die Leute, die Async Rust ursprünglich eingebracht haben, teilweise einen Burnout erlebt und sich zurückgezogen, und es gab kaum Nachfolger. Umso erfreulicher ist ein PR von Leuten bei Google, der die Speicheranordnung eingefangener Variablen optimiert
Meine Kollegen und ich verwenden Async sehr viel, also sollten wir es vielleicht selbst machen oder zumindest damit anfangen. „Kostenlos“ ist wohl eher so kostenlos wie ein Hundewelpe kostenlos ist
Insofern ist der Titel etwas reißerisch, aber ich würde ihn trotzdem nicht zurückziehen
Der Autor wirkt, als verbeiße er sich in den Overhead trivialer Funktionen. Ihn stört der Overhead der Zustände „panic“ und „returned“, aber das ist kein großes Problem
Die meisten nützlichen Async-Blöcke sind groß genug, dass der Overhead von Fehlerfällen darin untergeht
Beim mangelnden Inlining könnte etwas dran sein. Aber was die Zahl massenhaft aktiver Tasks typischerweise begrenzt, ist der von jedem Task benötigte Zustandsraum
Async wirkt insgesamt wie eine unreife Idee. Gewöhnlicher Code war ohnehin schon asynchron
Wenn man auf eine Async-Aufgabe warten muss, schläft der Thread, bis sie bereit ist, und der Kernel abstrahiert das. Aber weil man es nicht mochte, Code als logische Threads zu strukturieren, hat man Callback-Systeme für Events eingeführt, und später festgestellt, dass Callbacks schwer zu durchdenken sind und sequenzielle Steuerung besser ist
Deshalb denke ich, dass Threads das richtige Programmiermodell waren
Jetzt bevorzugen Sprachruntimes aus Gründen der Portabilität und Performance „Green Threads“, aber die meisten Sprachen bieten sie nicht ordentlich an. Stattdessen entstehen Probleme wie Async-/Nicht-Async-Funktionsfarben, Scheduling, Prioritäten und fehlende Präemption. Das ist ein schlechteres Scheduling- und Prozessmodell als in den 1970ern
Auch Async-Code wird oft so geschrieben, dass er die darstellbare Parallelität nicht maximal ausschöpft. Zum Beispiel als „await process(x) für jedes X“, statt „führe N E/A-Aufgaben alle gleichzeitig aus“
In der Thread-Welt ist dieses Parallelitätsproblem aber noch schlimmer. Threads sind inhärent zu schwergewichtig, um Parallelität effizient auszudrücken, und es gibt keine Möglichkeit, sie in diese Richtung zu optimieren
Das ist keine neue Erkenntnis. Es ist seit Langem bekannt, dass Work-Stealing-Executoren eine viel geringere Latenz und konsistentere P99-Werte als traditionelle Threads liefern. Deshalb hat Apple Anfang der 2000er GCD entwickelt
Threads liefern dem Kernel-Scheduler nicht die reichhaltigeren Informationen, die er zum Verständnis der Arbeitslast bräuchte, und Kernel-Threads sind ein zu schwergewichtiges Mittel, um feingranulare Parallelität zu erreichen. Bei E/A oder gemischten Lasten statt reiner Berechnung ist es noch schlimmer
Nicht jedes Programm braucht dieses Leistungsniveau, aber mit demselben Aufwand erreicht man deutlich leichter einen höheren Performance-Standard, und in der Praxis bekommt man Latenz und Durchsatz, die mit dem traditionellen Ansatz schwer zu erreichen sind
Dass Async grundsätzlich in die richtige Richtung weist, sieht man auch an io_uring. Der Hochleistungs-E/A-Ansatz des Kernels mit io_uring unterscheidet sich komplett von traditionellem Threading und Systemaufrufen, und auch die Completion-Verarbeitung ist viel näher an Async-Parallelität. Allerdings reichen async/await allein farblich nicht aus, um Beziehungen zwischen Async-Aufgaben auszudrücken, sodass die vollständige Nutzung schwieriger ist
Als ich zuletzt mit Coroutine-/Scheduling-Code gearbeitet habe, brauchte das Erzeugen und Joinen eines sofort endenden Threads etwa 200µs, während das Erzeugen, Einplanen und Abwarten eines eigenen Green Threads nur rund 400ns dauerte
Man muss nicht 10 Jahre warten, bis wieder jemand ein absurd komplexes Async-Framework entwirft. In jeder Systemsprache kann man mit 20 Zeilen Assembler selbst Green Threads / stackbehaftete Coroutines bauen
Die Optimierung bandbreitenorientierten Codes ist eine Frage des Schedulings. Im klassischen Multithreading-Modell hat man nur begrenzte Kontrolle über Scheduling, im Async-Modell dagegen fast vollständige
Ein gut optimierter Async-Schedule ist bei derselben bandbreitenorientierten Arbeit deutlich schneller als eine gleichwertige Multithread-Architektur, der Unterschied ist riesig
Der Großteil von Hochleistungscode heute ist bandbreitenorientiert, und Async existiert, um solche Workloads leichter optimieren zu können
Wenn man nebenläufige Verarbeitung testet und prüfen will, ob Race Conditions sauber behandelt werden, sind Callbacks viel einfacher, weil man das Scheduling steuern kann. Jeder Callback repräsentiert eine getrennte Einheit, sodass man erkennen kann, welche Events man umordnen kann, und verschiedene Reihenfolgen leichter prüfen kann
Bei Threads ignoriert man Reihenfolgen dagegen leicht und denkt nicht darüber nach, wann Komplexität aus anderen Threads den aktuellen Thread beeinflussen kann. Das ist nicht wirklich einfacher, sondern eher vereinfachend
Außerdem ist es schwer, Nebenläufigkeitsszenarien tatsächlich zu verändern und zu testen, wenn man nicht künstliche Barrieren einbaut, um Threads anzuhalten, oder E/A durch Stubs ersetzt und Mocks mit Callbacks übergibt, die die Reihenfolge steuern
Das Problem bei Callbacks ist, dass der eingefangene Call Stack nicht dem logischen Call Stack entspricht. Außer bei manchen Bibliotheken oder Runtimes, die sich Mühe geben, sinnvolle Call Stacks zu liefern, braucht man gute Fehlerdefinitionen
Natürlich kann man auch beide Paradigmen mischen und so nur die Nachteile beider Welten bekommen
Wenn das Hauptziel von Rust Sicherheit ist, verstehe ich nicht, warum es panic gibt. Man sollte beweisen können, dass ein Codepfad niemals panic auslösen kann
Ich habe mir das die ganze Woche angesehen, und es ist sehr schwer, Programme zu bauen, die garantiert niemals panic auslösen. Soweit ich es verstehe, ist der Panic-Handler ungefähr 300 KB groß, und die einzige Möglichkeit, ihn wegzulassen, ist, dass der Code beim Kompilieren überhaupt keine panic-fähigen Pfade enthält. Nach der Kompilierung im Binärprogramm zu prüfen, ob der Panic-Handler enthalten ist, fühlt sich wie ein Hack an
Mit Lints kann man
unwrapund andere panic-Operationen verbieten, aber wenn es eine No-Panic-Rust-Teilmenge gäbe, würden viele der in diesem Artikel behandelten Probleme verschwindenEs ist frustrierend, mit einer Sprache zu arbeiten, in der es so viele Operationen gibt, die theoretisch panic auslösen können, obwohl das in der Praxis außer bei Dingen wie Bit-Flips nie passiert. Das gilt auch, wenn man beweisen will, dass ein Array nicht leer ist, oder wenn man Async behandelt
Am Ende schreibt man dann massenhaft Fehlerbehandlung für Situationen, die nie eintreten werden, oder benutzt seltsame Strukturen wie das Muster einer nichtleeren Liste mit getrenntem erstem Feld und Restliste. Und auch diese Struktur bringt ihren eigenen Overhead mit
Auch an mehr beweisbasierter Nutzung wird langsam gearbeitet, einschließlich Beweisen der Art, dass ein Array nicht leer ist
Wenn es kein panic gäbe und man in allen Situationen weiterlaufen müsste, müsste man an allen Stellen, die Invarianten prüfen, viel Fehlerbehandlung einbauen, um sich von Situationen wie speicherkorrumpierenden Zuständen mit gebrochenen Invarianten zu erholen
Das wäre genau dieselbe Art massiver Fehlerbehandlung für Situationen, die fast nie eintreten, über die du dich gerade beschwerst
Es ermüdet, wenn Leute erwarten, dass ihre Werkzeuge alles unfailbar machen, ohne selbst etwas tun zu wollen. Man will eine einfache API, und wenn die nicht einfach genug ist, will man Kubernetes-Container, die mit YAML „programmiert“ werden, und wenn das noch nicht einfach genug ist, dann einen klickbaren Hosting-Service von GCP oder Amazon
Letztlich geht es dann weniger ums Programmieren als um den Wunsch, fehlerfreie Apps zu konsumieren, und diese Lebensweise existiert nur in einer symbiotischen Beziehung zu Menschen, die tatsächlich Dinge bauen
Solche hässlichen, aber notwendigen Diskussionen gibt es in C++ schon seit einer Weile
Mir gefiel die ansteckende Natur von Async nie, seit es in Rust eingeführt wurde
Ich wünsche Rust Erfolg, und wenn es mehr solche Leute gibt, könnte die Zukunft von Rust heller aussehen
Ich habe kürzlich mit Rust-Async-Arbeit begonnen, und mein Hauptproblem im Moment ist Code-Duplizierung
Jede Funktion, die sowohl eine asynchrone als auch eine blockierende API unterstützen soll, muss doppelt geschrieben werden.
maybe-asyncwäre schönIch habe mir zur Umgehung Crates wie maybe-async und bisync angesehen, aber alle hatten Probleme oder starke Einschränkungen
asyncoderconstgenerisch machen kannIm Moment ist die beste Wahl für Code, der sowohl in synchronen als auch in asynchronen Welten leben soll, sans-io. Thomas Eizinger von Fireguard hat dazu einen guten Artikel geschrieben[1]
Dieses Muster löst nicht nur das Sync-/Async-Problem sauber, sondern macht Tests auch einfacher und öffnet zudem die Tür zu Techniken wie DST[2]
Ich habe ebenfalls einen Artikel zu diesem Thema geschrieben[3], in dem ich betone, dass das Problem über Async vs. Sync hinausgeht und auch unterschiedliche Executor umfasst
0: https://github.com/rust-lang/effects-initiative
1: https://www.firezone.dev/blog/sans-io
2: https://notes.eatonphil.com/2024-08-20-deterministic-simulat...
3: https://hugotunius.se/2024/03/08/on-async-rust.html
async-Funktion bereitsmaybe-asyncDer Unterschied zwischen
fn -> voidundfn -> Futureist, dass ersteres sofort bis zum Ende ausgeführt wird, während letzteres auch erst später enden kannWenn du eine Async-Funktion blockierend ausführen willst, nimm einfach einen blockierenden Executor
Was mir an diesem Artikel gefällt, ist, dass man dadurch auch die Rust-Ziele für 2026 sieht
Wir verwenden Rust im Team, mussten aber nie besonders tief einsteigen, um die Dinge zu erledigen, die wir brauchten. Trotzdem macht es Spaß zu sehen, wie sich eine Sprache mit viel Community-Feedback von Grund auf weiterentwickelt
Bei C++ hatte ich dieses Gefühl nie wirklich, und in anderen Bereichen weiß ich nicht gut genug, wie es dort abläuft
Etwas schade ist nur, dass für jedes Ziel offenbar eigene Finanzierung nötig ist, was ein bisschen nach Kickstarter wirkt. Ich frage mich, ob das das beste Modell ist, das wir bisher gefunden haben
Ein Projektziel ist ein System, in dem eine Person oder eine kleine Gruppe ausdrückt, dass sie an etwas arbeiten möchte, und die freiwilligen Helfer des Rust-Projekts um laufende Unterstützung bittet, etwa Code-Reviews oder Antworten auf Fragen
Das bedeutet nicht, dass das Rust-Projekt selbst dieses Ziel gesetzt oder zwingend unterstützt hat
Daher ist es nicht richtig, das als offizielle Rust-Roadmap zu sehen; treffender wäre: „Es gibt Beitragende, die in diesem Bereich arbeiten möchten“
Wenn Technologie kommerziell etabliert ist, läuft es leider oft so. Es ist schwer, großen Sponsoren vorzuwerfen, dass sie nur die Dinge fördern, die sie selbst interessieren
Soweit ich weiß, stammt ein beträchtlicher Teil der Finanzierung von TweedeGolf glücklicherweise von der niederländischen Regierung
Neue Features kann man „verkaufen“. Ihre Entwicklung kostet Geld, aber sie lösen echte Probleme, und wenn die Kosten des Problems höher sind als die Entwicklungskosten, sind Unternehmen meist bereit zu zahlen
Wartung ist schwieriger, aber inzwischen gibt es auch Maintainer-Fonds. Ein Beispiel ist der Fonds von RustNL: https://rustnl.org/maintainers/
Solche Fonds zielen auf breitere und dauerhaftere Arbeit, die von vielen Organisationen mit kleineren Beiträgen getragen wird
Ob es das beste Modell ist, weiß ich nicht, aber zumindest scheint es bis zu einem gewissen Grad zu funktionieren
Wenn man die Dokumentation zu Rust Async und Tokio liest, wird gut erklärt, warum CPU-intensive Teile nicht in den Async-Stack gehören, wie man grundlegende Werkzeuge wie
std::sync::Mutexeffizient in Async-Blöcken verwendet und wie man synchronen und asynchronen Code miteinander verbindetViele Codes interessieren sich nicht für Effizienz oder brauchen sie nicht, und befolgen diese Hinweise deshalb nicht. Aber es gibt viele Projekte, denen Performance und Effizienz wichtig sind, und sobald der Code in Produktion läuft, merkt man die Fallstricke. ScyllaDB ist ein Beispiel
LLMs helfen dabei auch nicht. Sie machen alles bis zu
mainasync, verwenden die falschen Grundwerkzeuge und entwerfen das System nicht sauberDas Zusammenfalten redundanten Zustands, also das Muster,
matchwie improcess_command-Beispiel aus den await-Zweigen herauszuziehen, ist heute wahrscheinlich die einfachste Sache, die jeder auf bestehenden Async-Code anwenden kannDafür braucht es keine Compiler-Arbeit, nur Refactoring
Zu der Stelle „Futures lassen sich nicht leicht inline setzen“: In einer von mir gebauten Programmiersprache habe ich einen benutzerdefinierten Pass geschrieben, der Aufrufe von Async-Funktionen innerhalb von Async-Funktionen inline setzt
Das funktioniert im Großen und Ganzen gut und kann etwas Boilerplate beseitigen, aber die resultierende Binärgröße wächst stark an
Technisch könnte Rust dasselbe tun
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