- Rusts
async/awaitzielt 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
asyncRust müssen Daten entweder alsSendverschoben oder als Referenzen mit'staticbehandelt werden, und wegen der ansteckenden Natur vonasyncwiederholen sich diese Einschränkungen im gesamten Code Arckann zwar Compile-Probleme lösen, macht aber die Lifetimes von Objekten und Ressourcen unklar, und dazu kommen weitere Fallstricke wie rekursivesasync, 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/awaitasync fngibt keinen Wert direkt zurück, sondern ein Future oder Promise, dessen Ergebnis mit.awaiterhalten 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
Sendmarkiert verschoben oder über Referenzen mit der Lifetime'staticweitergegeben 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 asyncist ansteckend, also muss auch eine Funktion, die eineasync-Funktion aufruft, selbstasyncwerden- 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_onzur 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
Arcmacht 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
- Rekursive
- 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
asyncRust 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,Pinund'staticdurch 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
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 istunsafeverboten. 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.
asyncpasst 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 ohneRcundWeakzu 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.
asyncihre Probleme haben, überwiegen die Vorteile insgesamt deutlich.Ähnlich wie Linux’
lockdepkö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.Außerhalb des Datenzugriffs können weiterhin Race Conditions entstehen: https://news.ycombinator.com/item?id=23599598
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
asyncRust gibt es eine merkwürdige Stelle.Wenn man massenhaft
Arc,RwLockund 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, einfachArcdranzukleben 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
Arcverwendet und Lifetimes handhabt. Wenn man eineasync-Runtime implementiert, braucht man das wohl, aber ich verstehe nicht recht, warum durchschnittliche Library-Nutzer sich so stark darauf konzentrieren sollten.asyncbedeutet nicht automatisch Multithreading, und beiasyncinnerhalb 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/RwLockkapselt, sodass es für Aufrufer wie ein einfacher Funktionsaufruf aussieht.Die Sorge um
Send+Syncverstehe ich ebenfalls nicht ganz. Meiner Erfahrung nach ist das meiste problemlosSend+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.Aber diese Art zu programmieren unterscheidet sich stark von
asyncJavaScript, 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 demArc- undRwLock-Pfad.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.
asyncist faktisch ein deutlich schwierigeres Rust, und es ist schade, dass es praktisch allen aufgezwungen wurde, obwohl es vermutlich nur etwa 1 % der Projekte wirklich brauchenIn 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
asyncnö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 wirdDie Rust-Community setzt
asyncviel 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 mittiny_http,rouilleoderastradeutlich begrenzter istDurch die Wahl stackloser Koroutinen entstanden
async/awaitund das Problem der farbigen Funktionen, und damit die im Text beschriebene Reibung. Go hat dieses Problem nicht, weil es stackbehaftete Koroutinen verwendetRust 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
asyncRust, sondern Tokio, und Tokio übernimmt faktisch all die Aufgaben, die die vermiedene Runtime erledigt hätteDadurch bekommen viele Nutzer von
asyncRust die schlechten Seiten beider Welten. Im Embedded-Bereich wirdasyncRust teils mit einer sehr schlanken Runtime genutzt, aber das sind wenige, und selbst dort sind nicht alle völlig überzeugtreqwestherein, das wiederumh2hereinzieht, und das zieht wiedertokiohereinasynczu verwendenAus 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
asynchat sich in zu vielen Crates ausgebreitet, sodass am Ende das gesamte Programmasyncwird oder zumindest für vieles von Tokio abhängtWenn man einen Webserver will, wirkt es wie „
async + tokiooder verschwinde“, und bei SQL-Connectoren ist die Stimmung, dass man sie selbst schreiben muss, wenn man keine asynchrone Variante will. Alle lösen die vonasyncverursachten Probleme auf unterschiedliche Weise, und Dinge wieasyncClosures fühlen sich an, als würde man dem Compiler die Höllentore öffnenEs ist gut, dass Rust selbst und der Compiler bei der Problemlösung helfen, aber ein Ökosystem, das fast bei „
asyncoder bau es selbst“ landet, reicht nicht ausfutures-Crate bessere asynchrone Grundbausteine gegeben, hätte sich viel Schmerz vermeiden lassenEs 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-stddazunimmtIch bin kein Experte für
asyncRust, aber nachdem ich diesen Monat ein paar Tausend Zeilen synchrones Rust geschrieben habe, ist mein Eindruck: Wennrustceinen Ansatz schwierig macht, gibt es dafür meist einen guten Grund, und oft gibt es einen besseren Weg, ein ähnliches Ergebnis zu erreichenWenn 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.
asyncentwickelt 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
asyncsind 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 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
liburingoderepollin zwei Phasen, „submit“ und „handle“, aufteilt und an andere Komponenten sendet. Wenn man zum Beispiel einetcp-connectionerstellt, 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 perEPOLLOUT/io_uring_prep_writevsendenFü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} | state3Die Lebensdauer von
Arcist nicht unbekannt, sondern wird dadurch bestimmt, wo und wie es gehalten wirdDie 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.
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
async-Transformation Optimierungen verhindert, die der Compiler bei nicht asynchronem Code vornehmen kannDie Stelle, an der mit
Weakgerungen 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 seltenChannels 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
Notifyund SemaphoreMutexes 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_HORRORliegenDer 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_blockingvergleichsweise gut handhabenDer Autor weiß wahrscheinlich, was
Arcist und wie es funktioniert; der Punkt ist eher, dass man in Rustasyncdeutlich häufigerArcstatt gewöhnlichem RAII verwendet als in synchronem CodeWenn 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...
Arcist nicht zufällig, sondern statisch nicht bekanntArckann verschoben oder ausgeliehen werden und lässt sich auch verwenden, ohne den Referenzzähler anzufassenIn vielen Fällen ist es deutlich günstiger als Objekte in Sprachen mit implizitem Reference Counting
Ich mag Rust, aber
asyncist ein Durcheinander, und man kann asynchronen Code nicht so schreiben wie synchronen CodeIch 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 bereitstellenIch verdrahte gerade Logik so, dass in einer Struktur, die
Futureimplementiert, 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-Entwicklerasyncist 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 reichlichPinobendraufMan kann keine Fibers mit Gültigkeitsbereich ausführen, also hängt man am Ende überall
Arcdran;Pinist ohneunsafeschwer zu benutzen, und eine winzige Änderung an einer asynchronen Funktion kann die Futures der gesamten Codebasis zu!Sendmachenasync-Schwierigkeiten wohl lösen. Man muss sehen, wie sich das entwickeltAsync Everything ist ein schlechtes Sprachdesign
async/awaitwar 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=oNnITaBseYQGegenü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/awaithat 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/awaitaus 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
asyncRust größtenteils berechtigt.Der Artikel erklärt die Komplexität und Schwierigkeit von
asyncRust 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'staticsind, 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::forgetvergisst, kann der Borrow Checker nicht wissen, dass Referenzen, die dieser Future transitiv weitergereicht hat, immer noch leben.Statt überall
Arczu verstreuen, verwende ich solcheunsafe-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-
'staticFutures wird ebenfalls gearbeitet; ich hoffe, sie gelingt.Ein weiteres großes Problem ist, dass
async traitderzeit boxed Futures verlangt und damit an jeder Funktionsaufrufgrenze ein zusätzlichesmalloc/freeeinfü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
GOTOan; 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.'staticsind, sondern dass nur Futures, die'staticsind, perspawndie Nebenläufigkeit der Runtime nutzen können.Ein Future muss
Pinsein, umpoll()ed zu werden, und bei einem gepinntenT: !Unpinmuss am EndeDropaufgerufen werden: https://doc.rust-lang.org/std/pin/#drop-guaranteeVom
async-Feature des Compilers erzeugte Futures haben diese Eigenschaft, und in manuelle Futures kann man ebenfallsPhantomPinnedeinbauen. Dadurch kann man Tricks mitmem::forgetnach einempoll()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/Rcweiter 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.Rcentfernen oder stattdessen infektiöseunsafe-Trait-Grenzen einführen würde.