1 Punkte von GN⁺ 2023-10-08 | 1 Kommentare | Auf WhatsApp teilen
  • 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 + 'static oder Send + Sync + 'static entstehen
  • 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 Send sein muss
    • Wenn der Systemzustand nicht klar überschaubar ist, ist es schwer zu entscheiden, wie sich Send garantieren 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_REUSEPORT auf 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_REUSEPORT bereits 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

 
GN⁺ 2023-10-08
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 + 'static kümmert, wird es weiter verwenden.

    • Wenn man also nicht wirklich C10M-Skalierung braucht, könnte man doch einfach einen Thread pro Verbindung verwenden und auf Komplizierteres verzichten, oder?
      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.
    • Ich finde die Debatte async/await versus stackful Coroutines interessanter, aber darum geht es in diesem Beitrag nicht.
      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_...
    • Es gibt auf der Welt mehrere Themen, bei denen Menschen nicht einer Meinung sind.
      Dieser Beitrag behandelt einfach eine andere Debatte als die, von der ich mir gewünscht hätte, dass er sie führt.
    • Man kann async/await zusammen mit Channels verwenden und sich dabei darauf beschränken, an async-Funktionen nur unveränderliche Referenzen oder Copy-Typen zu übergeben, während gemeinsam veränderbare Typen über Channels kommunizieren.
      Man kann auch einen Server im Erlang-Stil bauen, der veränderbare Typen besitzt und über Channels kommuniziert, oder man kann mit Arc weitermachen.
      Rust gibt einem die Möglichkeit, beides zu tun.
    • Soweit ich es verstehe, hindert einen nichts daran, Channels auf Rusts bestehender async-Unterstützung zu verwenden.
      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.

    • Der Hauptgrund, warum Rust-async-Frameworks Work Stealing verwenden, scheint zu sein, dass es sich auf Framework-Ebene leicht aktivieren lässt und die Performance vieler Anwendungen verbessert, insbesondere solcher, die nicht ideal entworfen sind.
      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.
    • Ich frage mich, was mit der Idee Data Push gemeint ist.
  • 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 Arc und Mutex vermeiden.
    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.

    • Ich habe solche Dienste geschrieben, hätte sie aber nicht I/O-lastig genannt.
      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.
    • Genau. Nicht alles, was Nebenläufigkeit braucht, ist ein Webserver.
      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.
    • tokio unterstützt einen Single-Thread-Executor, den man verwenden kann, wenn man ihn wirklich braucht, und das ist auch nicht schwer.
      In der tokio-API heißt das LocalSet.
      https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html...
    • Ich wollte gerade zu demselben Zitat etwas schreiben.
      Auch wenn ein einzelner Thread auf einer einzelnen CPU ausreicht, kann man trotzdem Nebenläufigkeit wollen.
    • Statt Arc und Mutex wird man wohl Rc und RefCell verwenden, 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 noch Send + 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 + Sync loszuwerden.
    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 bt machen 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.

    • Java bekommt viel Kritik ab, aber dieser Teil funktioniert seit etwa 25 Jahren einfach gut und war enorm nützlich bei der Fehlersuche.
      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/uniq nach Hotspots.
    • Um solche Probleme abzumildern, verwenden wir in der Firma eine Bibliothek, die regelmäßig loggt, welche Tasks laufen und in welcher Datei/Zeilennummer sich jede Task gerade befindet.
      Man muss vor jedem await-Punkt manuell r.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(), und task.rs enthält list_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

    • Ich habe kaum gesehen, dass async andere Teile von Rust beeinflusst
      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
    • Dem Zustrom von Webentwicklern stimme ich zu
      Es wäre schade, wenn dadurch die Nützlichkeit von Rust für Systemprogrammierung kaputtginge
    • Ich dachte eher, Web-Apps seien geradezu ideal für Thread-per-Core
      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 denke, das ist passiert, weil die Rust-Community solche Entwickler ziemlich aktiv angezogen hat, um Marktanteile und Bekanntheit zu gewinnen
      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 selbst
    Denn 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 haben
    Ein 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 Send zu tun

  • Guter 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 Send ist“ klingt für mich etwas arrogant
    Die Probleme mit 'static-Lebensdauern und Send/Sync-Constraints sind für Entwickler breit nachvollziehbar, und ich hatte nicht den Eindruck, dass sie dumm wären

    • Das verweist auf das, was zuvor gesagt wurde
      Leute 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
    • „schwierig“ steht in Anführungszeichen, weil es nicht zwingend schwieriger ist
      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
    • Ich persönlich habe definitiv auch manchmal Schwierigkeiten zu verstehen, ob mein Zustand Send ist oder nicht
      Deshalb 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 async selbst eine verfrühte Optimierung ist
    99 % 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]?

    • Ein Grund könnte sein, dass man Rust vermutlich gar nicht erst verwenden möchte, wenn Thread-per-Core-Performance ausreicht
      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 Send hinzufü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“.

    • Nehmen wir an, man schreibt ungefähr solchen Code:
      Es gibt einen Server, und in serve werden Nachrichten gelesen, dann wird jeder Message-Handler als neuer Task mit spawn(async move { ... }) gestartet.
      Anfangs funktioniert alles gut.
      Dann ändert man eines Tages die Implementierung von do_this, sodass der Typ von this nicht mehr Send ist, und bekommt bei spawn(...) einen hässlichen Compilerfehler, weil der von der anonymen async move { }-Scope erzeugte Typ nicht Send ist.
      Der Grund ist nicht unbedingt klar, und die Fehlermeldung hilft auch nicht.
      Wenn this nicht Send ist, darf es nicht über das .await von do_that(arg).await hinweg gehalten werden.
      Denn jedes .await ist ein Ausführungspunkt, an dem das Future nachgibt und vom Executor auf einen anderen Thread geplant werden kann.
      Wenn man den Typ Send machen 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_local umstellen.
      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 Send implementiert, kann es subtil in den Code einsickern und später auf undurchsichtige Weise kaputtgehen.
    • Im Großen und Ganzen erkennt der Compiler, ob etwas Send und Sync ist.
      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 Send und Sync verlangt 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 Send in manchen Situationen verbergen, aber nicht in allen.
      Und langfristig kann einen das architektonisch wieder einholen.
    • Der Code kompiliert nicht, wenn nicht überall dort, wo es nötig ist, alles Send/Sync ist.
      Ich kann mich irren, aber der bequeme Weg, das zu erreichen, ist normalerweise, Dinge, die geteilt werden können, in Arc oder Mutex zu verpacken.
    • Ich denke, das Argument gegen Thread-per-Core als Default lässt sich einfach formulieren.
      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.