4 Punkte von GN⁺ 2024-03-26 | 1 Kommentare | Auf WhatsApp teilen
  • Rusts async/await ist kein einfacher Ersatz für Threads, sondern ein Programmiermodell, das I/O-zentrierten Nebenläufigkeitscode als komponierbare Zustandsmaschinen ausdrückt
  • Code, der wie ein Webserver viele Verbindungen gleichzeitig verarbeiten muss, stößt mit rein linearer Ausführung an Grenzen; Threads lagern die Client-Verarbeitung mit thread::spawn aus und ermöglichen so gleichzeitige Verarbeitung
  • async/await gibt an await-Punkten die Ausführung ab und lässt den Executor andere Aufgaben weiter ausführen, sodass viele Tasks innerhalb einer Runtime verschränkt ausgeführt werden
  • Anforderungen wie ein Timeout von 3 Sekunden lassen sich in async durch die Kombination von race und Timer hinzufügen; in synchronem Thread-Code braucht man dagegen einen speziellen Wrapper für TcpStream sowie Lese-/Schreib-Timeouts, was die Allgemeinverwendbarkeit einschränkt
  • Erklärt man async nur über Performance-Overhead, entstehen bei CPU-bound Aufgaben Gegenbeispiele; die Stärke von Rust async liegt in der semantischen Ausdruckskraft und der Komponierbarkeit des Ökosystems

Der Ausgangspunkt des Nebenläufigkeitsproblems in Rust

  • Gewöhnlicher Rust-Code hat grundsätzlich eine lineare Ausführungsstruktur
    • Nach dem Ende einer Aufgabe wird die nächste ausgeführt, etwa foo(), bar(), baz()
  • Wenn wie bei einem Webserver mehrere Dinge gleichzeitig verarbeitet werden müssen, stößt diese lineare Struktur schnell an Grenzen
    • In einer Struktur, die mit TcpListener::accept() Clients annimmt und handle_client() ausführt, muss der zweite Client warten, während der erste Client verarbeitet wird
    • Wenn handle_client() einige Millisekunden dauert und es 2 gleichzeitige Clients gibt, entsteht eine kurze Wartezeit
    • Bei 2 Millionen gleichzeitigen Clients kann der Nutzer am Ende der Warteschlange mehrere Minuten warten

Wie Threads das Problem lösen

  • Betriebssystem-Threads können Registerwerte und den Programm-Stack im Speicher sichern, eine andere Routine ausführen und später die ursprüngliche Routine fortsetzen
  • Webserver-Code delegiert die Client-Verarbeitung in der Form thread::spawn(move || handle_client(client)) an einen separaten Thread
    • Der Main-Thread nimmt weiter neue Verbindungen per accept() an
    • Wenn der Thread für die Client-Verarbeitung blockiert, kehrt das OS zum Main-Thread zurück und kann die nächste Verbindung annehmen
    • Zwei Clients können nach einer Verzögerung im Bereich weniger Mikrosekunden parallel ausgeführt werden
  • Hat ein produktionsreifer Webserver Dutzende CPU-Kerne, lässt das OS Threads nicht nur so erscheinen, als würden sie gleichzeitig laufen, sondern kann tatsächlich mehrere Threads gleichzeitig ausführen

Wie async/await funktioniert

  • Für Nebenläufigkeit im Userspace gibt es mehrere Modelle, etwa ereignisbasierte Programmierung, Actor und Coroutines; Rust hat sich für async/await entschieden
  • Vereinfacht gesagt wird das Programm zu einer Sammlung unabhängig ausführbarer Zustandsmaschinen kompiliert
    • Eine async fn ist keine traditionelle Funktion, sondern eine Funktion, die eine Zustandsmaschine zurückgibt
    • await bindet eine andere Zustandsmaschine als Teilschritt in die aktuelle Zustandsmaschine ein
    • Wenn eine innere Funktion die Ausführung abgibt, etwa beim Warten auf eine neue Verbindung, übergibt die gesamte Zustandsmaschine die Kontrolle an den übergeordneten Executor
  • Ein Executor wie smol::Executor führt statt der aktuellen Zustandsmaschine andere, mit spawn erzeugte Zustandsmaschinen aus
    • Der Block async move { handle_client(client).await } ist eine neue Zustandsmaschine, die von main unabhängig ist
    • Wenn main nachgibt, läuft eine der Client-Aufgaben; gibt diese wieder nach, rotiert die Ausführung zur nächsten Aufgabe
  • Mit dieser Struktur lassen sich Millionen Clients gleichzeitig handhaben, doch zusätzliche Konzepte wie Executor, Tasks und Zustandsmaschinen erhöhen auch die Komplexität

