1 Punkte von GN⁺ 2026-05-06 | 2 Kommentare | Auf WhatsApp teilen
  • 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 zwei await-Stellen erzeugt 360 Zeilen MIR und die Zustände Unresumed, Returned, Panicked, Suspend0, Suspend1, während die synchrone Version nur 23 Zeilen benötigt
  • Wenn beim erneuten poll eines bereits abgeschlossenen Future statt panic einfach Poll::Pending zurü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 } ohne await erzeugt derzeit eine Zustandsmaschine mit standardmäßig drei Zuständen, aber eine Optimierung auf stets Poll::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 einem await und 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

Aufbau der erzeugten Futures

  • Der Beispielcode lässt foo() async { 5 } zurückgeben, und bar() führt foo().await + foo().await aus
  • bar hat zwei await-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_resume ist 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 bar erzeugt 360 Zeilen MIR, während die synchrone Version nur 23 Zeilen benötigt
  • Das vom Compiler ausgegebene CoroutineLayout ist faktisch eine Menge von Zuständen in Enum-Form
    • Unresumed: Startzustand
    • Returned: abgeschlossener Zustand
    • Panicked: Zustand nach einer Panic
    • Suspend0: erste await-Stelle, speichert das foo-Future
    • Suspend1: zweite await-Stelle, speichert das erste Ergebnis und das zweite foo-Future
  • Future::poll ist eine sichere Funktion, daher darf ein erneuter Aufruf nach Abschluss des Future kein UB auslösen
    • Aktuell wird nach Suspend1 Ready zurückgegeben und das Future in den Zustand Returned versetzt
    • Wird in diesem Zustand erneut poll aufgerufen, tritt eine Panic auf
  • Der Zustand Panicked scheint dazu zu dienen, ein erneutes poll eines Future zu verhindern, nachdem eine Panic innerhalb einer async-Funktion per catch_unwind abgefangen wurde
    • Nach einer Panic kann sich das Future in einem unvollständigen Zustand befinden, daher könnte erneutes poll zu UB führen
    • Dieser Mechanismus ist Mutex Poisoning sehr ähnlich
    • Für diese Interpretation des Zustands Panicked gibt es schwer belastbare Dokumentation; die Sicherheit dafür liegt bei etwa 90 %

Muss poll nach Abschluss wirklich panicen?

  • Ein Future im Zustand Returned panicet 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 poll einfach Poll::Pending zurückgibt, lässt sich der Vertrag des Typs Future ohne 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 = false bei 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=abort könnte sich eventuell sogar der Zustand Panicked selbst entfernen lassen; die Auswirkungen müssen noch genauer geprüft werden

