2 Punkte von GN⁺ 2023-09-09 | 1 Kommentare | Auf WhatsApp teilen
  • Rusts async/await zielt auf groß angelegte Nebenläufigkeit mit Zehntausenden Verbindungen, kollidiert dabei aber mit Rusts Zielen von Low-Level-Kontrolle und statischer Lifetime-Prüfung und führt so zu einer anderen Entwicklererfahrung als normales Rust
  • Threads und Channels reichen für viele Arten von Software aus, aber bei Größenordnungen wie C10K wird das Modell „eine Verbindung pro Thread“ zu schwergewichtig, sodass User-Space-Tasks und Runtime-Scheduling nötig werden
  • In async Rust müssen Daten entweder als Send verschoben oder als Referenzen mit 'static behandelt werden, und wegen der ansteckenden Natur von async wiederholen sich diese Einschränkungen im gesamten Code
  • Arc kann zwar Compile-Probleme lösen, macht aber die Lifetimes von Objekten und Ressourcen unklar, und dazu kommen weitere Fallstricke wie rekursives async, der Unterschied zwischen Future und Task sowie blockierende Aufrufe, die Runtime-Threads anhalten
  • In Haskell oder Go verhält sich „async code“ wie normaler Code, und Runtime sowie GC verbergen die Unterschiede, sodass Rusts explizite Kontrolle bei dieser Art der Programmierung nicht nur als reiner Vorteil wirken muss

Warum Nebenläufigkeit und Parallelität nötig sind

  • Schnelle Programme haben gleichzeitig zwei Anforderungen
    • Sie müssen mehrere CPU-Kerne nutzen, um den gesamten Rechner auszulasten
    • Sie müssen während sie auf langsame Vorgänge wie Internet-Nachrichtenübertragung oder das Öffnen von Dateien warten, andere Arbeit fortsetzen
  • Parallelität ist das Problem, Code gleichzeitig auf mehreren CPUs auszuführen
  • Nebenläufigkeit ist eine Art, ein Problem in unabhängige Teile zu zerlegen
  • Beides ist nicht dasselbe, aber wenn ein Programm in nebenläufige Teile zerlegt wird, können diese Teile parallel laufen und die Kerne beschäftigt halten

Prozesse, Threads und Channels

  • Eine einfache Methode, ein nebenläufiges System zu bauen, ist die Aufteilung des Codes in mehrere Prozesse
    • Der Betriebssystem-Scheduler führt Zeitscheiben ausführbarer Prozesse auf verfügbaren CPU-Kernen aus
    • Auch beim Verbinden von Shell-Befehlen über Pipes wird dieses Modell verwendet
  • Der Prozess-Ansatz hat hohe Kosten für die Kommunikation zwischen Prozessen
    • In vielen Implementierungen müssen Daten in den Speicher des OS kopiert und anschließend wieder zurückgeholt werden
    • Mit Shared Memory lassen sich die Kosten senken, aber damit wird der Vorteil schwächer, dass das OS Prozesse voneinander isoliert
  • Threads teilen sich denselben Speicher und vermeiden so diesen Overhead, aber beim falschen Einsatz von Synchronisationswerkzeugen wie Mutexes, Condition Variables und Semaphoren können Data Races und Deadlocks entstehen
  • Tony Hoares Modell der Communicating Sequential Processes verbindet Threads über Queues oder Channels
    • Threads teilen keinen Speicher und erhalten dadurch eine Isolation ähnlich wie Prozesse
    • Ein- und Ausgaben jedes Threads werden über Channels sichtbar, was Schlussfolgern und Debugging erleichtert
    • Channels selbst übernehmen die Synchronisation: Ist ein Channel leer, wartet der Empfänger, ist er voll, wartet der Sender
  • Die Rust-Standardbibliothek enthält std::sync::mpsc::sync_channel
  • Für viele Arten von Software reicht eine Kombination aus Threads und Channels sowie Werkzeugen wie Rayon, das CPU-intensive Schleifen parallelisiert