Komponierbarkeit am Beispiel von Timeouts

  • Eine der Stärken von Rust ist Komponierbarkeit
    • Iterator erlaubt es, mehrere Kombinatoren anzuhängen und das Ergebnis erneut an Funktionen zu übergeben, die einen Iterator entgegennehmen
    • Wie bei mpsc::channel()s recv.try_iter().filter(...).map(...) lassen sich Werte filtern, transformieren und einer Liste hinzufügen
  • async/await macht diese Komponierbarkeit auch auf I/O-bound Funktionen anwendbar
  • Wenn handle_client() eine asynchrone Funktion ist, die read_to_end, do_something_with_data und write_all per await aufruft, lässt sich ein Timeout von 3 Sekunden durch die Kombination zweier Futures implementieren
    • race führt zwei Futures gleichzeitig aus
    • Timer kehrt nach Ablauf einer angegebenen Zeit zurück
    • Man muss den bestehenden Code zur Verbindungsbehandlung nur in einen async-Block einwickeln und ihn gegen ein Future antreten lassen, das nach 3 Sekunden einen TimedOut-Fehler zurückgibt
  • Dieser Ansatz ist nicht nur an TcpStream gebunden
    • Auf alles, was impl AsyncRead + AsyncWrite implementiert, lässt sich dasselbe Muster anwenden
    • Auch Ziele wie ein GZIP-Stream über einem gewöhnlichen Stream, Unix-Sockets oder Dateien sind austauschbar

Einschränkungen bei derselben Timeout-Implementierung in synchronem Thread-Code

  • In blockierendem Code ist es im Allgemeinen schwierig, Systemaufrufe wie read oder write abzubrechen; Methoden wie das Schließen des File Descriptors sind in Rust nicht verwendbar
  • TcpStream bietet set_read_timeout und set_write_timeout
    • Für Lesen und Schreiben lassen sich jeweils Timeouts setzen
    • Wenn ein Client jedoch alle 2,9 Sekunden 1 Byte sendet, kann ein einfaches Timeout immer wieder zurückgesetzt werden
  • Um sich dagegen zu schützen, muss man einen Typ wie DeadlineStream erstellen, der TcpStream umhüllt und bei jedem Lese-/Schreibvorgang die bis zur Gesamt-Deadline verbleibende Zeit berechnet und als Timeout setzt
  • Dieser Ansatz kann funktionieren, hat aber große Einschränkungen
    • Er ist an TcpStream gebunden
    • Rust hat kein Trait, das die Nutzung von set_read_timeout und set_write_timeout abstrahiert
    • Für die Anwendung auf einen generischen Writer ist viel zusätzliche Arbeit nötig
    • Für das Setzen der Timeouts kommen zusätzliche Systemaufrufe hinzu
    • In realer Webserver-Logik kann die Nutzung noch umständlicher sein

Beispiele aus dem Rust-async-Ökosystem

  • Dass das HTTP-Ökosystem einschließlich Clients async/await als zentralen Runtime-Mechanismus übernommen hat, liegt an der Komponierbarkeit von Funktionen
    • Funktionen, die HTTP-Aufrufe ausführen, lassen sich passend zu verschiedensten Lücken und Use Cases einsetzen
  • tower ist ein repräsentatives Beispiel für die Komponierbarkeit von async/await
    • Implementiert man Services als async-Funktionen, lassen sich Timeouts, Rate Limiting, Load Balancing, Hedging und Backpressure-Verarbeitung hinzufügen
    • Unabhängig davon, welche Runtime verwendet wird und was intern im Service passiert, kann tower angewendet werden, um die Robustheit zu erhöhen
  • macroquad ist eine kleine Game Engine für Rust und verwendet async/await in der Main-Funktion, um die Engine auszuführen
    • async/await eignet sich in Rust, um Situationen auszudrücken, in denen eine lineare Funktion angehalten werden muss, um auf eine Aufgabe zu warten
    • Möglich sind etwa Konfigurationen, bei denen im selben Thread Netzwerkverbindungen eines Gameservers und ein GUI-Framework gleichzeitig gepollt werden

Grenzen einer rein performancebasierten Erklärung von async

  • Das Rust Async Book vergleicht OS-Threads damit, dass sie Nebenläufigkeit ohne Änderung des Programmiermodells leicht ausdrücken lassen, Synchronisierung zwischen Threads aber schwierig ist, der Performance-Overhead groß ist und auch Thread Pools große I/O-bound Workloads nicht ausreichend unterstützen können
  • In der async-Community gibt es die Tendenz, auf die Frage, warum man async statt OS-Threads verwenden sollte, sinngemäß mit „weniger Overhead, ansonsten gleich“ zu antworten
  • Der Grund, warum Webserver-Autoren zu async/await gewechselt sind, war die Lösung des C10k-Problems; aber Performance muss nicht für alle Nutzer der Grund sein, async/await zu wählen
  • Performancevorteile können je nach Situation verschwinden
    • Bei CPU-bound Aufgaben kann ein threadbasierter Workflow schneller sein als ein entsprechender async-Workflow
    • Der vorübergehende Performancevorteil von Rust async wurde überbetont, während die semantischen Vorteile unterschätzt wurden
  • async/await ist kein Werkzeug für Nischenfälle, sondern ein mächtiges Programmiermodell für Muster, die sich in synchronem Rust ohne Dutzende Threads und Channels nur schwer prägnant ausdrücken lassen