Auch ohne await wird immer eine Zustandsmaschine erzeugt

  • foo() gibt nur async { 5 } zurück, daher wäre die optimale manuelle Implementierung ein Future ohne Zustand, das immer Poll::Ready(5) zurückgibt
  • Im vom Compiler erzeugten MIR existieren jedoch weiterhin die drei Standardzustände Unresumed, Returned, Panicked
    • Beim poll wird 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
  • 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 Ready zurückgeben

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=3 muss 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 foo 5 zurückgibt, kann die Antwort von bar aber nicht zu 10 optimieren
    • Auch der Aufruf der poll-Funktion von foo bleibt erhalten
    • Grund dafür sind potenzielle Panic-Pfade, die der Compiler nicht vollständig auflösen kann
    • LLVM weiß nicht, dass foo tatsächlich nur einmal aufgerufen wird und keine Panic auslöst
  • 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 lediglich foo(blah).await ausführt
    • Das ist ein Muster, das häufig auftritt, wenn per Trait abstrahiert wird
    • Der aktuelle Compiler erzeugt dafür eine Zustandsmaschine für bar und ruft darin die Zustandsmaschine von foo auf
    • Effizienter wäre es, wenn bar selbst das foo-Future wäre
  • Mit Preamble und Postamble wird es komplexer
    • Beispiel: bar(input) erzeugt über input > 10 ein blah, awaited dann foo(blah) und wendet auf das Ergebnis * 2 an
    • Das ist typisch, wenn async-Funktionen in eine andere Signatur transformiert werden, besonders in Trait-Implementierungen
  • Auch diese Form von bar benötigt keinen eigenen async-Zustand
    • Über die einzelne await-Stelle hinaus muss außer den von foo eingefangenen Werten nichts erhalten bleiben
    • Allerdings kann bar dann nicht einfach direkt foo selbst sein, sondern den Großteil seines Zustands an foo delegieren
  • In einer manuellen Implementierung könnte BarFut die Zustände Unresumed { input } und Inlined { foo: FooFut } haben
    • Beim ersten poll wird die Preamble ausgeführt, foo(blah) erzeugt und in den Zustand Inlined gewechselt
    • Danach wird auf das Ergebnis von foo.poll(cx) die Postamble angewendet
  • Würde sich Code bis zur ersten await-Stelle vorab ausführen lassen, könnte auch der Zustand Unresumed entfallen, aber es ist garantiert, dass ein Future vor dem ersten poll nichts 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 poll immer ready zurückgibt, müsste das aufrufende Future für diese await-Stelle keinen eigenen Zustand anlegen
    • Wendet man solche Optimierungen rekursiv an, ließen sich viele Futures zu deutlich einfacheren Zustandsmaschinen zusammenfalten
  • In der aktuellen Struktur von rustc scheint 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).await
    • CommandId::B => send_response(456).await
  • In diesem Fall enthält das CoroutineLayout jeweils _s0 und _s1, die denselben Coroutine-Typ von send_response speichern, und es entstehen die beiden Zustände Suspend0 und Suspend1
  • 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).await aufgerufen wird, verschwinden die doppelten Zustände
    • CommandId::A wird zu 123
    • CommandId::B wird zu 456
    • Danach folgt send_response(response).await
  • Nach dem Refactoring enthält das CoroutineLayout nur noch ein gespeichertes Future und nur noch einen Zustand Suspend0
  • 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

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

 
GN⁺ 2026-05-06
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

    • Je nach Kontext kann es so wirken, als hänge das gesamte Ökosystem von tokio ab, aber wenn man sich Embedded Rust ansieht, ergibt es etwas mehr Sinn
      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
    • Mich würde interessieren, was die Alternative wäre. Ich bin zufrieden damit, tokio zu verwenden, aber es ist auch gut, wenn andere Leute andere Executor wie smol, async-std oder glommio nutzen
      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
    • Die Erwähnung von Java ist interessant, denn Java hatte historisch ähnliche Probleme
      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
    • Es gibt viele Bereiche, in denen man Rust mit Async nutzen kann, ohne von tokio abhängig zu sein. Tatsächlich scheint vor allem der Web-/Server-Bereich wirklich fest an tokio gebunden zu sein
      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

    • Der Titel wurde einfach deshalb so gewählt, weil er faktisch stimmt. Seit Async um 2019 eingeführt wurde, hat sich nicht allzu viel grundlegend geändert
      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
    • Ich stimme zu, dass der Titel zu übertrieben ist
      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

    • Die Aussage „normaler Code war ohnehin schon async, und Threads schlafen beim Warten, der Kernel abstrahiert das“ ist nicht korrekt
      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
    • Sobald Kernel und OS-Scheduler eingreifen, kann man 3–4 Größenordnungen langsamer werden, als eigentlich möglich wäre
      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
    • Ob „Threads das richtige Programmiermodell“ sind, hängt davon ab, was man macht. Für rechenlastige Workloads passen Threads, für bandbreitenorientierte Workloads passt Async
      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
    • Ich finde eher, dass Callbacks einfacher zu durchdenken sind
      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
    • Threads sind nicht besser oder schlechter als Async+Callbacks, sondern ein anderes Modell. Es gibt Probleme, für die Threads gut passen, und andere, die sich mit Async deutlich besser ausdrücken lassen
  • 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 unwrap und andere panic-Operationen verbieten, aber wenn es eine No-Panic-Rust-Teilmenge gäbe, würden viele der in diesem Artikel behandelten Probleme verschwinden
    Es 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

    • Im Bereich Rust-in-Linux arbeitet man mit Dingen wie fehlerschlagenden Speicheroperationen an diesem Problem. Für sie ist das eine notwendige Funktion
      Auch an mehr beweisbasierter Nutzung wird langsam gearbeitet, einschließlich Beweisen der Art, dass ein Array nicht leer ist
    • panic ist für Benutzbarkeit und Sicherheit ziemlich wichtig
      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
    • Rusts Ziel ist Speichersicherheit. panic ist in Bezug auf Speichersicherheit vollkommen sicher
    • Selbst das Betriebssystem, auf dem dein Programm läuft, ist nicht perfekt
      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-async wäre schön
    Ich habe mir zur Umgehung Crates wie maybe-async und bisync angesehen, aber alle hatten Probleme oder starke Einschränkungen

    • Es wird an Keyword-Generics gearbeitet, mit denen man Funktionen über Schlüsselwörter wie async oder const generisch machen kann
      Im 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
    • Es hängt stark davon ab, was du konkret tust, aber wenn es einfach genug ist, könntest du vielleicht ein Makro bauen, das Typen und await austauscht
    • Das ist das klassische Problem der Funktionsfarben. https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
    • Aus meiner Sicht ist eine async-Funktion bereits maybe-async
      Der Unterschied zwischen fn -> void und fn -> Future ist, dass ersteres sofort bis zum Ende ausgeführt wird, während letzteres auch erst später enden kann
      Wenn 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

    • Der Begriff „Projektziel“ ist ziemlich irreführend im Vergleich zu dem, was er tatsächlich bedeutet
      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“
    • Es scheint selbst innerhalb des ISO-Komitees für C++ einen gewissen Konsens zu geben, dass der Evolutionsprozess dieser Sprache in gewissem Maß kaputt ist. Hauptsächlich wegen Größe und Organisationsform
      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
    • Bei Open-Source-Arbeit scheint es grob zwei Arten zu geben: Feature-Entwicklung und Wartung
      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::Mutex effizient in Async-Blöcken verwendet und wie man synchronen und asynchronen Code miteinander verbindet
    Viele 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 main async, verwenden die falschen Grundwerkzeuge und entwerfen das System nicht sauber

  • Das Zusammenfalten redundanten Zustands, also das Muster, match wie im process_command-Beispiel aus den await-Zweigen herauszuziehen, ist heute wahrscheinlich die einfachste Sache, die jeder auf bestehenden Async-Code anwenden kann
    Dafür braucht es keine Compiler-Arbeit, nur Refactoring

    • Man bräuchte zumindest ein benutzerdefiniertes Lint, das Stellen findet, an denen das möglich ist. Das käme Compiler-Arbeit schon ziemlich nahe
  • 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

 
