Warum async/await statt Threads wählen
(notgull.net)- Rusts
async/awaitist 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::spawnaus und ermöglichen so gleichzeitige Verarbeitung async/awaitgibt anawait-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
asyncdurch die Kombination vonraceundTimerhinzufügen; in synchronem Thread-Code braucht man dagegen einen speziellen Wrapper fürTcpStreamsowie Lese-/Schreib-Timeouts, was die Allgemeinverwendbarkeit einschränkt - Erklärt man
asyncnur über Performance-Overhead, entstehen bei CPU-bound Aufgaben Gegenbeispiele; die Stärke von Rustasyncliegt 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()
- Nach dem Ende einer Aufgabe wird die nächste ausgeführt, etwa
- 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 undhandle_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
- In einer Struktur, die mit
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
- Der Main-Thread nimmt weiter neue Verbindungen per
- 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/awaitentschieden - Vereinfacht gesagt wird das Programm zu einer Sammlung unabhängig ausführbarer Zustandsmaschinen kompiliert
- Eine
async fnist keine traditionelle Funktion, sondern eine Funktion, die eine Zustandsmaschine zurückgibt awaitbindet 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
- Eine
- Ein Executor wie
smol::Executorführt statt der aktuellen Zustandsmaschine andere, mitspawnerzeugte Zustandsmaschinen aus- Der Block
async move { handle_client(client).await }ist eine neue Zustandsmaschine, die vonmainunabhängig ist - Wenn
mainnachgibt, läuft eine der Client-Aufgaben; gibt diese wieder nach, rotiert die Ausführung zur nächsten Aufgabe
- Der Block
- 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
Iteratorerlaubt es, mehrere Kombinatoren anzuhängen und das Ergebnis erneut an Funktionen zu übergeben, die einenIteratorentgegennehmen- Wie bei
mpsc::channel()srecv.try_iter().filter(...).map(...)lassen sich Werte filtern, transformieren und einer Liste hinzufügen
async/awaitmacht diese Komponierbarkeit auch auf I/O-bound Funktionen anwendbar- Wenn
handle_client()eine asynchrone Funktion ist, dieread_to_end,do_something_with_dataundwrite_allperawaitaufruft, lässt sich ein Timeout von 3 Sekunden durch die Kombination zweier Futures implementieren - Dieser Ansatz ist nicht nur an
TcpStreamgebunden- Auf alles, was
impl AsyncRead + AsyncWriteimplementiert, lässt sich dasselbe Muster anwenden - Auch Ziele wie ein GZIP-Stream über einem gewöhnlichen Stream, Unix-Sockets oder Dateien sind austauschbar
- Auf alles, was
Einschränkungen bei derselben Timeout-Implementierung in synchronem Thread-Code
- In blockierendem Code ist es im Allgemeinen schwierig, Systemaufrufe wie
readoderwriteabzubrechen; Methoden wie das Schließen des File Descriptors sind in Rust nicht verwendbar TcpStreambietetset_read_timeoutundset_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
DeadlineStreamerstellen, derTcpStreamumhü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
TcpStreamgebunden - Rust hat kein Trait, das die Nutzung von
set_read_timeoutundset_write_timeoutabstrahiert - 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
- Er ist an
Beispiele aus dem Rust-async-Ökosystem
- Dass das HTTP-Ökosystem einschließlich Clients
async/awaitals 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
towerist ein repräsentatives Beispiel für die Komponierbarkeit vonasync/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
towerangewendet werden, um die Robustheit zu erhöhen
- Implementiert man Services als
macroquadist eine kleine Game Engine für Rust und verwendetasync/awaitin der Main-Funktion, um die Engine auszuführenasync/awaiteignet 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 manasyncstatt OS-Threads verwenden sollte, sinngemäß mit „weniger Overhead, ansonsten gleich“ zu antworten - Der Grund, warum Webserver-Autoren zu
async/awaitgewechselt sind, war die Lösung des C10k-Problems; aber Performance muss nicht für alle Nutzer der Grund sein,async/awaitzu 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
asyncwurde überbetont, während die semantischen Vorteile unterschätzt wurden
- Bei CPU-bound Aufgaben kann ein threadbasierter Workflow schneller sein als ein entsprechender
async/awaitist 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 Rustabgesehen von der gelegentlichen Nutzung der Schlüsselwörterasyncundawaitso 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/awaitzur 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
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-
Mutexund die Channels voncrossbeam-channelzu 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 esparking-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
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.
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?
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-...
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
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.
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
Blockierenden Code in einer async-Anwendung zu verwenden ist nicht so nahtlos, wie man hoffen würde, aber auch nicht schwierig. Statt
foo()verwendet mantokio::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.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.
„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.
Ein Codeblock verließ sich vielleicht auf exklusiven Zugriff, und weil es kein
awaitgab, war das garantiert; fügt man mitten darin einawaithinzu, 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.
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.
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
asyncverwenden. Wenn async/await so gut ist, warum macht man dann nicht einfach alle Funktionen implizit async und verwendet normale Funktionsaufrufe stattawait? Dann würde man im Grunde mit Threads programmierenOS-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.waitkann für blockierendes Warten im klassischen Sinn bleiben, etwa auf Events oder das Ende anderer Threads, aber nicht für FunktionsaufrufeZurück zum Webserver-Beispiel: Wenn ein Timeout mit
driver.race(timeout).awaitimplementiert wird, was passiert dann mit dem Client-Socket, nachdemraceden 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_raceverfolgt per Timer parallel zum Thread den Timeout und ruft, wenn die Zeit abläuft, im Java-Stilclient_thread.interrupt()auf.Thread.interrupt()setzt nur ein Flag, wenn der Thread nicht blockiert ist, und wirft eineInterruptedException, 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 inhandle_clientzu deklarieren, sodass der Programmierer nicht vergisst, den Client-Socket zu schließenrace()werdenDropped, unddriverselbst bleibt bestehenWenn man die Typen unverändert verwendet, wird Rust sich beschweren, dass das
Resultnicht behandelt wird; und wenn innerhalb des Futures ein neuer Socket lokal erstellt wurde, wird er aufgeräumtDas 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 mitsleep()abgeben; das ist schnell und leichter nachzuvollziehenAuch Javas
Thread.interruptist 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ünschenswertMeistens 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 sindAlle 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
poll()undselect()schreiben, aber das ist wieder ein eigener AnsatzEs 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/AsyncReadsehen überzeugend aus, aber wenn man sich auf *nix beschränkt, kann man dasselbe tatsächlich auch mit Threads und File Descriptors machenDiese Verallgemeinerung ist schlecht begründet und scheint auf bestimmte Workloads verzerrt zu sein
Wenn man sich überhaupt nicht für async interessiert und einfach etwas über Rust lesen möchte, ist das ziemlich unbefriedigend
poll(), würde ich gern wissen, um welchen Anwendungsfall es geht. Ich habe in meinem ganzen Leben noch nie so einen Fall gesehenDer Fehler war, es auch in die große Mehrheit der Anwendungsfälle zu drücken, die so etwas nicht brauchen
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 abbrechenWenn man zum Beispiel etwas, das
spawn_blockingverwendet, 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 weiterSo 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?“
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
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
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-uringnicht 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-Traitsio::Read/Write.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
awaitselbst. Dank expliziter Wartepunkte kann man die Interaktionen eines nebenläufigen Programms tatsächlich nachvollziehen.Ein yieldender Fiber ist ein bisschen wie das
gotoder 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.
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.
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!
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.
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.
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