Unterschiede akzeptieren, statt es wie synchrones Rust zu machen

  • In der Rust-Projekt-Roadmap gibt es die Richtung, dass das Schreiben von async Rust abgesehen von der gelegentlichen Nutzung der Schlüsselwörter async und await so einfach sein sollte wie das Schreiben synchronen Codes
  • Es gibt jedoch auch die Sichtweise, dass das Framing, async Rust „genauso wie sync Rust“ zu machen, grundsätzlich schwierig ist
    • Selbst wenn man es zu 99 % ähnlich machen kann, werden durchschnittliche Nutzer die Unterschiede zwangsläufig bemerken
  • Das async/await-Ökosystem von Rust sollte nicht versuchen, synchronem Rust gleich zu werden, sondern seine Stärken – Komponierbarkeit und Ausdruckskraft – deutlicher herausstellen
  • Damit async/await zur Standardwahl wird, wenn Nebenläufigkeit gebraucht wird, sollte dieses Modell eher aus semantischen Gründen erklärt werden als aus technischen Performancegründen

1 Kommentare

 
GN⁺ 2024-03-26
Hacker-News-Kommentare
  • Single-Thread-async/await ist ein einfaches und gut bekanntes Modell; JavaScript funktioniert so.
    Threads ermöglichen es, mehrere CPUs zur Problemlösung einzusetzen, und Rust hilft beim Lock-Management. Man kann auch Threads mit unterschiedlichen Prioritäten haben, was für rechengebundene Aufgaben nötig sein kann.
    Multi-Thread-async/await hingegen wird unübersichtlich. Sobald ernsthaft rechengebundene Abschnitte hinzukommen, blockieren sie faktisch Threads, die mit anderen Aufgaben geteilt werden, wodurch das Modell leicht zusammenbricht.
    Rusts rechengebundenes Multi-Threading funktioniert nicht so gut wie erhofft. Im Allocator können mehrere Threads auf denselben Lock einschlagen, wodurch es zu einem futex congestion collapse kommen kann; besonders wenn während einer Buffer-Erweiterung bei gehaltenem globalem Allocator-Lock erneut kopiert wird, wird das sehr teuer. Der Bibliotheks-Allocator in den .DLLs der Microsoft-Library-Emulation von Wine war anfällig dafür: Die CPU-Zeit ging vollständig in Spinlocks auf, die Performance brach um zweistellige Faktoren ein, während Microsofts Implementierung das Problem nicht hatte.
    Außerdem können der Standard-Mutex und die Channels von crossbeam-channel zu Starvation durch unfaire Mutexes führen. Wenn mehrere Threads wiederholt eine Ressource locken, arbeiten und wieder freigeben, kann ein Thread ständig gewinnen und die übrigen verdrängen. Wenn ein fairer Mutex nötig ist, gibt es parking-lot, allerdings ohne die Poisoning-Sicherheit bei Thread-Panics, die der Standard-Mutex bietet.
    Wenn es nicht I/O-bound ist, wird es erheblich komplizierter.
    https://users.rust-lang.org/t/mutex-starvation/89080

    • Stimmt. Es ging zwar hauptsächlich um I/O-bound Berechnungen, aber auch dort entstehen Contention-Probleme.
      Wenn der I/O-Durchsatz ohnehin wieder die Grenze ist, fragt man sich, was eine Million Coroutines bringen sollen. Wenn ein DB-Connection-Pool der Größe 10 sofort erschöpft ist, retten Coroutines einen nicht; sie machen Debugging und Workarounds nur schwieriger und das Ganze schwerer nachvollziehbar.
    • Ich denke, dass man dieses Problem am Ende vielleicht wieder auf Hardwareebene neu durchdenken muss.
      CPU-bound Probleme scheinen auf systematisches Unterbrechen/Fortsetzen hinauszulaufen. Wenn für n laufende Ausführungs-Threads ein fairer und effizienter Queue-basierter Kontextwechsel möglich wäre – etwa eine CPU mit n laufenden Kontexten –, könnte das Problem dann nicht zu einem Problem der Ressourcenzuteilung werden?
    • Ich weiß nicht, warum die Schwierigkeiten von kooperativem Multitasking immer wieder neu entdeckt werden.
      Selbst Go, das ich für eine verantwortungsvoll entworfene Sprache halte, begann zunächst kooperativ und musste am Ende auf präemptiv umstellen. Das heißt nicht, dass kooperatives Multitasking nutzlos ist, aber es sollte mit Warnhinweisen versehen sein; mehr noch, bestimmte Arten von Code sollte man möglicherweise schon statisch an der Ausführung hindern.
      Als verwandten Text lasse ich „What color is your function“ hier.
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
    • Ich wiederhole immer wieder, dass das ein Implementierungsdetail ist.
      Multi-Thread-Executoren für async/await können Starvation durchaus handhaben, und die .NET-Implementierung hält sogar sehr schlechten Code aus, der blockierende Aufrufe und Asynchronität mischt.
      https://news.ycombinator.com/item?id=39530435
      https://news.ycombinator.com/item?id=39786142
      https://news.ycombinator.com/item?id=39721626
    • Die farbigen Funktionen, die async/await erzeugt, verursachen auch zusätzliche Kosten in Softwareentwicklung und Wartung.
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
      Wenn die Software keine hohe Skalierbarkeit braucht, sind die Kompromisse von async den Preis möglicherweise nicht wert.
  • Der Kern der Debatte um async/await und Threads ist nicht, welche Seite komplexer ist, sondern dass das Ökosystem in zwei Teile gespalten wird und eine Seite zum Bürger zweiter Klasse wird, sodass Reibung entsteht, wenn man sich in einem Projekt falsch entscheidet.
    Man kann beides mischen, aber wenn es nötig wird, ist es hacky und ineffizient. Im heutigen Rust-Ökosystem ist praktisch entschieden, dass man, sobald I/O ins Spiel kommt, an das async/await-Ökosystem gebunden ist. Und fast alles, was man in Rust tun will, umfasst mit wenigen Ausnahmen I/O, daher muss man nicht-asynchrone Libraries im Allgemeinen ignorieren, ob der Rest der Anwendung async will oder nicht.
    Hätte Rust eine besser komponierbare Abstraktion als async/await verwendet und hätte diese Komponierbarkeit nicht verlangt, auch alles andere zu async/await zu machen, wären die meisten Beschwerden wohl verschwunden.

    • Ich stimme der Diagnose zu. In meinem Artikel zu Rust async[0] bin ich zum gleichen Schluss gekommen.
      Noch schlimmer ist, dass das Ökosystem nicht nur zweigeteilt wird, sondern man auch innerhalb von async-Code meist stark an einen Executor gebunden ist, in der Regel Tokio. Wenn man das Problem der Funktionsfarben erweitert, hat man statt Blau (kein I/O), Grün (blockierendes I/O) und Rot (async I/O) in der Praxis Blau, Grün, Rot (Tokio), Lila (async-std), Orange (smol) und so weiter.
      Für dieses Problem halte ich das sans-I/O-Pattern für die beste Lösung. Wenn man sämtlichen blauen Code trennt und für I/O und Zeit Inversion of Control nutzt, kann die zentrale Protokolllogik nichts von I/O wissen und lässt sich leicht mit verschiedenen I/O-Formen umhüllen.
      0: https://hugotunius.se/2024/03/08/on-async-rust.html
    • Man muss nicht-asynchrone Libraries nur dann ignorieren, wenn es zwei wählbare Libraries gibt und der Rest gleich ist. Solche Fälle sind selten.
      Blockierenden Code in einer async-Anwendung zu verwenden ist nicht so nahtlos, wie man hoffen würde, aber auch nicht schwierig. Statt foo() verwendet man tokio::spawn_blocking(foo).await; das führt den neuen Code in einem separaten Thread aus und gibt ein Future zurück, das abgeschlossen ist, wenn dieser Thread fertig ist.
    • C# ist im Grunde ähnlich.
      Für I/O gibt es zwar nicht-asynchrone Optionen, aber wenn man die asynchrone Option nutzt, wird man praktisch gezwungen, alles bis hin zu Main() async zu machen. Es gibt auch sichere Wege, aus einer synchronen Methode eine async-Methode aufzurufen, aber sie machen das Debugging extrem schwierig.
  • Im Artikel fehlt vieles.
    async/await läuft in einem Thread-Kontext, sodass keine Locks oder Synchronisation nötig sind; wenn man async/await aber auf mehreren Threads ausführt, um CPU-Kerne zu nutzen, braucht man Locks und Synchronisation wieder. Diese Komplexität kann vor externem Code verborgen werden. Zum Beispiel ist es einfacher, für jede async-Aufgabe eine eigene DB-Verbindung zu öffnen, statt den Zugriff auf eine einzelne DB-Verbindung zu synchronisieren, aber bei SQLite oder PostgreSQL kann das die Performance beeinflussen.
    Bei async/await ist Fehlerweitergabe nicht klar. Das gilt besonders, wenn man async-Aufgaben bündeln will; Happy Eyeballs ist ein typisches Beispiel.
    Wenn es um Netzwerk-I/O geht, muss man auch Backpressure behandeln. Die async/await-Implementierung von CPython ist berüchtigt dafür, zu wenig Netzwerk-Backpressure zu haben, und daraus entstehen Probleme.

    • async/await hat viele Probleme, aber mein größter Einwand ist dieser:
      „Design Patterns“ der Gang of Four war größtenteils ein Kochbuch, um C++-Mängel zu umgehen, doch Leute haben diese Patterns auch auf Sprachen angewendet, die diese Mängel gar nicht haben.
      Rust ist nicht JavaScript und kann mehrere Threads gut ausführen. Deshalb musste man nicht zwingend async/await verwenden; als Systemsprache hätte Rust andere Lösungen ausprobieren können.
      Aber um Rust JavaScript-Programmierern schmackhaft zu machen, brauchte man async/await. without.boats schrieb, er habe async/await „mit der aufrichtigen Leidenschaft vorangetrieben, die aus der Annahme entstand, dass Rusts Überleben von diesem Feature abhängt“.
      https://without.boats/blog/why-async-rust/
      Ob async/await technisch gut zu Rust passt, war nicht wichtig; es wirkt eher so: JavaScript-Programmierer waren an async/await gewöhnt, also musste Rust ebenfalls async/await bekommen.
    • async/await ist, genau wie Threads, ein Nebenläufigkeitsmechanismus, und beim Zugriff auf gemeinsam genutzten Speicher braucht man immer Locks. Ich weiß nicht, woher die Aussage kommt, dass man keine Locks brauche.
    • async kann in Bezug auf Locks sogar beängstigender sein.
      Ein Codeblock verließ sich vielleicht auf exklusiven Zugriff, und weil es kein await gab, war das garantiert; fügt man mitten darin ein await hinzu, geht der Code kaputt. Threading zwingt einen zumindest, im Code festzuhalten, wofür exklusiver Zugriff nötig ist.
      async bedeutet auch, dass man sein Thread-Scheduling selbst verwalten will. Wenn es viel I/O und nur kurze CPU-bound-Codeabschnitte gibt, ist das in Ordnung; wenn es aber CPU-bound Code gibt, auch nur gelegentlich, spielt man Scheduler.
    • Als ich zu einem Team stieß, das Node.js verwendete, hatten wir Backpressure-Probleme.
      Mehrere Services starben einfach mit ABEND, und aus Java kommend fand ich diese Lücke überraschend. Es war auch schwierig, dem Team zu erklären, wie man das behebt.
      Wegen der Fehlerweitergabe würde ich async/await nicht verwenden, wenn ich die Wahl habe. Für ein Solo-Projekt vielleicht, aber wenn ich mit anderen zusammenarbeite, Libraries nutze und alle auf dasselbe Verständnis bringen muss, auf keinen Fall.
      Sprachseitige strukturierte Nebenläufigkeit habe ich noch nicht ernsthaft genutzt, aber ich setze Hoffnungen in Javas Project Loom. Auf den ersten Blick scheint es diese Debatte bedeutungslos zu machen.
    • In Rust läuft async/await nicht nur in einem Thread-Kontext.
  • Der Beitrag hat ein paar Probleme
    Es gibt nur ein Beispiel, einen Webserver, und die Thread-Lösung ist falsch gelöst. Außerdem tut die Frage so, als wollten die Leute OS-Threads statt async/await
    Was Programmierer wollen, sind konzeptionelle bzw. semantische Threads. Sie wollen sequenzielle Logik schreiben und keine seltsamen Annotationen wie async verwenden. Wenn async/await so gut ist, warum macht man dann nicht einfach alle Funktionen implizit async und verwendet normale Funktionsaufrufe statt await? Dann würde man im Grunde mit Threads programmieren
    OS-Threads sind wegen statisch allokierter Stacks teuer; was wir wollen, sind günstige Threads, von denen auf einer einzelnen CPU Millionen laufen können. Nur eben ohne die klobigen Wörter async/await. wait kann für blockierendes Warten im klassischen Sinn bleiben, etwa auf Events oder das Ende anderer Threads, aber nicht für Funktionsaufrufe
    Zurück zum Webserver-Beispiel: Wenn ein Timeout mit driver.race(timeout).await implementiert wird, was passiert dann mit dem Client-Socket, nachdem race den Timeout-Fehler gemeldet hat? Leakt er nicht offen und verbunden weiter?
    Auch die Timeout-Variante mit Threads könnte man fast genauso wie async/await bauen, etwa threaded_race(client_thread, timeout).wait. threaded_race verfolgt per Timer parallel zum Thread den Timeout und ruft, wenn die Zeit abläuft, im Java-Stil client_thread.interrupt() auf. Thread.interrupt() setzt nur ein Flag, wenn der Thread nicht blockiert ist, und wirft eine InterruptedException, wenn er in einem I/O-Aufruf blockiert ist. Da es eine geprüfte Exception ist, zwingt der Compiler dazu, client.read_to_end(&mut data) in try/catch einzuschließen oder die Exception in handle_client zu deklarieren, sodass der Programmierer nicht vergisst, den Client-Socket zu schließen

    • Die Werte innerhalb von race() werden Dropped, und driver selbst bleibt bestehen
      Wenn man die Typen unverändert verwendet, wird Rust sich beschweren, dass das Result nicht behandelt wird; und wenn innerhalb des Futures ein neuer Socket lokal erstellt wurde, wird er aufgeräumt
      Das Gute an Rust-Futures ist, dass man dieses gesamte Verhalten drumherum definieren kann. Anders als in einem Modell, in dem alle Funktionen blockieren, kann Rust festlegen, wann die Ausführung auf die nächste Aufgabe in der Arbeits-Queue verschoben wird, und mit explizit gespeichertem Zustand (der Future-Struct) Aufgaben beliebig schnell pollen. Deshalb muss man nicht wie bei Threads mit sleep() abgeben; das ist schnell und leichter nachzuvollziehen
      Auch Javas Thread.interrupt ist am Ende eher eine Art Sleep-Loop und für die meisten Anwendungen vielleicht in Ordnung. Rust ist aber eine Systemsprache; in Embedded-Systemen kann man sich so etwas nicht leisten, und auch für Kernel oder Low-Latency-Anwendungen ist es nicht wünschenswert
    • Manche Programmierer wollen sequenzielle Logik, aber viele wollen genau das Gegenteil
      Meistens ist mir ziemlich egal, ob es ein blockierender oder nicht-blockierender Systemaufruf des OS ist; ich möchte aber den Kontrollfluss des Programms verstehen, das ich lese, und wissen, wo gewartet wird und wie es parallel laufen kann
      Eigentlich hätte ich bei der Arbeit mit blockierenden Funktionen gern ein Keyword-Paar blocking/block. Blockierende Aufrufe können subtil alles langsamer machen, und ich habe zu viele frustrierend langsame Apps gesehen, in deren UI-Thread blockierende Systemaufrufe gelandet sind
    • Alle Funktionen implizit async zu machen und sie wie normale Funktionsaufrufe zu verwenden, wurde in den vergangenen Jahrzehnten mehrfach versucht. Such einfach nach „RPC“
      Alle Versuche, synchron und asynchron zu vereinheitlichen, sind gescheitert. Zwischen Code, der innerhalb eines Threads läuft, Code, der zwischen Threads läuft, und sogar Code, der zwischen Computern läuft, gibt es große semantische Unterschiede. Wenn man versucht, das wegzuabstrahieren, reicht die Abstraktion am Ende nicht aus; deshalb ist es besser, es von Anfang an richtig zu lernen
    • Ich erinnere mich, dass withoutboats in irgendeinem Beitrag gesagt hat, die eigentliche Antwort sei Kompatibilität mit C
    • Man kann Code auch mit poll() und select() schreiben, aber das ist wieder ein eigener Ansatz
  • Es ist interessant, eine fast schon marketingartige Kampagne zu sehen, die async/await das Gesicht wahren soll
    Meiner Erfahrung nach war das nicht nur ein technischer Fehler, sondern hat auch die Community teuer zu stehen gekommen. Statt sich auf tatsächlich nützliche Sprachfeatures zu konzentrieren, sind Rusts Anstrengungen auf diesen Irrweg der Verwirrung geraten
    Trotzdem habe ich weiterhin große Erwartungen an die Sprache und halte sie für das Beste, was wir derzeit haben. Ich mache mir nur Sorgen, dass dieser Streit ewig weitergeht
    PS: Die Beispiele mit AsyncWrite/AsyncRead sehen überzeugend aus, aber wenn man sich auf *nix beschränkt, kann man dasselbe tatsächlich auch mit Threads und File Descriptors machen

    • Ich habe async schon in Firmware eingesetzt, und es war ein Lebensretter
      Diese Verallgemeinerung ist schlecht begründet und scheint auf bestimmte Workloads verzerrt zu sein
    • Ich kenne Rust nicht besonders gut, daher weiß ich nicht, ob das stimmt, aber aus meiner Erfahrung drehen sich heutzutage gefühlt 9 von 10 Rust-Diskussionen auf HN/reddit um async
      Wenn man sich überhaupt nicht für async interessiert und einfach etwas über Rust lesen möchte, ist das ziemlich unbefriedigend
    • Wenn du glaubst, dass Threads schneller sind als poll(), würde ich gern wissen, um welchen Anwendungsfall es geht. Ich habe in meinem ganzen Leben noch nie so einen Fall gesehen
    • Es ist kein technischer Fehler; wenn man Async-Code mit extrem niedriger Latenz braucht, ist es eine hervorragende Lösung
      Der Fehler war, es auch in die große Mehrheit der Anwendungsfälle zu drücken, die so etwas nicht brauchen
    • Ich frage mich, ob es Belege für die Behauptung gibt, dass async anderen nützlichen Features Ressourcen weggenommen hat
      Viele wichtige Rust-Projekte hängen nicht nur wegen großer Performancegewinne gegenüber threadbasierten Alternativen von async ab, sondern auch wegen seiner Designeigenschaften. Bei wichtigen I/O-gebundenen Workloads sind diese Vorteile leicht zu erkennen. Dass kluge Leute, die reale Probleme lösen, async in wichtigen Crates breit übernommen haben, ist für mich ein starkes Signal dafür, dass async tatsächlich ein nützliches Sprachfeature ist
      Der Streit findet vor allem auf Hacker News und reddit statt, in der Form, dass Leute, die async nicht brauchen, wütend sind, weil I/O-Crates, die sie nutzen, nun async verlangen. Ich verstehe, dass das keine schöne Situation ist, und es stimmt auch, dass async echte Probleme hat und einige davon noch gelöst werden. Es ist nicht perfekt. Aber die Spaltung rund um async, die man in Foren sieht, wirkt auf mich nicht so breit oder dramatisch wie in realen Projekten
  • Was hier stark fehlt, ist Abbruch
    Futures lassen sich sehr leicht abbrechen. Thread-Abbruch ist dagegen schmutzig wie ein Whac-a-Mole-Spiel, und ein erzwungenes Beenden von Threads ist wegen des Risikos, dass Locks gesperrt zurückbleiben, nicht vertrauenswürdig
    Im async-Modell von Rust kann man jedes Future von außen mit einem Timeout versehen. Nicht jede unterste I/O-Funktion muss eine Timeout-Option unterstützen, und man muss dieses Timeout auch nicht durch den gesamten Call-Stack weiterreichen
    Kombiniert man das mit Drop-Guards, der Best Practice in Rust für die Verwaltung laufender Zustände, lassen sich auch große und komplexe Aufgaben einfach und zuverlässig abbrechen

    • Es ist nicht so, dass sich alle Futures leicht abbrechen lassen, sondern dass man bei allen Futures leicht so tun kann, als seien sie abgebrochen
      Wenn man zum Beispiel etwas, das spawn_blocking verwendet, abbricht (drop), läuft es im Hintergrund trotzdem weiter, und der Nutzer merkt es womöglich nicht. Auch async-Dateisystemoperationen, die über einen Thread-Pool implementiert sind, laufen nach einem Abbruch weiter
      So etwas kann zu schwer verständlichen Bugs führen, etwa: „Ich bin sicher, dass nichts diese Datei schreibt — warum schlägt der Dienst dann fehl, weil die Datei in Benutzung ist?“
    • Wenn man async Futures implementieren konnte, hätte man stattdessen auch abbrechbare Threads implementieren können
      Das Problem ist ziemlich isomorph. Systemaufrufe sind schwierig, aber ob man sie in einem Thread oder in einem async Future ausführt: Bei demselben Systemaufruf entstehen exakt dieselben Abbruchprobleme
    • Ich verstehe nicht, warum Thread-Abbruch schwierig sein soll
      Man hält einen Zustand wie ein Flag vor, auf das alle Threads zugreifen können, und lässt die Arbeitsschleife dieses Flag prüfen. Ist es false, kehrt sie zurück, und man joint den Thread — fertig
    • Meiner Erfahrung nach war Abbruch nichts, worüber man sich groß Sorgen machen musste
      Wenn eine Aufgabe nicht mehr nützlich ist, reicht es, wenn diese Information irgendwann für die Funktion sichtbar wird, die stellvertretend für sie aufgerufen wurde. Solange man nicht unmittelbar vor etwas sehr Teurem wie dem Start eines RPC steht, prüfe ich das nicht extra
  • Die bessere Frage ist meiner Ansicht nach: „Warum async/await statt fiber?“
    Ich weiß, dass Rust vor 1.0 Green Threads hatte und sie bewusst entfernt hat, aber es gibt mehrere Ansätze für Fiber-basierte Nebenläufigkeit, etwa solche, bei denen man keine schwere Runtime in die Sprache einbauen muss.
    Wenn ich den Artikel richtig verstanden habe, lobt er vor allem, dass man ein Future jederzeit droppen kann. Bei Threads kann man aus offensichtlichen Gründen nichts Vergleichbares tun, und selbst wenn es technisch möglich wäre, wäre es extrem unsicher. Diese Fähigkeit hat jedoch enorme Kosten. Man kann nicht nur Completion-basierte Executor wie io-uring nicht zusammen mit Stack-basierten Arrays nutzen oder Unteraufgaben auf anderen Executor-Threads ausführen lassen, sondern es entstehen auch subtile Fallstricke und Zuverlässigkeitsprobleme, die sehr unangenehme Überraschungen sind, wenn man synchrones Rust gewohnt ist.
    https://smallcultfollowing.com/babysteps/blog/2022/06/13/asy...
    Task-Abbruch muss im Kern kooperativer Abbruch sein; nichtkooperativer Abbruch wirkt oberflächlich bequem, ist aber meiner Meinung nach eher ein fehlgeleitetes Feature mit tief sitzenden Problemen darunter.
    Auch das Lob für die Komponierbarkeit von async/await ist seltsam. Im heutigen Rust ohne proper effect system ist es wegen seiner Ansteckungswirkung weit von Komponierbarkeit entfernt. Man muss zum Beispiel nur versuchen, die map-Methode der Standardbibliothek mit einer async Closure zu verwenden, oder die Standard-Traits io::Read/Write.

    • Wenn man Fiber in Rust als kooperative Nebenläufigkeitsabstraktion im User Space einführen will, erzwingt das mehrere Designentscheidungen.
      Man muss etwa wählen, ob Stacks als Spaghetti Stacks implementiert werden, ob eine prozessweite Memory-Mapping-Bibliothek erforderlich ist oder ob man sich auf Stacks fester Größe beschränkt.
      Alle drei Ansätze machen Probleme, wenn sie mit Code aus Sprachen interagieren, die eine andere ABI haben. Wenn man etwa aus einem Fiber heraus C-Code aufruft und dieser C-Code versucht, einen anderen Fiber fortzusetzen, kann das ziemlich kompliziert werden.
      Einer der Vorteile von async/await ist das Schlüsselwort await selbst. Dank expliziter Wartepunkte kann man die Interaktionen eines nebenläufigen Programms tatsächlich nachvollziehen.
      Ein yieldender Fiber ist ein bisschen wie das goto der Nebenläufigkeitswelt. Wenn man eine Methode aufruft, weiß man nicht, ob die Ausführung als Nebeneffekt anhält oder ob sich der Zustand der Welt beim Fortsetzen geändert hat. Da man an der Grenze zur Außenwelt defensiv programmieren muss, passen Fiber besser zu Aufgaben, die isoliert laufen und über Completion kommunizieren.
      Green Threads, Fiber und Coroutines teilen hier dasselbe Problem. Kooperative Nebenläufigkeit im User Space löst die schwierigen Teile der Nebenläufigkeit nicht wirklich, sondern verschiebt eher Papier auf dem Schreibtisch. Rusts async/await ist expliziter und versteckt daher die Nebeneffekte nicht, die andere Mechanismen verbergen.
    • Ich sehe nicht, wie Fiber das Abbruchproblem lösen. Ist das nicht nahezu gleichwertig?
      Fiber-basierter Code fühlt sich schwer nachvollziehbar an, weil man den gerade laufenden Thread im Kopf verfolgen muss. Zumindest für mich ist es viel einfacher, Werte zu verfolgen, die irgendwann fertiggestellt werden.
    • Stackful Fiber sind für Low-Level-Code nicht besonders gut.
      Siehe Gor Nishanovs Review für das C++-Komitee http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p136.... Darauf wird auch hier verlinkt: https://devblogs.microsoft.com/oldnewthing/20191011-00/?p=10.... Die Zusammenfassung ist ebenfalls eindeutig: DO NOT USE FIBERS!
    • Da es um Ruby geht, ist die Thread-/GVL-Situation anders als bei Rust, aber vielleicht ist so etwas gemeint:
      https://m.youtube.com/watch?v=qKQcUDEo-ZI
      Meiner Ansicht nach zeigt das recht gut, warum async/await ansteckend und unbeholfen ist und warum Fiber zumindest in der Ruby-Implementierung ein deutlich besseres Paradigma sind.
    • Bei mathematischen Problemen kann kooperativer Abbruch ziemlich nervig sein.
      Ein Optimierungsalgorithmus kann ein Nullstellensuchproblem aufrufen, das wiederum einen ODE-Integrator aufruft, und so weiter; jede dieser Ebenen kann sehr lange laufen. Man müsste überall Cancellation Tokens durchreichen, aber numerische Frameworks unterstützen das normalerweise nicht.
      Man kann und sollte für alle Algorithmen eine Begrenzung der Iterationszahl setzen, aber bei verschachtelten Algorithmen garantiert das eher nur, dass sie irgendwann dieses Jahr anhalten, nicht unbedingt innerhalb von 5 Sekunden.
      Bei solchen Problemen kann ich garantieren, dass meine Arbeit nur aus massiven mathematischen Berechnungen, Allokationen und den daraus folgenden Page Faults besteht, ohne I/O, sowie dem Schreiben von Log-Strings in ein Standardbibliotheks-Queue-Objekt, das im Main Thread verarbeitet wird und nicht abgebrochen werden soll. Andere benötigte Funktionen kann man ebenfalls über die Queue zurück an den Main Thread schicken.
      Im 21. Jahrhundert sollte dieses Problem meiner Meinung nach lösbar sein, ohne überall zwanghaft Cancellation Tokens durchzureichen und ohne Leute dazu zu bringen, defensiv gegen lange laufenden Code zu programmieren, der das Token nicht prüft.
  • Wieder eine async/await-Diskussion, in der Leute async/await nicht verstehen, sich nicht vorstellen können, warum man auf einem einzelnen Thread einen Nebenläufigkeitsmechanismus braucht, und annehmen, niemand brauche das.
    UI-Programmierung, Kommunikation mit der GPU und Kommunikation zwischen Runtimes sind gute Beispiele, und es gibt sicher weitere.
    Threads, ob Green Threads oder nicht, passen in solchen Fällen nicht, async/await dagegen schon.

    • In GUIs kann man Threads problemlos verwenden, und ich habe früher mehrere GUI-Apps geschrieben, die Threads ziemlich effektiv genutzt haben.
    • Es ist definitiv wichtig, auf einem einzelnen Thread mehrere Aufgaben explizit verwalten zu können.
      Wenn sich andere Sprachfeatures implementieren lassen, die dasselbe Binary erzeugen und für die Nutzer der Sprache weniger lästig sind, lohnt es sich, auch darüber zu sprechen.
  • Einer der großen Vorteile von Rusts async/await ist, dass es auch in Situationen ohne Threads oder dynamischen Speicher funktionieren kann.
    Es ist auch gut genug, um auf einem Mikrocontroller knappen Code zu schreiben, der wartet, bis ein Interrupt die über I2C eingehenden Daten in einen bestimmten Puffer liest. Es ist eine höherstufige Abstraktion, die Concurrency ermöglicht, ohne viel von der Interaktion mit der darunterliegenden Runtime offenzulegen.
    Jede größere Software, an der ich gearbeitet habe, hat so etwas in irgendeiner Form implementiert. Selbst in Code ohne modernes C++-Coroutine-Konzept wurden Apple Grand Central Dispatch, Intel Threading Building Blocks usw. verwendet. Andernfalls blockiert die Geschäftslogik bei I/O sehr ineffizient, oder es gibt eine enorme Anzahl von Threads, sodass Entwicklung und Debugging zur Hölle werden, oder alles ist von Implementierungsdetails der darunterliegenden Runtime überzogen – oder eine Mischung aus allen dreien.
    Wenn man keine bestehende Abstraktion der Sprache selbst oder einer Bibliothek verwendet, baut man sie am Ende selbst, und das ist schwierig und wahrscheinlich insgesamt schlechter als etwas weit Verbreitetes. Ich habe so etwas früher auch einmal selbst für C++ gebaut: https://github.com/goto-opensource/asyncly

  • Der Autor scheint zwei Dinge zu verwechseln.
    Das eine sind User-Space-Threads/Green Threads, das andere ist strukturierte Nebenläufigkeit.
    Ersteres ist ein Vorteil von async/await, aber kein einzigartiger. Es gibt Beispiele wie Go oder Java Loom, bei denen das ohne das Problem der Funktionsfarben möglich ist.
    Letzteres lässt sich sowohl mit OS-Threads als auch mit Green Threads implementieren. Siehe Javas Structured-Concurrency-JEP.
    https://openjdk.org/jeps/462