User-Space-Nebenläufigkeit und Rust async

  • Bei C10K-Problemen wie Webservern mit Zehntausenden gleichzeitig verbundener Nutzer stößt das Modell, einen Thread pro Verbindung zu verwenden, an seine Grenzen
    • Unter Linux hat jeder Thread einen 4-kB-Control-Block, und ein Thread-Wechsel erfordert einen Context Switch in den Betriebssystem-Scheduler
  • Für groß angelegte Nebenläufigkeit erzeugen und verwalten manche Sprachen Tasks im User Space
    • Die Runtime plant diese Tasks auf einem Pool von OS-Threads ein
    • Üblicherweise wird der Pool so aufgebaut, dass pro CPU-Kern ein Thread vorgesehen ist, um die Parallelität zu maximieren
    • Dieser Ansatz wird auch als Green Thread, Lightweight Thread, Lightweight Process, Fiber oder Coroutine bezeichnet
  • Rust verwendet das aus C# oder Node.js bekannte Modell async/await
    • async fn gibt keinen Wert direkt zurück, sondern ein Future oder Promise, dessen Ergebnis mit .await erhalten wird
  • Rust-Futures sind dank kooperativem Scheduling und stacklosem Design sehr klein und schnell
  • Rust versucht, die Future-Abstraktion bereitzustellen und dem Programmierer zugleich Low-Level-Kontrolle zu versprechen
    • Es versucht, die Lifetimes aller Objekte und Referenzen statisch zur Compile-Zeit zu prüfen
    • Futures zerlegen Code und die von ihm referenzierten Daten in Tausende Fragmente, die je nach Bedingungen, die erst nach Start der Ausführung bekannt sind, jederzeit auf beliebigen Threads laufen können
    • Ein Future, das Client-Daten ausliest, sollte nur laufen, wenn auf dem zugehörigen Socket Daten zum Lesen bereitstehen, aber Lifetime-Annotationen geben diesen Zeitpunkt nicht an
  • Rust baut keine Future-Runtime in die Sprache ein, sondern überlässt sie Bibliotheken wie Tokio
    • Nutzer erhalten dadurch die Freiheit, Alternativen passend zu ihrer Umgebung zu wählen
    • Aber selbst wenn man sich eine Welt vorstellt, in der Tokio in die Sprache eingebaut wäre, würden dieselben Regeln gelten, daher ist das für dieses Argument nur ein nebensächliches Detail

Der Druck durch Send, 'static und Arc

  • Um den Compiler zufriedenzustellen, müssen Daten entweder als Send markiert verschoben oder über Referenzen mit der Lifetime 'static weitergegeben werden
  • In async-Code teilen mehrere Tasks häufig gemeinsamen Zustand, daher passt das Verschieben von Daten ohne Kopie oft nicht
  • Auch Referenzen sind schwierig, und es gibt kein Gegenstück zu thread::scope, das die Lifetime eines Futures auf weniger als „für immer“ begrenzen würde
  • async ist ansteckend, also muss auch eine Funktion, die eine async-Funktion aufruft, selbst async werden
    • Deshalb müssen diese Lifetime- und Mobilitätsprobleme nicht nur in einigen wenigen Funktionen, sondern fortlaufend gelöst werden
    • Man kann die Kette unterbrechen, indem man mit block_on zur Laufzeit auf die Fertigstellung eines Futures wartet, aber dieser Ansatz ist nicht gut kombinierbar, und bei Verschachtelung kann die Runtime mit Panic abbrechen
  • Arc ist ein Werkzeug, um dynamische Lifetimes über mehrere Threads hinweg zu handhaben, und hilft dabei, den Borrow Check zu bestehen und den Code zu kompilieren
  • Doch ein breiter Einsatz von Arc macht die Lifetimes von Objekten und Ressourcen unklar
    • Es ist nicht mehr eindeutig, wann Ressourcen wie Speicher, Dateien oder Sockets freigegeben werden
    • Man trägt ähnliche Nachteile wie bei GC, ohne deren reale Vorteile wie Allocation Throughput, geringe Fragmentierung oder das Vermeiden von Cycle Leaks zu bekommen

Weitere Fallstricke von async Rust

  • Rust-Coroutinen sind stacklos, daher verwandelt der Compiler jede Coroutine in eine Zustandsmaschine, die sie bis zu .await-Punkten voranbringt
    • Rekursive async-Funktionen werden dadurch zu rekursiv definierten Typen
    • Wer einfach nur sich selbst aufrufen will, muss manuell boxing einsetzen oder ein Crate wie async-recursion verwenden
  • Ein Future tut nichts, bis es awaited wird
  • Ein Task beginnt seine Arbeit im Thread-Pool der Runtime und gibt ein Future zurück, das die Fertigstellung signalisiert
  • Es gibt keinen Mechanismus, der blockierenden Code innerhalb eines Futures verhindert
    • Ebenso wenig wird verhindert, dass ein solcher Aufruf den Runtime-Thread blockiert, auf dem er läuft
    • Das steht im Widerspruch zum Kernziel des Einsatzes von async

Unterschiede zu normalem Rust, Haskell und Go

  • async Rust fühlt sich deutlich anders an als „normales“ Rust
    • Es gibt mehr Fallstricke
    • Es ist schwerer zu verstehen und zu lehren
  • Nutzer stehen vor zwei Optionen
    • Sie verstehen tiefgehend, wie die Abstraktion tatsächlich funktioniert, und schreiben komplexen Code
    • Oder sie streuen Elemente wie Arc, Pin und 'static durch den Code und hoffen, dass es gutgeht
  • Selbst Teams mit erfahrenen Entwicklern können bei neuen Projekten mit Rust an diesen Details hängenbleiben
  • In Haskell oder Go ist „async code“ normaler Code
    • Beide Sprachen verbergen hinter einer umfangreichen Runtime den Unterschied zwischen blockierendem und nicht blockierendem Code
    • Lifetime-Probleme werden an die Garbage Collection delegiert
  • Bei dieser Art groß angelegter nebenläufiger User-Space-Software wirkt es als klarer Vorteil, dass Runtime und GC die Unterschiede verbergen
  • Rust ist für groß angelegte nebenläufige User-Space-Software möglicherweise kein gutes Werkzeug, und könnte besser für Projekte geeignet sein, die solche Anforderungen nicht haben

