Asynchronität ist nicht Gleichzeitigkeit
(kristoff.it)- Asynchronität und Gleichzeitigkeit sind Konzepte, die oft verwechselt werden, aber unterschiedliche Bedeutungen haben
- Asynchronität bezeichnet die Möglichkeit, dass Aufgaben unabhängig von einer Reihenfolge ausgeführt werden können
- Gleichzeitigkeit bezeichnet die Fähigkeit eines Systems, mehrere Aufgaben gleichzeitig voranzubringen
- Das Fehlen einer klaren Trennung der beiden Konzepte in Sprach- und Bibliotheksökosystemen führt zu Ineffizienz und Komplexität
- In der Programmiersprache Zig ermöglicht die Trennung von Asynchronität und Gleichzeitigkeit das Nebeneinander von synchronem und asynchronem Code ohne Codeduplizierung
Einleitung: Warum die Unterscheidung zwischen Asynchronität und Gleichzeitigkeit nötig ist
Durch Rob Pikes berühmten Vortrag ist der Satz „Gleichzeitigkeit ist nicht Parallelität“ weithin bekannt geworden, doch es gibt einen praktisch noch wichtigeren Punkt: die Notwendigkeit des Begriffs „Asynchronität“. Laut der Definition auf Wikipedia gilt:
- Gleichzeitigkeit: die Fähigkeit eines Systems, mehrere Aufgaben gleichzeitig per Zeitscheiben oder parallel zu verarbeiten
- Paralleles Rechnen: die tatsächliche gleichzeitige Ausführung mehrerer Aufgaben auf physischer Ebene
Daneben gibt es noch ein wichtiges Konzept, das wir oft übersehen: „Asynchronität“.
Beispiel 1: Zwei Dateien speichern
Wenn beim Speichern zweier Dateien (A, B) die Reihenfolge keine Rolle spielt,
io.async(saveFileA, .{io})
io.async(saveFileB, .{io})
- Es ist egal, ob zuerst A oder zuerst B gespeichert wird; auch ein abwechselndes Speichern dazwischen ist unproblematisch
- Sogar wenn Datei A vollständig gespeichert wird und erst danach B beginnt, ist der Code semantisch korrekt
Beispiel 2: Zwei Sockets (Server, Client)
Wenn innerhalb desselben Programms ein TCP-Server erstellt und ein Client verbunden werden soll,
io.async(Server.accept, .{server, io})
io.async(Client.connect, .{client, io})
- In diesem Fall müssen sich die beiden Aufgaben bei der Ausführung zwingend überlappen
- Das heißt: Während der Server Verbindungen annimmt, muss der Client ebenfalls versuchen, sich zu verbinden
- Würde man wie im ersten Dateibeispiel seriell arbeiten, entstünde nicht das beabsichtigte Verhalten
Begriffsordnung
Asynchronität, Gleichzeitigkeit und Parallelität werden hier wie folgt definiert:
- Asynchronität (asynchrony): die Eigenschaft, dass Aufgaben auch dann zu korrekten Ergebnissen führen, wenn sie außerhalb einer festen Reihenfolge ausgeführt werden
- Gleichzeitigkeit (concurrency): die Fähigkeit, mehrere Aufgaben gleichzeitig zu entfalten, sei es parallel oder zeitlich aufgeteilt
- Parallelität (parallelism): die Fähigkeit, mehrere Aufgaben physisch in Echtzeit gleichzeitig auszuführen
Die Beispiele Dateispeicherung und Socket-Verbindung sind beide asynchron, aber im zweiten Fall (Server–Client) ist Gleichzeitigkeit zwingend erforderlich
Der praktische Nutzen der Unterscheidung zwischen Asynchronität und Gleichzeitigkeit
Ohne diese Unterscheidung entstehen unter anderem folgende Probleme:
- Bibliotheksautorinnen und -autoren müssen asynchrone und synchrone Versionen ihres Codes doppelt schreiben (z. B.
redis-pyvs.asyncio-redis) - Für Nutzerinnen und Nutzer wird asynchroner Code „ansteckend“: Schon eine einzige Abhängigkeit von einer asynchronen Bibliothek kann dazu führen, dass das gesamte Projekt auf asynchron umgestellt werden muss
- Um das zu vermeiden, entstehen oft improvisierte Workarounds, die nicht selten Deadlocks und Ineffizienz verursachen
Daher bietet eine klare Trennung der beiden Konzepte sowohl für Bibliotheken als auch für ihre Nutzer große Vorteile
Zig: Trennung von Asynchronität und Gleichzeitigkeit
Die Sprache Zig verwendet io.async für Asynchronität, aber das garantiert keine Gleichzeitigkeit
- Das heißt: Auch wenn
io.asyncverwendet wird, kann die Ausführung intern weiterhin Single-Threaded und im Blocking-Modus erfolgen - Zum Beispiel kann
in einer Blocking-Umgebung genauso arbeiten wieio.async(saveFileA, .{io}) io.async(saveFileB, .{io})saveFileA(io) saveFileB(io) - Das bedeutet: Selbst wenn Bibliotheksautorinnen und -autoren
io.asyncverwenden, behalten Nutzer die Flexibilität, bei Bedarf sequenzielles Blocking-I/O auszuführen
Einführung von Gleichzeitigkeit und Mechanismen zum Task-Wechsel (Scheduling)
Wenn Gleichzeitigkeit erforderlich ist, braucht effektive Ausführung in der Praxis:
- ereignisbasiertes I/O ohne Blocking (z. B.
epoll,io_uring) - Primitive zum Task-Wechsel (z. B.
yield)
- Als Beispiel verwendet Zig in einer Green-Thread-Umgebung die Technik des Stack-Swapping, um Aufgabenwechsel durchzuführen
- Ähnlich wie beim Thread-Scheduling auf OS-Ebene werden dabei Zustände wie CPU-Register und Stack gespeichert und wiederhergestellt, um zwischen mehreren Tasks zu wechseln
- Erst mit einem solchen Wechselmechanismus kann asynchroner Code tatsächlich gleichzeitig geplant werden
- Auch stacklose Coroutine-Implementierungen (z. B.
suspend,resume) beruhen auf demselben Prinzip
Nebeneinander von synchronem und asynchronem Code
Wenn wie unten saveData zweimal mit io.async ausgeführt wird,
io.async(saveData, .{io, "a", "b"})
io.async(saveData, .{io, "c", "d"})
- Da die beiden Aufgaben zueinander asynchron sind, können sie selbst dann auf natürliche Weise in einem Gleichzeitigkeit-Kontext geplant werden, wenn die intern geschriebenen Funktionen synchron sind
- Weder Nutzer noch Bibliotheksautorinnen und -autoren haben Probleme, synchrone und asynchrone Funktionen ohne Codeduplizierung gemeinsam zu verwenden
Situationen kennzeichnen, in denen Gleichzeitigkeit „zwingend“ ist
Bei bestimmten Funktionen (z. B. accept eines TCP-Servers) muss im Code ausdrücklich dargestellt werden, dass zur Ausführung Gleichzeitigkeit erforderlich ist
- In Zig wird das etwa durch explizite Funktionen wie
io.asyncConcurrentgetrennt - Auf diese Weise wird ein Fehler ausgelöst, wenn die Ausführungsumgebung für die betreffende Aufgabe keine Gleichzeitigkeit unterstützt
- Anders als beim auf Asynchronität zielenden
io.asyncist hier die Garantie von Gleichzeitigkeit zwingend, weshalb es als failable Funktion implementiert wird
Fazit
- Asynchronität und Gleichzeitigkeit sind völlig unterschiedliche Konzepte und sollten klar voneinander getrennt werden
- Synchroner und asynchroner Code können nebeneinander bestehen
- Das Asynchronitäts-/Gleichzeitigkeitsmodell von Zig ermöglicht es, beide Welten ohne Codeduplizierung gemeinsam zu nutzen
- Eine solche Struktur wurde auch in anderen Sprachen wie Go angewendet und zeigt einen Weg, die „Ansteckung“ von async/await zu überwinden
- Mit Zigs neuem async-I/O-Design ist künftig eine noch intuitivere Umgebung für Gleichzeitigkeit und asynchrone Programmierung zu erwarten
1 Kommentare
Hacker-News-Kommentar
Die Definition von async wirkt erstaunlich schwer greifbar; ich selbst war einer von mehreren Leuten, die async in JavaScript entworfen haben, und ich stimme der in diesem Artikel vorgeschlagenen Definition nicht zu. Nur weil etwas async ist, funktioniert es nicht automatisch korrekt. Auch in async-Code kann es weiterhin viele Arten von Race Conditions auf Benutzerebene geben, egal ob die Sprache async/await unterstützt oder nicht. Meine jüngste Definition ist, dass async „explizit für Concurrency strukturierter Code“ ist. Auch diese Sicht muss wohl noch weiter geschärft werden. Ich habe dazu selbst etwas geschrieben: Quite a few words about async
Ich halte es für wichtig, zwischen dem abstrakten Konzept der Asynchronität und seiner konkreten Implementierung zu unterscheiden; Letztere umfasst sowohl sprachliche Abstraktionen als auch mechanische Koordinationsmittel. Auf der höchsten Abstraktionsebene ist Asynchronität einfach das Gegenteil von Synchronität. Wenn mehrere Akteure zusammenarbeiten müssen, etwa wenn eine Aufgabe beendet sein muss, bevor eine andere weiterlaufen kann, dann liegt der Kern von Asynchronität meist darin, dass unbekannt oder nicht festgelegt ist, wann das passiert. Diese Definition selbst ist nicht schwierig; das Problem ist die kognitive Last, wenn man solche Abstraktionen auf Sprachebene entwirft.
Ich bin in dem Thema nicht tief drin, aber für mich ist async-Code etwas, das ursprünglich blockierende Arbeit in nicht blockierende Arbeit verwandelt, damit andere Aufgaben gleichzeitig vorankommen können. Gerade im Embedded-Bereich ist diese Sicht für mich sehr naheliegend, weil lange blockierender Code in einer Loop I/O kaputtmachen und sicht- oder hörbare Fehler verursachen kann.
Ich frage mich sogar, ob async überhaupt definiert werden muss. Dass eine Definition so schwerfällt, liegt wohl daran, dass nichts perfekt auf ein einziges Konzept passt. Ich bezweifle auch, dass man async oder eine Event Loop überhaupt zwingend definieren muss. Im Bereich physischer Chips mit echter Parallelverarbeitung gibt es sicher unzählige Konzepte, die ich nicht kenne. Mir reichen „user finger“ (also Fingertipps usw.), „quickies“ (sehr kurz laufende Aufgaben), Job Queue sowie blockierende und nicht blockierende APIs. Um meine Ziele zu erreichen, sind nicht blockierende APIs gut, weil ich langlaufende Arbeit an ein Subsystem delegieren und selbst nur die „quickies“ wie das Speichern gewünschter Daten schreiben muss; dazu definiere ich dann verschiedene quickies für Erfolg und Fehler. Die Unterscheidung zwischen sync und async hilft mir dabei nicht besonders. Natürlich muss man die Begriffe verstehen, wenn andere darüber sprechen. Im Kern ist async für mich eine nicht blockierende API. Ein async-Programmiermodell ist letztlich eine Form, in der man kleine, atomare blockierende Aufgaben – gemessen an ihrer Laufzeit – entlang „chaotischer und nicht deterministischer“ Ereignisse schreibt. Was das System intern tut, ist mir egal; ich vertraue darauf, dass Browser, OS oder das Gerät selbst mehrere Ausführungseinheiten und einen guten Scheduler bereitstellen. Async ist für mich ein unscharf definierter Begriff, und selbst wenn man ihn definieren kann, ist fraglich, ob das nützlich ist. Viel praktischer sind Konzepte wie Events, die blockierende Natur der Aufgaben, die ich schreibe, Function Closures und die Frage, welche Dinge bei der Nutzung einer API in andere Jobs aufgeteilt werden. Schon der Begriff „callback“ war anfangs extrem verwirrend. Ich dachte, der Code würde dort anhalten, aber tatsächlich musste ich präzise verstehen, welcher Code nach dem vollständigen Ablauf dieses Teils beim Aufruf des „callback“ läuft und welche Informationen er sehen kann. Ehrlich gesagt ist das gleichzeitig chaotisch und genial. Viel einfacher als „async“ selbst ist das zugrunde liegende Modell: Events, blockierende Aufgaben, Job Queue und nicht blockierende APIs. Und es ist auch ziemlich wichtig zu verstehen, was ich tue und was Browser oder OS übernehmen. C++ deklariert zum Beispiel ein Concurrent-Modell, während das OS die tatsächliche Ausführung übernimmt. In JS deklariert man Browser oder Node mit nicht blockierenden APIs, dass es „vermutlich“ Concurrency gibt, und intern wird dann tatsächlich concurrent gearbeitet. Das Wichtigste ist, jede Aufgabe kurz zu halten (<50 ms) und die Absicht über nicht blockierende APIs mitzuteilen. C++ oder Rust sagen dem OS, dass echte Tasks concurrent laufen sollen, sodass die UI-Reaktionsfähigkeit erhalten bleibt, selbst wenn physisch nur ein Thread vorhanden ist. Am Ende muss ein async-Programmierer ein „gutes UX-Modell“ bauen und Events sauber auf quickies abbilden.
Es wirkt auf mich so, als hätte der Autor das Konzept des „yield“ aus der Definition von Concurrency herausgenommen und in den neuen Begriff „Asynchronität“ verschoben und dann behauptet, ohne dieses Konzept würde die ganze Concurrency zusammenbrechen. Meiner Ansicht nach ist die Fähigkeit zum yield bei Concurrency ohnehin essenziell und damit bereits inhärent enthalten. Es ist zwar ein wichtiges Konzept, aber es mit einem neuen Begriff abzuspalten, stiftet nur zusätzliche Verwirrung.
Ich denke, 1:1-Parallelität ist eine Form von Concurrency ohne yield. Ansonsten muss jede nicht parallele Form von Concurrency die Ausführung in irgendeinem Takt abgeben, selbst auf Instruktionsebene. In CUDA etwa können verzweigte Threads innerhalb desselben Warp ihre Instruktionen verschachtelt ausführen, sodass ein Zweig den anderen blockieren kann.
Ich möchte hervorheben, dass im zitierten Artikel ausdrücklich steht, „yield ist ein Konzept der Concurrency“.
Concurrency bedeutet nicht zwingend yield. Synchrone Logik braucht explizite Synchronisation, und yield ist nur ein Mittel zur Synchronisation. Mit asynchroner Logik meine ich Concurrency, die ohne Synchronisation oder yield funktioniert. Aus praktischer Sicht existieren weder Concurrency noch asynchrone Logik auf einer von-Neumann-Maschine in Reinform.
In diesem Kontext ist Asynchronität eine Abstraktion, die das Vorbereiten und Abschicken einer Anfrage von der Auswertung des Ergebnisses trennt. Dadurch wird es möglich, mehrere Anfragen einzureichen und ihre Resultate erst danach zu prüfen. Das erlaubt eine concurrente Implementierung, erzwingt sie aber nicht. Trotzdem ist das Ziel dieser Abstraktion, Concurrency zu gewinnen; ohne Concurrency gibt es auch den erhofften Nutzen nicht. Manche asynchronen Abstraktionen lassen sich ohne ein Mindestmaß an Concurrency gar nicht implementieren. Callback-Modelle kann man zum Beispiel auch auf einem Single-Thread nachahmen, aber das hat Grenzen, etwa Deadlocks, wenn man einen nicht rekursiven Mutex hält. Eine asynchrone Abstraktion ohne Concurrency muss also zwangsläufig scheitern. Wenn der Aufrufer eine Anfrage stellt, während er den Mutex hält, und der Callback vor dem Unlock ausgeführt wird, wird das Unlock womöglich nie erreicht. Es braucht mindestens einen separaten Thread, damit der Aufrufer bis zum Unlock kommen kann.
„Cooperative Multitasking ist nicht preemptive“: Der Begriff „async“ meint üblicherweise „Single-Thread, kooperatives Multitasking (explizites yield) und eventbasiert“, wobei externe Operationen concurrent ausgeführt und die Ergebnisse als Events gemeldet werden. In Multithreading- oder allgemein concurrenten Ausführungsmodellen hat async wenig eigene Bedeutung; selbst wenn der betreffende Thread blockiert, läuft das Programm weiter. Yield-Punkte müssen dann nicht unbedingt explizit sein.
Zigs neue IO-Idee wirkt für normale App-Entwicklung frisch und ist wohl ideal für Leute, die keine stackless Coroutines brauchen. Für das Schreiben von Bibliotheken dürfte sie aber fehleranfälliger sein. Bibliotheksautoren können schwer erkennen, ob das vorgegebene IO single-threaded oder multithreaded ist oder ob es eventbasiertes IO ist. Code rund um Concurrency, Async und Parallelität ist schon dann schwer zu schreiben, wenn man den gesamten IO-Stack vollständig kennt; wenn IO von außen hereingereicht wird, wird es noch schwieriger. Wenn die IO-Schnittstelle zu einem riesigen „kleinen OS“ anwächst, explodiert auch die Zahl der zu testenden Szenarien. Ich bin nicht sicher, ob man mit den von der Schnittstelle bereitgestellten Async-Primitiven wirklich alle Edge Cases sauber abdecken kann. Um verschiedene IO-Implementierungen zu unterstützen, müsste der Code sehr defensiv sein und immer das am stärksten parallele IO annehmen. Vor allem die Kombination dieses Ansatzes mit stackless Coroutines dürfte nicht einfach werden. Wenn man unnötiges Coroutine-Spawning vermeiden will, braucht man explizites Polling von Coroutines, und ich bezweifle, dass die meisten Entwickler so etwas selbst schreiben werden. Am Ende landet man vermutlich wieder bei einer Struktur, die normalem async/await-Code ähnelt. Mit dynamischem Dispatch und Zigs Tendenz zu Bottom-up-Design dürfte das insgesamt zu einer ziemlich High-Level-Sprache führen. Es gibt noch keine echten Praxiserfahrungen, daher ist die Bezeichnung „ohne Kompromisse“ im Moment ziemlich gewagt. Eine echte Bewertung ist wohl erst nach einigen Jahren Einsatz möglich.
Stackless Coroutines sollen ohnehin unterstützt werden. Sie werden für das WASM-Ziel gebraucht und kommen daher sicher. Dynamischer Dispatch wird nur verwendet, wenn es mehr als zwei IO-Implementierungen gibt; nutzt man nur eine, wird das durch Direct Calls ersetzt. Da das Ganze noch nicht in der Praxis validiert ist, halte ich „ohne Kompromisse“ ebenfalls für verfrüht. Ich habe gehört, dass die Sprache Jai ein ähnliches Modell erfolgreich verwendet – dort mit implizitem IO-Kontext statt expliziter Kontextübergabe –, aber auch das würde ich noch nicht als echten Praxiseinsatz bezeichnen.
Ich stimme zu, dass Code, der sowohl synchrone als auch asynchrone Ausführung unterstützen soll, immer vom maximal parallelen IO ausgehen muss. Wenn Asynchronität aber sauber in den Low-Level-IO-Event-Handlern umgesetzt ist, dann muss man überall sonst nur dieselben Prinzipien einhalten. Im schlimmsten Fall läuft der Code einfach sequentiell und damit langsamer; Race Conditions oder Deadlocks entstehen dadurch nicht.
Ich finde Zigs Idee sehr gut, weil man nicht zwei getrennte Bibliotheken verwenden muss. Sorgen macht mir aber immer das Testen von asynchronem Code. Woher soll man wissen, dass ein Test, der heute grün ist, wirklich alle später auftretenden Szenarien und Reihenfolgen abbildet? Bei Thread-Programmen ist es dasselbe Problem, nur ist Multithread-Code immer noch schwerer zu schreiben und zu debuggen. Ich vermeide Threads nach Möglichkeit. Das eigentliche Problem ist, Entwicklern async- und Thread-Umgebungen korrekt verständlich zu machen. Ich habe kürzlich mit einem Team gearbeitet, das in einem Python-System halb JS und halb Python eingesetzt und den Code großflächig auf async und threaded umgestellt hatte. Sie wussten aber nicht einmal, was der Global Interpreter Lock (GIL) ist. Meine Hinweise wirkten wohl nur wie Nörgelei. Schlimmer noch: Ihre Tests bestanden immer, selbst wenn man den Code faktisch kaputtmachte. Mangum erzwingt, dass Background- und Async-Arbeit beim Ende einer HTTP Request noch fertiggestellt wird, aber das wussten sie nicht. Und selbst wenn man so etwas erklärt, bleibt es vielen gleichgültig. Wichtig ist nicht nur, ob man etwas weiß, sondern ob andere darauf achten.
In Zig soll es eine Io-Testimplementierung geben. Damit will man dann auch Stresstests wie Fuzzing unter einem parallelen Ausführungsmodell ermöglichen. Der entscheidende Punkt ist aber, dass die meiste Bibliothekslogik wohl io.async oder io.asyncConcurrent gar nicht direkt aufrufen muss. Die meisten Datenbankbibliotheken können zum Beispiel vollständig als rein synchroner Code geschrieben werden. Der Applikationsentwickler kann diesen Code dann leicht asynchronisieren, etwa mit io.async(writeToDb) und io.async(doOtherThing). Das ist weniger fehleranfällig und viel leichter zu verstehen, als async/await über den ganzen Code zu verstreuen.
Stimme zu: In asynchronem oder multithreaded Code alle möglichen Interleavings zu testen, ist berüchtigt schwierig. Selbst mit Fuzzern oder Concurrency-Testframeworks ist es schwer, ohne die Lehren aus dem echten Betrieb wirklich sicher zu sein. In verteilten Systemen wird es noch schlimmer. Wenn man zum Beispiel eine Webhook-Infrastruktur entwirft, hat man es nicht nur mit async im eigenen Code zu tun, sondern zusätzlich mit Netzwerk-Retries, Timeouts und partiellen Fehlern. Unter hoher Concurrency werden Retries, Deduplication und Idempotency zu eigenständigen Engineering-Problemen. Deshalb braucht man dann mitunter spezialisierte Dienste wie Vartiq.com, die einen Teil dieser operativen Concurrent-Komplexität abstrahieren und den Blast Radius verringern können – ich arbeite dort. Das ändert aber nichts daran, dass die Testprobleme von async innerhalb meines eigenen Codes weiterbestehen. Unterm Strich verstärken async, Threading und verteilte Concurrency ihre Risiken gegenseitig, weshalb Kommunikation und Systemdesign wichtiger sind als jede Syntax oder Bibliothek.
Ich glaube, der Autor verwechselt in seiner Definition von Concurrency einige Dinge. Dazu lohnt sich ein Blick in Lamports Aufsatz.
Bitte nicht nur den Link posten, sondern auch erklären. Ich fand die Definitionen eigentlich in Ordnung. Zum Beispiel: Asynchronität bedeutet, dass Aufgaben korrekt bleiben, auch wenn sie nicht in Reihenfolge ausgeführt werden. Concurrency ist die Systemeigenschaft, mehrere Aufgaben gleichzeitig voranbringen zu können, sei es durch Parallelität oder Task Switching. Parallelität bedeutet, dass auf physischer Ebene tatsächlich zwei oder mehr Aufgaben gleichzeitig laufen.
Genau deshalb habe ich aufgehört, diese Begriffe überhaupt noch zu verwenden. Egal mit wem man spricht, jeder versteht etwas anderes darunter, sodass die Begriffe selbst für die Kommunikation kaum noch Bedeutung haben.
Der Autor wusste aus dem Blogpost selbst, dass es bereits bestehende Definitionen für den Begriff gab. Er hat bewusst eine neue Definition vorgeschlagen, und solange diese in sich konsistent ist, reicht das. Die Frage ist nur, ob Leser sie akzeptieren.
Die Hälfte von Lamports Aufsatz lässt sich in den meisten Sprachen konzeptionell gar nicht ausdrücken. Nur weil man Threads erzeugt, diskutiert man noch längst nicht über totale und partielle Ordnung. Solche Fragen braucht man eher bei der Protokollentwicklung mit TLA+. Dass eine Funktion in Zigs async-API nur in asynchronen Ausführungsumgebungen funktioniert und sonst einen Compilerfehler auslöst, muss man nicht gleich zu einer neuen Theorie erklären.
Eine gute Methode, um zu beurteilen, ob der Begriff „Asynchronität“ wirklich nötig ist, ist zu prüfen, ob er nicht nur in einer Sprache oder einem Modell, sondern in vielen verschiedenen Concurrent-Modellen nützlich ist. Wenn man ihn also gleichermaßen in Haskell, Erlang, OCaml, Scheme, Rust, Go usw. braucht, dann hat er Wert. Generell gilt: Kommt kooperatives Scheduling ins Spiel, muss man viel stärker darauf achten, dass das ganze System wegen fehlerhaftem Code nicht hängen bleibt oder Verzögerungen erzeugt. Bei präemptivem Scheduling verschwinden viele dieser Probleme; da ein kompletter System-Lockup unmöglich wird, schrumpft die Problemklasse erheblich.
„Asynchronität“ ist hier das falsche Wort; dafür gibt es bereits den mathematisch gut definierten Begriff „Kommutativität“. Manche Operationen sind reihenfolgeunabhängig, etwa Addition oder Multiplikation, andere nicht, etwa Subtraktion oder Division. In normalem Code wird die Reihenfolge von Operationen meist durch die Zeilennummern dargestellt, also von oben nach unten; in async-Code bricht diese Reihenfolge auf. Ein so geschriebenes asyncConcurrent(...) muss deshalb zwangsläufig verwirrend wirken. Wenn man den Blogpost nicht komplett verinnerlicht hat, ist kaum klar, was gemeint ist. In Zig – und auch in Rust, das ich ebenfalls mag – tauchen solche „hipsterhaften“ Ansätze öfter auf. Entweder man implementiert dieses prozedurale, async-basierte Kommutativitäts-/Ordnungsmodell ähnlich wie Lifetimes in Rust, oder man verwendet einfach Begriffe, mit denen die Leute bereits vertraut sind.
Ich stimme nicht zu, dass „asyncConcurrent(...) verwirrend ist“. Wenn man den Kern des Blogposts verinnerlicht hat, ist es überhaupt nicht verwirrend. Ob die Idee den Lernaufwand wert ist, ist eine andere Frage. Wenn genug Leute das wirklich praktisch anwenden, wird sich mit der Zeit zeigen, ob die Idee sich in der Praxis bewährt. Und wenn man „Kommutativität“ als Ersatzbegriff einführt, wird es in Zig eher noch verwirrender, weil es dort tatsächlich Operatoren gibt, die kommutativ sind. Bei f() + g() könnte man dann leicht auf die Idee kommen, Zig dürfe beides parallel ausführen, nur weil die Addition kommutativ ist. Ausführungsreihenfolge und Kommutativität sind aber völlig verschiedene Dinge und müssen getrennt bleiben.
Genau genommen ist Kommutativität eine Eigenschaft von (binären) Operationen. Wenn man sagt, zwei async-Anweisungen wie connect und accept seien vertauschbar, stellt sich die Frage: bezüglich welcher Operation? Der bind-Operator (>>=) oder etwas wie .then(...) kommt dem derzeit noch am nächsten, aber das ist bisher eher Intuition als saubere Theorie.
Asynchronität erlaubt auch partielle Ordnung. Selbst wenn zwei Operationen in derselben Reihenfolge „retired“ werden müssen, ist das noch etwas anderes als ihre tatsächliche Ausführungsreihenfolge. Subtraktion ist zum Beispiel nicht kommutativ, aber man kann die Abfrage des Kontostands und die Berechnung des abzubuchenden Betrags parallel ausführen und die Ergebnisse danach in der richtigen Reihenfolge anwenden.
Nur weil ein anderer Begriff dieses Konzept möglicherweise mit umfasst, ist er nicht automatisch besser als „Asynchronität“. „Kommutativität“ liest sich, klingt und schreibt sich deutlich sperriger. Asynchronität ist viel vertrauter.
Die Behauptung mit der Kommutativität hat noch eine weitere Grenze: Wenn A und B jeweils mit C kommutieren, dann gilt ABC = CAB, aber daraus folgt nicht notwendigerweise ACB. Bei Asynchronität müsste dagegen ABC = ACB = CAB gelten. Vielleicht gibt es dafür schon einen mathematischen Fachbegriff, aber ich kenne ihn nicht.
Als Netzwerkprogrammierer habe ich sehr viel mit Concurrency, Parallelität und asynchronem Code gearbeitet, und dieser Artikel wirkt auf mich etwas verworren – als würde er verzweifelt versuchen, auf einer lückenhaften Abstraktion eine Antwort zu finden. Wenn die Tools oder die Implementierung selbst falsch sind, ist genau das das Problem: Dann kann alles sehr leicht „kaputtgehen“. Ehrlich gesagt macht das Debuggen von Multithread-Code sogar ziemlich Spaß. Wenn ich sehe, wie sehr andere Leute Angst vor dem Multithreading-Monster haben, finde ich das eher amüsant.