GN⁺ 2026-05-06
Lobste.rs-Kommentare
  • War ein deutlich konstruktiverer Beitrag, als ich nur anhand des Titels erwartet hatte

    • Ich halte ihn im Grunde einfach für zutreffend. Sieben Jahre nach dem MVP-Release gab es beim Sprachdesign oder der Compiler-Implementierung kaum Fortschritte, und nachdem die Leute, die das MVP hauptsächlich gebaut hatten, ungefähr zur gleichen Zeit ihr Engagement im Projekt zurückfuhren, ist die Weiterentwicklung danach praktisch zum Stillstand gekommen.
      Hoffentlich bekommt jemand, der daran arbeiten will, die nötige Unterstützung
  • I want to work on this in the compiler and as such have submitted it as a Project Goal

    Stop generating statemachines that don’t have to be there
    Make the compiler’s job easier by removing panic paths and branches
    Make statemachines smaller

    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:

    It's amazing how we can write executor agnostic code that can run concurrently on huge servers and tiny microcontrollers.

    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 Pending bleibt?

    • Genau. Solche Futures sind dann steckengeblieben und werden nie fertig. Allerdings kann man in diesen Zustand nur aus bereits fehlerhaftem Low-Level-async-Code geraten, und Code, der abgeschlossene Futures nicht korrekt nachverfolgt, erzeugt vermutlich ohnehin schon Leaks und Deadlocks
      Mit .await kann man nicht falsch pollen
  • Ein paar Gedanken dazu:

    1. Der Beitrag wirkt auf mich wie das Argument, mehr Optimierungslogik aus LLVM herauszuziehen und auf die MIR-Ebene zu verlagern. Ich verstehe zum Beispiel, warum das Inlining von async-Funktionen in MIR einfacher ist als in LLVM. Wenn man das für async auf MIR-Ebene geschafft hat, wäre es dann nicht sinnvoll, die Logik auch auf synchrone Funktionen zu verallgemeinern und einige der LLVM-Optimierungspässe zu entfernen? Mir ist klar, dass das ein großes Vorhaben wäre; das ist weniger eine praktische Frage als eine Richtungsfrage. Ab einem gewissen Komplexitätsgrad des Frontend-/Midend-Compilers scheint es vielleicht besser, einen guten Teil der generischen LLVM-Optimierungen an anderer Stelle unterzubringen
    2. Ich bin immer noch kein Fan von panic=unwind. Außer bei einigen Test-Harnesses habe ich kaum Vorteile gesehen, die die Kosten gegenüber panic=abort aufwiegen. Selbst bei Test-Harnesses könnte man unter Linux vielleicht eine ähnliche Wahl ermöglichen, indem man auf obskure Weise clone nutzt und den Ausführungs-Thread mit wait statt mit pthread_join behandelt. Aber da kann ich auch falschliegen
  • Ist 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?

    • Der Tonfall wirkt etwas unnötig unhöflich und aggressiv. Websites können nun mal Bugs haben; es zu melden ist sinnvoll, aber dieser Kommentar klingt schon ziemlich gehässig
      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