1 Kommentare

 
GN⁺ 2023-09-09
Kommentare auf Hacker News
  • Ich schreibe einen leistungsfähigen Metaverse-Client in Rust, aktuell mit etwa 40.000 Zeilen Code.
    Ein Demo-Video gibt es unter https://video.hardlimit.com/w/tp9mLAQoHaFR32YAVKVDrz.
    Für ein richtiges Metaverse muss nutzergenerierter Content nahezu in Echtzeit verarbeitet werden. Dadurch braucht man zwei- bis dreimal so viel VRAM wie bei vergleichbaren Spielen, Hunderte Mbit/s Bandbreite, um Assets vom Server zu laden, mehrere CPUs sowie Vulkan, um Rendering und GPU-Uploads parallel auszuführen.
    Das ist nicht wie „Web-Scale“-Concurrency, bei der kleine Server getrennt im selben Adressraum laufen, sondern eine Struktur, in der ein Render-Thread mit hoher Priorität, ein Thread zur Aktualisierung von Netzwerk-Events, Threads zum Laden und Entpacken von Assets sowie mehrere Threads für bewegliche Objekte, LOD, Cache-Bereinigung usw. zusammenarbeiten.
    In Rust nutze ich, abgesehen von Konstanten, keinen globalen Zustand, setze ziemlich viele Locks ein, verwende Channels dort, wo sie passen, und der wichtigste Objektbaum wird mit Single Ownership hauptsächlich vom Update-Thread bearbeitet. Verbindungen zu Grafikobjekten werden über Arc-Referenzzählung verwaltet, und Meshes sowie Texturen werden über Rend3/WGPU/Vulkan auf die GPU geladen.
    Hätte ich das in C++ gemacht, würde ich ständig gegen Crashes kämpfen. In Rust habe ich speicherbezogene Crashes ungefähr einmal im Jahr, und selbst die stammen meistens aus fremdem unsafe-Code. In meinem eigenen Code ist unsafe verboten. Das Kompilieren ist zwar schwierig, aber wenn es einmal klappt, „funktioniert es einfach“ — was ich deutlich besser finde als Concurrency-Debugging.
    Es gibt auch Kritikpunkte. Rust ist stark gegen Data Races, verhindert aber Deadlocks nicht; dafür bräuchte es einen statischen Analysator, der die Lock-Reihenfolge entlang der Aufrufpfade verfolgt. async passt nicht gut zu rechenlastigen Aufgaben und mehreren Threads mit unterschiedlichen Prioritäten, sickert aber ständig über Abhängigkeiten ein. Häufige Strukturen mit Single Ownership und Rückreferenzen sind ohne Rc und Weak zu schwierig, und das Trait-System ist ebenfalls komplex, sodass in Teilen der Asset-Verarbeitung, wo Objektorientierung natürlicher wäre, duplizierter Code entsteht.
    Auch die zentralen Grafik-Crates sind noch nicht ausgereift. Dass „Rust fünf Spiele und 50 Game Engines hat“, ist kein Sprachproblem, sondern ein Ökosystemproblem; selbst im Vergleich zu https://gamedev.rs/ wirkt ernsthafte Spieleentwicklung mit Rust noch unterrepräsentiert. Für professionelle Spieleentwicklung mit festem Zeitplan ist das Rust-Game-Ökosystem meiner Ansicht nach noch nicht bereit; grob geschätzt müssten etwa fünf Leute noch ungefähr ein Jahr daran arbeiten.

    • Ich habe in den letzten drei Jahren einen Robotik-Simulator in Rust gebaut und fast die gleiche Erfahrung gemacht. In drei Jahren gab es etwa fünf echte Runtime-Bugs, und obwohl Rust und async ihre Probleme haben, überwiegen die Vorteile insgesamt deutlich.
    • Potenzielle Deadlocks durch Nachverfolgen der Lock-Reihenfolge zu finden, klingt nach einer guten Idee.
      Ähnlich wie Linux’ lockdep könnte man analysieren, welche Locks gehalten werden, während andere Locks genommen werden, und riskante Kombinationen melden, noch bevor es tatsächlich hängt. Bei komplexen Locks bräuchte man wohl Annotationen wie „diese Lock-Klasse wird immer in Adressreihenfolge genommen“, aber umsetzbar wirkt es.
    • Ich mache in einem MMO fast dasselbe in Java, und das JDK macht es einem sehr leicht. Man erzeugt Modelle aus dem Netzwerk heraus und verschiebt Objekte über eine nebenläufige Queue in den UI-Thread; das ist fast langweilig einfach und trotzdem schnell.
    • Rust ist nicht frei von Race Conditions, sondern frei von Data Races.
      Außerhalb des Datenzugriffs können weiterhin Race Conditions entstehen: https://news.ycombinator.com/item?id=23599598
    • Das Prioritätsproblem lässt sich relativ leicht lösen.
      Man kann mehrere Thread-Pools anlegen und Futures passend routen, oder eine eigene Event-Loop schreiben, die aus mehreren Event-Queues mit unterschiedlichen Prioritäten liest. Der zweite Ansatz kann, solange die Laufzeit einzelner Aufgaben begrenzt ist, auch bei 100 % CPU-Auslastung niedrig priorisierte Arbeit voranbringen und zugleich für hoch priorisierte Aufgaben weiche Echtzeitgarantien bieten.
  • Bei async Rust gibt es eine merkwürdige Stelle.
    Wenn man massenhaft Arc, RwLock und geteilten Zustand verwendet, wird es unsauber; insbesondere die Kritik, dass sich 'static, sobald es sich überall ausbreitet, wie farbige Funktionen auf alles überträgt, stimmt. Früher habe ich versucht, einfach Arc dranzukleben und Lifetimes clever zu leihen — das wurde ein Chaos.
    Aber Rust hat auch Channels. Der Code, den ich derzeit schreibe, besteht meist aus einigen Tasks, die Channels bedienen, eingehende Nachrichten ansehen und bei Bedarf Nachrichten für andere Tasks in die passenden Channels legen. Es werden keine Objekte geteilt. Wenn mehrere Tasks ein großes Objekt benötigen, lasse ich es in einem Task, der die entsprechenden Anfrageergebnisse als Nachrichten verschickt, oder jeder Task erstellt sich im Nachrichtenfluss seine eigene Kopie.
    Trotzdem gibt es sehr viele Texte darüber, wie man Arc verwendet und Lifetimes handhabt. Wenn man eine async-Runtime implementiert, braucht man das wohl, aber ich verstehe nicht recht, warum durchschnittliche Library-Nutzer sich so stark darauf konzentrieren sollten.

    • Die Kritik fühlt sich etwas seltsam an. async bedeutet nicht automatisch Multithreading, und bei async innerhalb desselben Threads gibt es kein Sharing; daher muss man auch nicht jedes geteilte Ziel mit magischen Keywords versehen.
      Wenn es zwischen Threads hin und her geht, sendet man eher Signale über Channels, statt massenhaft geteilten Zustand zu haben. Falls wirklich notwendiger globaler Zustand existiert, baut man eine kleine Struktur darum, die einen Mechanismus für exklusiven Zugriff wie Arc/RwLock kapselt, sodass es für Aufrufer wie ein einfacher Funktionsaufruf aussieht.
      Die Sorge um Send+Sync verstehe ich ebenfalls nicht ganz. Meiner Erfahrung nach ist das meiste problemlos Send+Sync, und die Dinge, die es nicht sind, sollten oder können es auch nicht sein. Manchmal möchte man Code schreiben, ohne über Details nachzudenken, aber wenn man effiziente Concurrency und Parallelität braucht, werden Mikrosekunden und Durchsatz wichtig — und dann muss man tatsächlichen Computercode richtig schreiben.
    • Das Paradigma der Nachrichtenweitergabe ist wirklich gut, und Sprachen wie Erlang haben gezeigt, dass sie für verteilte Systeme eine hervorragende Wahl sind.
      Aber diese Art zu programmieren unterscheidet sich stark von async JavaScript, das sich wie synchroner Code mit Green Threads anfühlt. Die Leute wollen Code in der ihnen vertrauten Weise schreiben und landen deshalb in Rust offenbar auf dem Arc- und RwLock-Pfad.
    • Smalltalk und der Traum echter Objektorientierung leben noch.
    • Diesen Rat habe ich an der Universität von einem Professor bekommen, und er hat mir wirklich sehr geholfen.
      Ein Problem als Daten zu strukturieren, die zwischen Tasks fließen, diese mit Queues zu verbinden und geteilten Zustand zu vermeiden, ist unabhängig von der Sprache ein besserer Weg, mit Multithreading umzugehen.
    • Wie ein kluger Programmierer sagte: „Kommuniziere nicht, indem du Speicher teilst; teile Speicher, indem du kommunizierst.“
  • async ist faktisch ein deutlich schwierigeres Rust, und es ist schade, dass es praktisch allen aufgezwungen wurde, obwohl es vermutlich nur etwa 1 % der Projekte wirklich brauchen
    In diesen 1 % ist es allerdings wirklich großartig. Für Dienste, die wie linkerd oder nginx massenhaft Netzwerkaufrufe als Kernaufgabe verarbeiten, für Spiele, in denen enorme Mengen leichtgewichtiger Tasks laufen, oder für Embedded-Fälle, in denen kooperative Nebenläufigkeit nötig ist, wird async Rust zu einer starken Waffe
    Der meiste Code auf System- und Anwendungsebene braucht keine asynchrone Ein-/Ausgabe. Für REST-Apps reicht ein Thread-Pool aus, und selbst wenn async nötig ist, ist meist ein Hybridmodell richtig, bei dem es auf kleine Teile wie Netzwerk beschränkt bleibt und der Rest über Threads und Channels verbunden wird
    Die Rust-Community setzt async viel zu wahllos überall ein, sodass blockierende Ein-/Ausgabe in Rust, die eine bessere User Experience hätte, im Ökosystem zu einem Bürger zweiter Klasse geworden ist. Bei Web-Frameworks gibt es mehrere gut entworfene asynchrone Frameworks wie Axum und Warp, während die Auswahl auf der blockierenden Seite mit tiny_http, rouille oder astra deutlich begrenzter ist

    • Der Kernpunkt ist, dass Rust Koroutinen falsch implementiert hat
      Durch die Wahl stackloser Koroutinen entstanden async/await und das Problem der farbigen Funktionen, und damit die im Text beschriebene Reibung. Go hat dieses Problem nicht, weil es stackbehaftete Koroutinen verwendet
      Rust hatte anfangs ebenfalls stackbehaftete Koroutinen in Betracht gezogen, ging dann aber zu einem stacklosen Modell über, weil ein präemptiver Koroutinen-Runtime nötig gewesen wäre und man die Kosten als hoch einschätzte. Die meisten verwenden aber kein runtimefreies async Rust, sondern Tokio, und Tokio übernimmt faktisch all die Aufgaben, die die vermiedene Runtime erledigt hätte
      Dadurch bekommen viele Nutzer von async Rust die schlechten Seiten beider Welten. Im Embedded-Bereich wird async Rust teils mit einer sehr schlanken Runtime genutzt, aber das sind wenige, und selbst dort sind nicht alle völlig überzeugt
    • Ich habe gesehen, dass Tokio schon wieder als Abhängigkeit in mein Programm hineingezogen wurde. Ich nutze es nicht einmal direkt, aber irgendeine Funktion eines Crates, die ich nicht verwende, zieht reqwest herein, das wiederum h2 hereinzieht, und das zieht wieder tokio herein
    • Wenn die Plattform virtuelle Threads unterstützt, frage ich mich, ob es noch einen Grund gibt, async zu verwenden
      Aus Sicht eines Java-Nutzers bin ich gerade dabei, das ganze asynchrone Paradigma zu verwerfen und Code auf ein blockierendes Modell mit virtuellen Threads umzuschreiben, bei dem Blocking in Ordnung ist
  • async hat sich in zu vielen Crates ausgebreitet, sodass am Ende das gesamte Programm async wird oder zumindest für vieles von Tokio abhängt
    Wenn man einen Webserver will, wirkt es wie „async + tokio oder verschwinde“, und bei SQL-Connectoren ist die Stimmung, dass man sie selbst schreiben muss, wenn man keine asynchrone Variante will. Alle lösen die von async verursachten Probleme auf unterschiedliche Weise, und Dinge wie async Closures fühlen sich an, als würde man dem Compiler die Höllentore öffnen
    Es ist gut, dass Rust selbst und der Compiler bei der Problemlösung helfen, aber ein Ökosystem, das fast bei „async oder bau es selbst“ landet, reicht nicht aus

    • Hätte es in der Standardbibliothek oder im futures-Crate bessere asynchrone Grundbausteine gegeben, hätte sich viel Schmerz vermeiden lassen
      Es bräuchte etwa Traits, die Executor implementieren müssen, oder einen einfachen blockierenden Executor, mit dem man asynchronen Code aus synchronem Code ausführen kann. Derzeit ist es schon eine Qual, eine Library zu bauen, die mehrere asynchrone Runtimes unterstützt, sodass man am Ende nur Tokio unterstützt oder höchstens noch async-std dazunimmt
  • Ich bin kein Experte für async Rust, aber nachdem ich diesen Monat ein paar Tausend Zeilen synchrones Rust geschrieben habe, ist mein Eindruck: Wenn rustc einen Ansatz schwierig macht, gibt es dafür meist einen guten Grund, und oft gibt es einen besseren Weg, ein ähnliches Ergebnis zu erreichen
    Wenn man die Sprache gerade lernt, würde ich empfehlen, sich zuerst an gewöhnlichen synchronen Code, Schleifen und Bedingungen sowie die Borrowing-Regeln zu gewöhnen. async entwickelt sich noch stark weiter, nicht nur bei der Implementierung, sondern auch auf der philosophischen Ebene: „Was ist Asynchronität, und wie sollte sie sich Nutzern zeigen?“
    Der Compiler stützt sich stark auf Traits, aber die Fähigkeiten von Traits im Umgang mit async sind noch nicht stabilisiert. Ein Beispiel ist Arbeit wie https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-i...
    Wenn asynchrone Fähigkeiten in Traits noch nicht stabilisiert sind, ist ein Angriff darauf, dass asynchroner Rust-Code noch nicht schön ist, letztlich so ähnlich, als würde man einen frühen Entwurf eines Buchs kritisieren, das erst noch fertiggestellt wird

    • Ich frage mich, was ein „gutes asynchrones API-Design“ ausmacht. Wie sollte ein Server aussehen, wenn man ihn vollständig asynchron ausrichtet und dabei skalierbar, wartbar und leicht verständlich halten will?
      Ich frage mich auch, wie man verhindert, dass sich Asynchronität über die gesamte Codebasis ausbreitet
      Meine aktuelle Idee ist eine Struktur, bei der ein I/O-Thread Systemereignisse von liburing oder epoll in zwei Phasen, „submit“ und „handle“, aufteilt und an andere Komponenten sendet. Wenn man zum Beispiel eine tcp-connection erstellt, könnte man asynchrone Events wie „bereit zum Schreiben“ oder „bereit zum Lesen“ abonnieren; das Schreibbereitschafts-Event würde Daten aus einem mit einem normalen Mutex gefüllten Buffer entnehmen und per EPOLLOUT/io_uring_prep_writev senden
      Für die Ereignisweitergabe zwischen Threads könnte man einen Multi-Producer-/Multi-Consumer-Ringpuffer nach dem LMAX-Disruptor-Pattern verwenden. Anwendungsthreads oder Thread-Pools hätten jeweils ihre eigene Event Loop und verarbeiten diesen Ringpuffer
      Ich arbeite auch an einer Syntax, um die Auslösereihenfolge asynchroner Events auszudrücken; sie sieht wie eine Bash-Pipeline aus und heißt statelines: initialstate1 initialstate2 = state1 | {state1a state1b state1c} {state2a state2b state2d} | state3
    • Wenn es nicht stabilisiert ist, sollte man es auch nicht in Production verwenden
    • Interessant sind die Kommentare, die davon ausgehen, dass der Autor ein Rust-Anfänger sei. Vielleicht hat er sogar mehr Erfahrung als sie
  • Die Lebensdauer von Arc ist nicht unbekannt, sondern wird dadurch bestimmt, wo und wie es gehalten wird
    Die Diskrepanz in diesem Artikel scheint daher zu kommen, dass der Autor nicht Rust lernt und seine Arbeitsweise an die Sprache anpasst, sondern versucht, ein früheres Denkmodell wie Garbage Collection Rust aufzuzwingen. Das ist eine häufige Falle beim Lernen einer neuen Sprache, aber bei Rust stolpert man besonders oft darüber.

    • In diesem Sinne hat auch die Lebensdauer von Objekten in Garbage-Collection-Systemen eine Untergrenze: „solange sie referenziert werden“
      Das ist jedoch fast das Gegenteil des Ziels des Borrow Checkers, Objektlebensdauern zur Compile-Zeit statisch zu begrenzen
      Tatsächlich war es eher das Gegenteil. Nachdem ich etwa zehn Jahre lang Systemprogrammierung in C, C++ und Rust gemacht hatte, nutze ich in meinem aktuellen Job viel Haskell, und es war ziemlich aufschlussreich zu sehen, dass große Sprachruntimes und Garbage Collection in manchen Problembereichen keine Monster sind
    • Ein großer Teil der Kritik fühlt sich so an. Ich hatte erwartet, dass es ein Text darüber wird, wie die async-Transformation Optimierungen verhindert, die der Compiler bei nicht asynchronem Code vornehmen kann
      Die Stelle, an der mit Weak gerungen wird, wirkt so, als solle eine komplexe Ownership-Struktur gebaut werden; das ist in Rust generell keine leichte Sache. Ich verwende schwache Smart Pointer nur sehr selten
      Channels werden kaum erwähnt, sind aber das wichtigste Werkzeug, um unterschiedliche Teile eines Programms miteinander kommunizieren zu lassen, wenn man asynchronen Code oder asynchronen und synchronen Code verbindet. Es gibt auch Signal-Abstraktionen wie Notify und Semaphore
      Mutexes sind langsam und werden leicht zum Bottleneck, und geteilter Zustand wird schnell komplex. Das ist seit Langem bekannt. Das Problem könnte von vornherein in einer Struktur wie BIG_GLOBAL_STATIC_REF_OR_SIMILAR_HORROR liegen
      Der Hinweis, dass nicht verhindert wird, Blocking-Code in einem asynchronen Kontext aufzurufen, ist berechtigt, lässt sich aber bei Bedarf mit Dingen wie tokio::spawn_blocking vergleichsweise gut handhaben
    • Reference Counting ist ebenfalls eine Form von Garbage Collection https://en.wikipedia.org/wiki/Garbage_collection_(computer_s...
      Der Autor weiß wahrscheinlich, was Arc ist und wie es funktioniert; der Punkt ist eher, dass man in Rust async deutlich häufiger Arc statt gewöhnlichem RAII verwendet als in synchronem Code
      Wenn 90 % der Programmobjekte per Reference Counting verwaltet werden, könnte es besser sein, eine Tracing-Garbage-Collection zu verwenden, statt die Kosten vieler kleiner Heap-Allokationen und -Freigaben sowie atomarer Operationen zu zahlen. Auch das Tokio-Tutorialbeispiel zeigt in eine ähnliche Richtung: https://tokio.rs/tokio/tutorial/shared-state
      Ich frage mich, ob eine echte Tracing-Garbage-Collection in Rust typische asynchrone Anwendungen wie HTTP-Server spürbar schneller machen könnte: https://manishearth.github.io/blog/2015/09/01/designing-a-gc...
    • Die Lebensdauer von Arc ist nicht zufällig, sondern statisch nicht bekannt
    • Rusts Arc kann verschoben oder ausgeliehen werden und lässt sich auch verwenden, ohne den Referenzzähler anzufassen
      In vielen Fällen ist es deutlich günstiger als Objekte in Sprachen mit implizitem Reference Counting
  • Ich mag Rust, aber async ist ein Durcheinander, und man kann asynchronen Code nicht so schreiben wie synchronen Code
    Ich bin zunehmend überzeugt, dass es eine schlechte Idee ist, beides zu mischen, und dass der Go-Ansatz richtig sein könnte: alles synchron lassen und nur ein primitives async-Channel-Element bereitstellen
    Ich verdrahte gerade Logik so, dass in einer Struktur, die Future implementiert, synchrone Methoden aufgerufen werden; das ist eine ziemlich interessante Herausforderung. Zero-Cost-Abstraktionen für Async lassen sich für Nutzer bis zu einem gewissen Grad einfach machen, aber den Schmerz tragen die Library-Entwickler

    • Dem letzten Punkt stimme ich nicht zu. async ist definitiv auch für Endnutzer schmerzhaft und fühlt sich an wie eine separate Sprache, der zentrale Rust-Funktionen wie Lifetimes und explizite Typen fehlen, mit reichlich Pin obendrauf
      Man kann keine Fibers mit Gültigkeitsbereich ausführen, also hängt man am Ende überall Arc dran; Pin ist ohne unsafe schwer zu benutzen, und eine winzige Änderung an einer asynchronen Funktion kann die Futures der gesamten Codebasis zu !Send machen
    • Library-Entwickler haben mehr Spielraum als Nutzer, um Komplexität zu bewältigen. Solche Arbeit erfahrenen Entwicklern zu überlassen, die die Basisinfrastruktur bauen, ist die richtige Richtung
    • Ich habe ein Beispiel für eine wasm-VM für Rust gesehen, die etwas bereitstellt, das wie transparentes M:N-Scheduling aussieht; mit so einem Ansatz ließen sich die meisten async-Schwierigkeiten wohl lösen. Man muss sehen, wie sich das entwickelt
  • Async Everything ist ein schlechtes Sprachdesign
    async/await war eine schreckliche Idee, mit der man das Problem beheben wollte, dass JavaScript keine brauchbaren blockierenden Threads hat, und wird nun an jede Sprache angeflanscht. Es wird Sprache und Library-Ökosystem in zwei Hälften spalten und noch lange Schmerzen verursachen.
    Wer außerhalb von JavaScript schon einmal Multithreading betrieben hat, weiß, dass Aktoren oder Communicating Sequential Processes der beste Ansatz für Multithreading sind.
    Auch Joe Armstrongs Dissertation erklärt, dass die einzige Möglichkeit, Multithread-Programme zu verstehen, darin besteht, pro Thread streng sequenziellen Code zu schreiben und den Code mehrerer Threads nicht an einer Stelle zu vermischen. Eine reale gleichzeitig ablaufende Aktivität des Problems sollte genau einem gleichzeitigen Prozess in der Programmiersprache entsprechen, damit die konzeptionelle Lücke minimal bleibt: https://erlang.org/download/armstrong_thesis_2003.pdf
    Gut ist auch Ron Presslers Kritik an async/await; er hat Javas Project Loom implementiert: https://www.youtube.com/watch?v=oNnITaBseYQ

    • JavaScript zu hassen macht Spaß, aber es ist interessant, sich Ryan Dahls erste Präsentation zu Node.js noch einmal anzusehen: https://www.youtube.com/watch?v=EeYvFl7li9E
      Gegenüber JavaScript selbst war er ziemlich ambivalent; sein Hauptziel war es, eine Abstraktion zu finden, mit der man eine epoll()-I/O-Event-Loop handhaben kann, ohne sich die Augen ausstechen zu wollen. Davor hatte er viele andere Ansätze ausprobiert.
    • async/await hat tatsächlich nicht in JavaScript begonnen, sondern in C#.
      Anders Hejlsberg von C# hat auch TypeScript entwickelt, und Funktionen wie Klassen, Arrow Functions und async/await aus TypeScript landeten schließlich in ES6+.
      Für JS/TS mit seiner Single-Thread-Event-Loop halte ich das für eine hervorragende Lösung. Je niedriger die Ebene einer Sprache ist, desto schlechter wird sie jedoch als Abstraktion; insofern ist die hier geäußerte Kritik an async Rust größtenteils berechtigt.
  • Der Artikel erklärt die Komplexität und Schwierigkeit von async Rust gut, aber wichtig ist auch, dass eine der Kernphilosophien von Rust Speichersicherheit ohne Leistungseinbußen ist.
    Rusts asynchrone Patterns, insbesondere die Art, wie sie den Compiler Datensicherheit garantieren lassen, zeigen diese Philosophie sehr gut. Trotz der Komplexität haben sie den Wert eines sichereren Nebenläufigkeitsmodells, das Entwickler dazu bringt, intensiv über Daten und Kontrollfluss nachzudenken.
    Rust ist vielleicht nicht die richtige Antwort für jede große nebenläufige User-Space-Anwendung, aber in Systemen, in denen Robustheit und Sicherheit oberste Priorität haben, können die Kompromisse gerechtfertigt sein. Mit der Weiterentwicklung des Ökosystems dürften auch mehr Abstraktionen und Libraries entstehen, die diese Schmerzen verringern.

  • Ich schreibe viel async-basiertes lock-freies Rust. Das Hauptproblem ist, dass Tokio-Futures 'static sind, und das stammt aus einem tief im Rust-Ökosystem verankerten Designfehler, nämlich der Entscheidung, dass Speicherlecks sicher sind.
    Dadurch lässt sich statisch nicht garantieren, dass ein Future ordnungsgemäß aufgeräumt wird. Wenn man eine asynchrone Aufgabe erzeugt und jemand den Future mit std::mem::forget vergisst, kann der Borrow Checker nicht wissen, dass Referenzen, die dieser Future transitiv weitergereicht hat, immer noch leben.
    Statt überall Arc zu verstreuen, verwende ich solche unsafe-Crates: https://docs.rs/async-scoped/latest/async_scoped/
    Das ist ein vernünftiger Kompromiss, weil es 99 % der Bugs abfängt, die ich in C++ gebaut hätte. An einer sicheren Implementierung von nicht-'static Futures wird ebenfalls gearbeitet; ich hoffe, sie gelingt.
    Ein weiteres großes Problem ist, dass async trait derzeit boxed Futures verlangt und damit an jeder Funktionsaufrufgrenze ein zusätzliches malloc/free einführt; das steht für dieses Jahr auf der Roadmap zur Behebung.
    Auch der Rat „benutz einfach Channels“ zerstreut in großen Codebasen den Kontrollfluss in alle Richtungen. Channels fühlen sich wie das moderne GOTO an; ich nutze sie zwar auch, aber eher nicht, wenn nur ein paar Dinge parallel ausgeführt werden sollen und man anschließend auf deren Abschluss wartet.

    • Die wichtige Unterscheidung ist, dass nicht Tokio-Futures selbst 'static sind, sondern dass nur Futures, die 'static sind, per spawn die Nebenläufigkeit der Runtime nutzen können.
      Ein Future muss Pin sein, um poll()ed zu werden, und bei einem gepinnten T: !Unpin muss am Ende Drop aufgerufen werden: https://doc.rust-lang.org/std/pin/#drop-guarantee
      Vom async-Feature des Compilers erzeugte Futures haben diese Eigenschaft, und in manuelle Futures kann man ebenfalls PhantomPinned einbauen. Dadurch kann man Tricks mit mem::forget nach einem poll() als undefiniertes Verhalten annehmen, und auch intrusive, selbstreferenzielle Future-Libraries werden möglich: https://docs.rs/futures-intrusive/latest/futures_intrusive/
      Ein Future kann durch Arc/Rc weiter am Leben bleiben und leaken, aber aus Sicht eines Library-Entwicklers lässt sich das entweder nicht sinnvoll von normaler Nutzung unterscheiden oder ist nicht besonders relevant.
    • Wenn man es als Designfehler betrachtet, dass Speicherlecks sicher sind, würde mich interessieren, ob man dann lieber innere Mutabilität abschaffen, Rc entfernen oder stattdessen infektiöse unsafe-Trait-Grenzen einführen würde.