Thread pro Kern
(without.boats)- Die Debatte über Rust-Async-Runtimes dreht sich weniger um die Frage, ob es „ein OS-Thread pro Kern“ gibt, sondern eher um die Entscheidung, die Last mit Work-Stealing auszugleichen oder mit Share-Nothing Datenbewegungen zu verringern
- Async Rust erfüllt bereits die Kernbedingungen von Thread-per-Core – Concurrency im Userspace und asynchrones I/O –, die verbleibende Streitfrage ist also, wie sehr sich geteilte Zustände zwischen Kernen vermeiden lassen
- Work-Stealing soll die Leerlaufzeit von Threads verringern und so Tail Latency sowie CPU-Auslastung verbessern, in Rust muss dabei jedoch Zustand über Yield-Punkte hinweg thread-sicher sein, weshalb die
Send-Einschränkung entsteht - Share-Nothing kann Latenzen senken, indem Daten im Cache eines bestimmten Kerns verbleiben, doch der Schwierigkeitsgrad unterscheidet sich stark zwischen Fällen wie Key-Value-Stores mit leicht teilbarem Zustand und Fällen mit Transaktionen oder atomaren Änderungen
- Das Paper von Enberg vergleicht Share-Nothing und Shared State, verwendet auf beiden Seiten jedoch kein Work-Stealing, weshalb sich die Ergebnisse nur schwer direkt als Kritik an Rust-Executors mit Work-Stealing lesen lassen
Der Kern der Debatte über Rust-Async-Runtimes
- In der Rust-Community wird darüber gestritten, dass wichtige Async-Runtimes wie Tokio standardmäßig einen multithreaded Executor verwenden und mehrere Tasks per Work-Stealing dynamisch ausbalancieren
- Einige Nutzer kritisieren, dass dieser Standardwert die Programmerfahrung verschlechtert, weil dadurch Einschränkungen wie
Send + 'staticoderSend + Sync + 'staticentstehen - Manche bevorzugen Single-Thread-Server, hier wird jedoch von Situationen ausgegangen, in denen ein Rust-System die Zeit von mehr als einem CPU-Kern nutzen soll
- Als Alternative wird oft „Thread-per-Core“ genannt, mit der Erwartung, dass es schneller ist und sich leichter implementieren lässt, doch beides gleichzeitig zu bekommen ist schwierig
Die Verwirrung durch den Namen „Thread-per-Core“
- Auch bestehende multithreaded Async-Executors fallen im weiteren Sinne unter Thread-per-Core
- Sie erzeugen einen OS-Thread pro Kern
- und planen auf diesen Threads sehr viel mehr Tasks als es Kerne gibt
- Pekka Enberg beschreibt Thread-per-Core als Kombination aus drei Ideen
- Concurrency wird im Userspace statt mit teuren Kernel-Threads verarbeitet
- Es wird asynchrones I/O verwendet, damit kerngebundene Threads nicht blockieren
- Daten werden zwischen CPU-Kernen aufgeteilt, um Synchronisationskosten und Datenbewegungen zwischen CPU-Caches zu vermeiden
- Enberg sieht die ersten beiden Punkte als wichtig für Systeme mit hohem Durchsatz, während der dritte womöglich nur auf sehr großen Multicore-Maschinen notwendig ist
- Wer Async Rust verwendet, erfüllt die ersten beiden Bedingungen – Userspace-Concurrency und asynchrones I/O – bereits
- Im Zentrum der Debatte steht daher nicht Thread-per-Core an sich, sondern welche Optimierung gewählt wird: Work-Stealing oder Share-Nothing
Ziel und Kosten von Work-Stealing
- Work-Stealing ist eine Optimierung, die dafür sorgen soll, dass alle Threads ständig Arbeit haben, um die Tail Latency zu senken
- In realen Systemen unterscheidet sich der Arbeitsaufwand je Task
- Eine HTTP-Anfrage kann deutlich mehr Arbeit erfordern als eine andere
- Selbst wenn die Arbeit anfänglich gleichmäßig auf Threads verteilt wird, können unvorhersehbare Unterschiede zwischen Tasks im Lauf der Zeit zu ungleicher Last führen
- Unter Volllast kann es passieren, dass manche Threads mehr Arbeit bekommen, als sie verarbeiten können, während andere untätig bleiben
- Tokio, async-std und smol implementieren alle Work-Stealing, um Tail Latency zu senken und die CPU-Auslastung zu verbessern
- Der Preis dafür ist, dass ein Task auf einem Thread anhalten und auf einem anderen weiterlaufen kann
- Zustand, der über Yield-Punkte hinweg verwendet wird, muss thread-sicher sein
- In Rust-APIs zeigt sich das dadurch, dass ein Future
Sendsein muss - Wenn der Systemzustand nicht klar überschaubar ist, ist es schwer zu entscheiden, wie sich
Sendgarantieren lässt
- Wenn Zustand auf andere Threads verschoben wird, entstehen Synchronisationskosten und Cache Misses, was dem Share-Nothing-Prinzip widerspricht, nach dem jede CPU nur mit ihrem eigenen Zustand arbeitet
Die Performance-Logik von Share-Nothing
- Share-Nothing ist ein Design, das Tail Latency senken soll, indem Daten nicht in langsamen, von mehreren Kernen geteilten Caches, sondern im schnelleren Cache eines bestimmten CPU-Kerns verbleiben
- Enbergs Paper The Impact of Thread-Per-Core Architecture on Application Tail Latency benchmarkt einen Share-Nothing-Key-Value-Store und eine Shared-State-Variante von memcached und zeigt Verbesserungen bei der Tail Latency
- Die Art, wie dieses Paper in der Rust-Community vereinfacht als „71 % Performancegewinn“ zitiert wird, ist oberflächlich und wenig hilfreich
- Enbergs Key-Value-Store teilt für die Share-Nothing-Architektur Zustand und Verbindungen auf
- Er partitioniert den Keyspace per Hash-Funktion über mehrere Threads
- Er verteilt eingehende TCP-Verbindungen per
SO_REUSEPORTauf Threads - Er routet Anfragen vom Thread, der die Verbindung verwaltet, per Message-Passing-Kanälen an den Thread weiter, der den jeweiligen Keyspace verwaltet
- memcached teilt hingegen die Besitzverhältnisse am partitionierten Keyspace über alle Threads, wobei jede Partition durch einen Mutex geschützt wird
- Enbergs Ergebnisse zeigen, dass eine Architektur mit Kanälen statt Mutexen eine geringere Tail Latency erreichen kann
- Das lässt sich so verstehen, dass Cache Misses reduziert werden, weil dieselbe Partition wiederholt angesprochen wird und im Cache eines Kerns verbleibt
- Allerdings lässt sich schwer behaupten, dass ein Design, das mit fortgeschrittenen Kernel-Features und sorgfältig geplanter Struktur Datenbewegungen vermeidet, leichter zu implementieren sei als eines, das Daten einfach in einen Mutex packt
Wenn sich Zustand leicht oder schwer partitionieren lässt
- Ein Key-Value-Store ist ein gutes Beispiel für einen Fall, in dem sich der Anwendungszustand leicht auf mehrere Threads verteilen lässt und daher gut zu Share-Nothing passt
- In komplexeren Anwendungen, in denen Zustände über mehrere Partitionen hinweg transaktional oder atomar geändert werden müssen, ist für eine korrekte Implementierung deutlich mehr Sorgfalt nötig
- Das Eintreten für Share-Nothing erinnert in gewisser Weise an die frühere Überhitzung rund um Eventual-Consistency-Datenbanken
- Die Performance kann besser werden
- dafür ist aber sorgfältiges Design nötig, um Bugs durch inkonsistente Daten zu vermeiden
Der Abstand zwischen Enbergs Paper und der Rust-Debatte über Work-Stealing
- Weder Enbergs Implementierung noch memcached verwenden Work-Stealing
- Deshalb ist es schwierig, das zentrale Performance-Ergebnis des Papers direkt mit Rust-Architekturen auf Basis von Work-Stealing zu verknüpfen
- Würde man Enbergs Architektur Work-Stealing hinzufügen, könnte das zwar zusätzliche Datenbewegungen verursachen, es wäre aber womöglich auch eine Möglichkeit, die CPU-Auslastung zu erhöhen
- Im Fall von memcached fällt es schwer, sich vorzustellen, warum zusätzliches Work-Stealing nicht hilfreich sein sollte
- Die Implementierung im Paper ist darauf ausgelegt, Arbeit durch eine ausgewogene Partitionierung des Keyspace und durch
SO_REUSEPORTbereits im Voraus gleichmäßig zu verteilen - In realen Umgebungen kann jedoch dynamische Unwucht auftreten
- Hot Keys erhalten mehr Lese- und Schreibzugriffe und erhöhen so die Last des Threads, der diesen Keyspace verwaltet
- Einige Verbindungen führen deutlich mehr Anfragen aus als andere und erhöhen damit die Last des Threads, der diese Verbindung verwaltet
- Der Benchmark des Papers scheint diese Ungleichgewichte nicht nachzubilden, weil jede Verbindung bei zufälligen Schlüsseln eine konstante Arbeitsmenge ausführt
- Auch in Share-Nothing-Systemen lassen sich Designs denken, die solche Ungleichgewichte abmildern, etwa indem Hot Keys in zusätzlichen Partitionen gecacht werden
- Manche Tasks könnten auf bestimmten Kernen fest gebunden bleiben, um Zustandsbewegungen zu vermeiden, während andere Formen von Work-Stealing als Optimierung zusätzlich eingesetzt werden
Praktisches Fazit
- Wenn ein System sorgfältig so entworfen wird, dass Datenbewegungen zwischen CPU-Caches vermieden werden, kann es bessere Performance erzielen als ein System, das das nicht tut
- Wer sich jedoch vor allem darüber beschwert,
Send-Einschränkungen zu Generics hinzufügen zu müssen, betreibt wahrscheinlich keine derart feingranulare Performance-Optimierung - Und wenn ein System ohnehin Shared State verwendet, ist schwer zu bestreiten, dass Work-Stealing unter Last die CPU-Auslastung verbessern kann
1 Kommentare
Meinungen auf Hacker News
Persönlich habe ich bei diesem Beitrag das Gefühl, dass er das große Ganze aus den Augen verliert und sich in Details verbeißt.
Der Kern der Debatte ist nicht ein Work-Stealing-Executor mit einem Thread pro Core, sondern ob async/await in Rust eine gute Abstraktion dafür ist.
Je mehr async-Code ich verwende, desto stärker fühlt es sich so an, als lecke die Abstraktion und als sei es schwierig, dagegen zu programmieren.
Das alternative Nebenläufigkeitsmodell, das sich die Leute wünschen, ist strukturierte Nebenläufigkeit mit stackful Coroutines auf einem Work-Stealing-Executor und Kommunikation über Channels.
Bis jemand das implementiert und mit async/await sowie dem Future-basierten Modell vergleicht, ist eine produktive Diskussion schwierig; wer async nicht mag, wird es meiden, und wer sich nicht um
Send + Sync + 'statickümmert, wird es weiter verwenden.Solche Größenordnungen dürften in den meisten Fällen sehr selten nötig sein.
Solche Artikel sagen oft nur „Kernel-Threads sind teuer“ und gehen darüber hinweg, als wäre das grundsätzlich wahr, tatsächlich ist es das aber nicht.
Wenn die Arbeit nicht nur ständig No-op-Tasks erzeugt, ist der Overhead „echter Threads“ wahrscheinlich gering, während die dadurch gewonnene Einfachheit enorm ist.
Das Zitat, auf das withoutboats eingeht, stammt aus dem verlinkten Beitrag und kritisiert konkret standardmäßiges Multithreading und Work Stealing.
[1] https://www.reddit.com/r/rust/comments/16p47f1/the_state_of_...
Dieser Beitrag behandelt einfach eine andere Debatte als die, von der ich mir gewünscht hätte, dass er sie führt.
Man kann auch einen Server im Erlang-Stil bauen, der veränderbare Typen besitzt und über Channels kommuniziert, oder man kann mit
Arcweitermachen.Rust gibt einem die Möglichkeit, beides zu tun.
Stackful Coroutines scheinen mir an diesem Punkt nicht besonders sinnvoll, weil man dann einfach separate Threads verwenden kann.
Das ursprüngliche Problem, das das Thread-per-Core-Modell vor etwa 15 Jahren lösen sollte, war, auf Allzweck-Multicore-Servern Rechenskalierbarkeit und Effizienz zu erreichen.
Entgegen manchen Behauptungen war Thread-per-Core ausdrücklich darauf ausgelegt, CPU-zentrierte Workloads zu optimieren; später stellte sich zwar heraus, dass eine ausgefeiltere I/O-Verarbeitung nötig war, aber auch, dass es sich hervorragend für I/O-zentrierte Workloads mit hohem Durchsatz eignet.
Bei solchen Artikeln wirkt es, als würden sie schnell mehrere Softwaredesign-Fehler wiederholen, die schon bei der Einführung der Thread-per-Core-Architektur gemacht wurden.
Fairerweise muss man sagen, dass die Informatik rund um Thread-per-Core vor allem aus dem HPC-Bereich stammt und schlecht dokumentiert ist.
Dieser Beitrag konzentriert sich auf ein schwieriges Problem der Thread-per-Core-Architektur: den Lastausgleich zwischen Cores.
Das Grundmodell besteht aus vier Varianten: Daten/Last jeweils als Push/Pull, und Work Stealing ist im Kern ein Last-Pull-Modell (load pull).
Dieser Ansatz hat nur dann geringen Overhead, wenn er kaum benötigt wird, also wenn es ein natürliches Gleichgewicht gibt, was bei realen Problemen selten ist.
Bei interessanteren Workloads, bei denen dynamische Lastverschiebungen zwischen Cores häufig sind, wird Work Stealing wegen des Koordinations-Overheads zum Performance-Engpass.
Trotzdem ist es leicht zu verstehen und wird für passende Workloads weiterhin eingesetzt, lässt sich aber nicht gut verallgemeinern.
Bei einigen seltenen Workloads, die im Artikel nicht erwähnt werden, ist es vermutlich sogar die beste Lösung.
Das Modell, das heute am meisten an Bedeutung gewinnt, scheint Data Push zu sein; es ist weniger intuitiv, benötigt aber deutlich weniger Koordination zwischen Threads.
Auch dieses Modell passt nicht zu jedem Workload, lässt sich aber auf die meisten gängigen Workloads gut verallgemeinern.
Thread-per-Core-Architekturen werden bleiben. Bei Skalierbarkeit und Effizienz sind sie nicht zu schlagen.
Allerdings fehlt den meisten Softwareentwicklern die Intuition dafür, wie ein modernes, idiomatisches Thread-per-Core-Design aussieht, und das wird dadurch verschärft, dass es nur wenige Artikel oder Papers gibt, die dieses Thema gründlich behandeln.
Nach der Profilbeschreibung und dieser Erklärung zu urteilen, sind solche Anwendungen vermutlich nicht die Art, mit der Sie hauptsächlich zu tun haben.
Über Links zu relevanter Literatur würde ich mich freuen.
Der Aussage „I/O-lastig bedeutet, dass es in der Praxis, wenn es in Rust geschrieben ist, nicht genug Arbeit gibt, um einen einzelnen Kern auszulasten. Dann sollte man natürlich ein Single-Thread-System schreiben“ stimme ich zu.
Ein großer Teil der Anwendungen, die ich schreibe, sind Daemons, die im Hintergrund auf Events reagieren, und wenn man sie Single-Threaded baut, kann man den Overhead von
ArcundMutexvermeiden.Dieser Overhead ist zu diesem Zeitpunkt meist eher eine syntaktische Last, aber Debugging und Wartung werden einfacher.
Dass man nur für das bezahlt, was man wirklich braucht, ist einer der Punkte, die ich an Rust mag.
Der ursprüngliche Artikel, auf den dieser Beitrag reagiert, kritisiert, dass tokio und andere Async-Bibliotheken es schwer machen, zu einer einfachen Single-Thread-Architektur zurückzukehren.
Das ist zwar etwas übertrieben, aber im Großen und Ganzen stimme ich dieser Kritik zu.
Die Defaults komplexer zu machen, nur weil es für High-Throughput-Anwendungen besser ist, wirkt wie das Gegenteil von Rusts Ideal.
Sie sind nicht an den Durchsatz gebunden, sondern die meiste Zeit im Leerlauf und erledigen Arbeit, wenn sie anfällt, möglichst schnell, um die Nutzung von Systemressourcen zu reduzieren.
Solange es nicht gelegentlich zu riesigen Arbeitsspitzen kommt und die Latenz dann sehr wichtig ist, bringt mehr als ein Thread keinen Vorteil, sondern nur zusätzliche Komplexität und Overhead.
In Betriebssystemen müssen alle Systemdienste IPC-Anfragen gleichzeitig bearbeiten können, aber die meisten tun das Single-Threaded, um den gesamten CPU-Verbrauch zu senken.
Wenn man auf einem Gerät mit 4 Kernen Dutzende Dienste nach dem Thread-per-Core-Modell baut, verschwendet man CPU und RAM.
In der tokio-API heißt das
LocalSet.https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html...
Auch wenn ein einzelner Thread auf einer einzelnen CPU ausreicht, kann man trotzdem Nebenläufigkeit wollen.
ArcundMutexwird man wohlRcundRefCellverwenden, und vom Code her ist das ähnlich komplex und wortreich, denke ich.Ich verstehe, dass es weniger effizient ist, aber in dem beschriebenen Fall dürften die Zusatzkosten einiger atomarer Operationen ohnehin vernachlässigbar sein, oder?
Zu dem Zitat: Ich stimme zu, dass „Die Ursünde der Rust-Async-Programmierung war, den Default multithreaded zu machen …
Send + 'static, schlimmer nochSend + Sync + 'static, nimmt einem die Freude an Rust“ ziemlich melodramatisch klingt.Ich glaube nicht, dass es einen so großen Unterschied machen würde,
Send + Syncloszuwerden.Was am meisten stört, ist
'static, und das liegt nicht am Work-Stealing.Was ich will, ist Scoped Concurrency.
Zum Beispiel so etwas wie <https://github.com/tokio-rs/tokio/issues/2596>.
Was ich an Rust async derzeit ebenfalls wirklich nicht mag, ist der Mangel an Instrumentierung.
Bei uns in der Firma gibt es ein Produktionsproblem, bei dem einige Tasks einfach hängen bleiben, und ich möchte so etwas wie
gdb; thread apply all btmachen können.Zumindest hoffe ich, dass <https://github.com/tokio-rs/tokio/issues/5638> hineinkommt.
Es existiert derzeit schon experimentell, aber meiner Erfahrung nach führt es gelegentlich zu Panics.
Ich will heute tatsächlich einen PR schreiben, um die experimentelle Version bei SIGTERM zu verwenden.
Meine Einschätzung ist: Da wir ohnehin gerade herunterfahren, ist es egal, wenn es crasht.
Keines dieser Probleme wird dadurch gelöst, dass man Work-Stealing abschafft.
Auch wenn ich noch mehr aufzählen würde, würde das Entfernen von Work-Stealing fast nichts helfen.
Man braucht nicht einmal einen Debugger: Wenn man der JVM SIGQUIT schickt, dumpen alle Threads ihre Stacktraces nach stderr und laufen weiter.
Darin ist auch enthalten, welche Locks die einzelnen Threads halten oder auf welche sie warten.
Jedes Mal, wenn ich eine andere Sprache verwende, vermisse ich diese Funktion.
Man kann sie in Produktion auch für improvisiertes Profiling nutzen.
Man nimmt mehrere Snapshots und sucht dann mit
grep/sed/sort/uniqnach Hotspots.Man muss vor jedem
await-Punkt manuellr.set_location(file!(), line!());einstreuen, aber es hat uns schon mehrfach geholfen zu erklären, warum ein System scheinbar hängt.[1] https://github.com/antialize/tokio-tasks/blob/main/src/run_t... enthält
set_location(), undtask.rsenthältlist_tasks().Auf diese Frage gibt es keine richtige Antwort; es hängt vollständig vom Use Case ab
Letztlich geht es um I/O-lastige und CPU-lastige Workloads und darum, wie stark Dinge wie Cache-Verdrängung oder Lock-Contention negativ ins Gewicht fallen
Wenn man einen HTTP-Server, der mit einer externen Datenbank kommuniziert und dazwischen nur leichte Business-Logik hat, auf einem gemeinsam genutzten virtuellen Server betreibt, ergeben Work-Stealing und Thread-Wiederverwendung intuitiv Sinn
Natürlich sollte man immer benchmarken
Wenn man dagegen eine Datenbank oder ein ähnliches System baut und hohe Nebenläufigkeit sowie viele Kontextwechsel unter Last überall Cache-Verdrängung und Contention verursachen, bekommt man Probleme
Dann ist Thread-per-Core sehr plausibel, und ein async-Framework selbst kann unter Umständen sinnlos sein
Es gibt keine dogmatische Antwort darauf, was „besser“ ist
Man muss die Anwendung profilen
Wie schon gesagt, habe ich das Gefühl, dass der gesamte Fokus von Rust durch den massiven Zustrom in Richtung Webservice-Entwicklung verzerrt wird
Ich bin noch nicht sicher, ob Rust für solche Aufgaben die passende Sprache ist, aber für diese Leute scheint es ziemlich gut zu funktionieren, also sei es so
Allerdings spiegeln die öffentliche Diskussion über die Sprache und die Crates, die gerade in den Vordergrund gedrängt werden, insgesamt diese Verzerrung wider
Das ist auch die Verzerrung vieler Software Engineers in diesem Forum
Systemanwendungen wie Game Engines, Kryptografie-Bibliotheken, Kernel, Kommandozeilen-Tools und Compiler werden erfolgreich gebaut, ohne async anzufassen
Ich pflege eine große Kryptografie-Bibliothek und wurde von der async-Seite überhaupt nicht beeinflusst
Es wäre schade, wenn dadurch die Nützlichkeit von Rust für Systemprogrammierung kaputtginge
Die Anwendung selbst hat außerhalb von Requests kaum Zustand, und Socket-Listener sowie Datenbankverbindungen lassen sich pro Thread aufteilen
Der verbleibende Zustand ist zwischen Requests wahrscheinlich größtenteils statisch, sodass Cache-Invalidierungen nicht häufig auftreten
Da es keinen geteilten Zustand gibt, sollte auch Ownership leicht zu handhaben sein
Ich will nicht sagen, ob das gut oder schlecht ist, aber Rust muss nun mit einem endlosen Strom webbezogener Bibliotheken und Frameworks sehr unterschiedlicher Qualität leben
Und weil viele zentrale Basisbibliotheken und Crates einen async-first-Ansatz gewählt haben, wird async weiterhin ein Diskussionsthema bleiben
Inzwischen ist es so weit, dass es für normale Entwickler schwierig geworden ist, gewöhnlichen synchronen Code für Geschäftsprobleme zu schreiben, sofern sie nicht als zentrale Projektrichtlinie festlegen, async nicht zu verwenden
Dass eine
Send-Grenze verlangt wird, um das Verschieben von Tasks zwischen Executor-Threads zu erlauben, sehe ich als klaren Mangel des Rust-async-Systems selbstDenn zusammen mit dem grundlegenden Problem von async Drop verhindert es die Implementierung von APIs mit Scopes
Wie bei Threads sollte es ausreichen,
Send-Grenzen nur bei Funktionen wie spawn oder beim Senden von Daten über Channels zu habenEin Ansatz ohne Sharing ist meist kaum mehr als ein Workaround, um diesen Mangel zu verbergen
Tasks optional an einen bestimmten Thread/Core zu pinnen, hat Vorteile und ist in manchen Situationen wirklich nützlich, aber das ist eine feiner abgestufte Diskussion und hat wenig mit den Beschwerden von async-Nutzern über
Sendzu tunGuter Artikel, und ich empfehle, mehr als nur die Überschrift zu lesen
Mein Lieblingssatz war: „Wenn die größte Beschwerde darin besteht, irgendeinem Generic eine
Send-Grenze hinzuzufügen, fällt es mir schwer zu glauben, dass diese Person Engineering auf diesem Niveau betreibt“Edit: Ich stimme dem Kommentar von „duped“ vollständig zu
Da ich den größeren Kontext dieser Diskussion nicht kannte, habe ich diesen Satz möglicherweise zu vorschnell zitiert
Die Stelle „Menschen, die den Systemzustand nicht richtig sehen können, können Schwierigkeiten haben, den besten Weg zu finden, sicherzustellen, dass ein Future
Sendist“ klingt für mich etwas arrogantDie Probleme mit
'static-Lebensdauern undSend/Sync-Constraints sind für Entwickler breit nachvollziehbar, und ich hatte nicht den Eindruck, dass sie dumm wärenLeute sagen, dass es einfacher und schneller sei, kein Work-Stealing zu verwenden
Mein Argument ist, dass es eines von beidem ist
Damit ein nicht teilender Ansatz schneller ist, muss man den Code auf eine Weise entwerfen, die nicht einfacher ist, als eine Shared-State-Architektur threadsicher zu machen
Im nächsten Absatz gibt es einen parallelen Satz als Gegenstück zu „langsam“
Ich denke nicht, dass Menschen, die Schwierigkeiten haben, paralleles und nebenläufiges Rust zum Kompilieren zu bringen, dumm sind
Ich mag es nur nicht, wenn sie so tun, als hätten die APIs, die wir ihnen gegeben haben, ihr Leben ruiniert
Wenn man es ohnehin tun muss, ist es nicht schwieriger
Es geht weniger darum, dass „Leute auf triviale Aufgaben überreagieren“, sondern eher darum, dass async Probleme früher lösen lässt, die irgendwann ohnehin gelöst werden müssen
Es fühlt sich ähnlich an wie der Borrow Checker
Manchmal ist er übermäßig restriktiv, aber manchmal gab es beim Annehmen, alles sei in Ordnung, tatsächlich einen übersehenen Edge Case
Sendist oder nichtDeshalb spricht mich der zitierte Satz an
Dieser Artikel verliert das große Ganze aus dem Blick und hängt sich an Details auf
Es gibt keinen immer richtigen Weg, um in jedem Programm die beste Performance zu erzielen
Man kann endlos darüber diskutieren, aber die Vor- und Nachteile von Thread-per-Core sind der klassische Fall von „kommt darauf an“
Das Problem ist, dass schon die Verwendung von
asyncselbst eine verfrühte Optimierung ist99 % der Rust-Programme sind nicht redis oder linkerd
Es sind CLI-Tools oder Web-Apps, die selbst in Python oder Ruby schnell genug wären
Warum also, frage ich mich, hat die Community blockierendes I/O in Rust aufgegeben, warum ist alles async geworden, und warum versehen Entwickler scheinbar standardmäßig alles mit
#[tokio::main]?Es gibt Sprachen, die im Gegenzug für Geschwindigkeit eine angenehmere Programmiererfahrung bieten, und Python ist ein Beispiel dafür
Wenn man Rust verwenden will, braucht man wahrscheinlich zusätzliche Performance, und da man bereits eine weniger bequeme Sprache akzeptiert hat, kann man für bessere Performance auch einen weniger bequemen Stil akzeptieren
Ich habe Rust zwar nie benutzt, kann den Unmut aber nachvollziehen.
Wenn man Code auf besondere Weise schreiben muss, damit Zustand zwischen Threads verschoben werden kann, nur um Last umzuverteilen, obwohl das gar nicht nötig ist und reichlich CPU-Headroom vorhanden ist – und dadurch die End-to-End-Latenz einzelner Requests sogar schlechter werden kann –, wäre das wohl ärgerlich.
Auf einer Plattform, auf der verschiebbarer Zustand der Default ist und fast nie kaputtgeht, könnte dieser Ansatz sinnvoll sein, aber bei Rust scheint das nicht der Fall zu sein.
Was mich interessiert, ist das Nutzungserlebnis.
Ich frage mich, ob es eher „Wenn man nicht den Zauberspruch
Sendhinzufügt, kompiliert der Code nicht“ ist, oder eher „Beim Work Stealing wird der Zustand beschädigt und es scheitert sporadisch auf schwer zu debuggende Weise“.Es gibt einen
Server, und inservewerden Nachrichten gelesen, dann wird jeder Message-Handler als neuer Task mitspawn(async move { ... })gestartet.Anfangs funktioniert alles gut.
Dann ändert man eines Tages die Implementierung von
do_this, sodass der Typ vonthisnicht mehrSendist, und bekommt beispawn(...)einen hässlichen Compilerfehler, weil der von der anonymenasync move { }-Scope erzeugte Typ nichtSendist.Der Grund ist nicht unbedingt klar, und die Fehlermeldung hilft auch nicht.
Wenn
thisnichtSendist, darf es nicht über das.awaitvondo_that(arg).awaithinweg gehalten werden.Denn jedes
.awaitist ein Ausführungspunkt, an dem das Future nachgibt und vom Executor auf einen anderen Thread geplant werden kann.Wenn man den Typ
Sendmachen kann, ist alles in Ordnung.Es gibt aber genug Fälle, in denen das nicht geht; dann muss man das Scheduling des Future auf etwas wie
spawn_localumstellen.Um das aufzurufen, muss man unter Umständen ziemlich viel Boilerplate hinzufügen.
Das ist das Problem mit
Send.Es geht nicht einfach darum, eine Typannotation hinzuzufügen; weil nicht immer offensichtlich ist, ob ein Typ
Sendimplementiert, kann es subtil in den Code einsickern und später auf undurchsichtige Weise kaputtgehen.SendundSyncist.Wenn man das Programm thread-sicher schreibt, gibt es kein Problem.
Der Kernpunkt ist dieser:
Leute beschweren sich, dass Tokio async schwierig sei, weil überall
SendundSyncverlangt werden, aber tatsächlich ist es an sich schwierig, nebenläufigen Code jeglicher Art sicher zu schreiben.Es ist nicht intuitiv, und das Problem ist, dass async den Eindruck erweckt, es würde das automatisch „erledigen“.
In Wirklichkeit ist das aber nicht so.
Man muss wissen, was man tut, und der Compiler hilft dabei nur.
Mit Thread-per-Core kann man die Notwendigkeit von
Sendin manchen Situationen verbergen, aber nicht in allen.Und langfristig kann einen das architektonisch wieder einholen.
Send/Syncist.Ich kann mich irren, aber der bequeme Weg, das zu erreichen, ist normalerweise, Dinge, die geteilt werden können, in
ArcoderMutexzu verpacken.Wenn es CPU-lastig ist, ist Work Stealing in den meisten Fällen besser.
Wenn es I/O-lastig ist, kann Thread-per-Core besser funktionieren, aber dann gibt es ohnehin genug CPU-Headroom, sodass Performance keine große Rolle spielt.
Meiner Ansicht nach ist Work Stealing der bessere Default für eine Sprach-API.