Nebenläufigkeit entscheiden

Async oder Threads: Warten gegen Rechnen

Async lohnt sich, wenn dein Dienst überwiegend wartet, Threads lohnen sich, wenn er rechnet. Die Entscheidung bleibt nicht lokal, weil async sich durch jede Signatur darüber zieht.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Seit 1997 am Markt Kleine Gruppen Präsenz und Live-Online Zertifizierte Trainer
Worum es geht

Async wird als Voreinstellung gewählt und zieht danach durch das ganze Projekt

Der typische Ablauf: Ein neuer Dienst entsteht, jemand greift zu Tokio, weil jedes Beispiel es so macht. Das funktioniert, bis irgendwo weiter unten eine Funktion rechnet oder eine blockierende Bibliothek aufruft. Ab da ist der Arbeiter-Thread der Laufzeitumgebung besetzt, und fremde Anfragen, die auf demselben Arbeiter liegen, stehen still. Das Symptom ist verwirrend: Ausreißer bei den Antwortzeiten ohne erkennbare Ursache, und sie zeigen sich nur unter Last.

In der anderen Richtung passiert Vergleichbares. Jemand parallelisiert mit Threads und will sich eine gemeinsame Zuordnung teilen. Rust lässt das ohne Sperre oder Kanal nicht zu, was gut ist, und der Reflex ist dann, alles in eine einzige große Sperre zu packen. Danach steht auf dem Papier Parallelität und in der Messung eine Ausführung nacheinander, mit dem zusätzlichen Aufwand für die Sperre obendrauf.

Teuer ist die falsche Wahl nicht wegen einer Funktion, sondern wegen der Signaturen. Eine synchrone Funktion nachträglich asynchron zu machen bedeutet, dass jeder Aufrufer bis zum Einstiegspunkt ebenfalls asynchron wird, und Traits mit asynchronen Methoden bringen eigene Umstände mit. Deshalb gehört diese Entscheidung an den Anfang und nicht in den dritten Sprint.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Der direkte Vergleich

Drei Modelle und wofür sie gebaut sind

Die drei stehen nicht gegeneinander, sie decken verschiedene Aufgaben ab. Interessant ist deshalb weniger, welches gewinnt, als bei welchem Kriterium eines von ihnen ausfällt.

Async mit Tokio

viele Aufgaben auf wenigen Threads, die Laufzeitumgebung wechselt an jedem Wartepunkt die Aufgabe

Threads aus der Standardbibliothek

echte Betriebssystem-Threads mit eigenem Stapel, ohne zusätzliche Abhängigkeit im Projekt

Rayon

verteilt Rechenarbeit über Iteratoren auf alle Kerne, ohne dass du Threads selbst verwaltest

Wie viele gleichzeitige Verbindungen trägt das Modell?

Async mit Tokio

Zehntausende offene Verbindungen sind normal, weil eine wartende Aufgabe nur ihren Zustand hält und keinen eigenen Stapel belegt.

Threads aus der Standardbibliothek

Jeder Thread belegt einen eigenen Stapel und wird vom Betriebssystem verwaltet. Im vierstelligen Bereich wird das teuer, darüber unbrauchbar.

Rayon

Dafür ist es nicht gebaut. Rayon verteilt Rechenarbeit auf eine feste Zahl von Arbeitern und verwaltet keine Verbindungen.

Wie gut lastet das Modell mehrere Kerne mit Rechenarbeit aus?

Async mit Tokio

Eine Aufgabe gibt nur an einem Wartepunkt ab. Eine Rechenschleife ohne Wartepunkt belegt den Arbeiter-Thread durchgehend und blockiert alle Aufgaben, die dort einsortiert sind.

Threads aus der Standardbibliothek

Ein Thread pro Kern lastet die Maschine aus, und das Betriebssystem unterbricht und verteilt selbst, ohne dass dein Code kooperieren muss.

Rayon

Genau dafür gebaut. Aus dem gewöhnlichen Iterator wird ein paralleler, und die Arbeit verteilt sich über Arbeitsdiebstahl auf alle Kerne.

Wie stark greift die Entscheidung in bestehenden Code ein?

Async mit Tokio

Async färbt die Aufrufkette. Eine Funktion asynchron zu machen bedeutet, dass jeder Aufrufer bis zum Einstiegspunkt ebenfalls asynchron wird, und Traits bringen zusätzliche Umstände mit.

Threads aus der Standardbibliothek

Ein Thread lässt sich an genau einer Stelle starten, ohne dass sich irgendeine Signatur darüber ändert.

Rayon

Meistens reicht der Wechsel des Iterators in einer einzigen Zeile, der Rest der Funktion bleibt unverändert.

Was fängt der Compiler an Nebenläufigkeitsfehlern ab?

Async mit Tokio

Send und Sync werden geprüft. Ein Rc in einer abgegebenen Aufgabe oder eine geteilte Referenz auf ein RefCell über einen Wartepunkt hinweg ist ein Übersetzungsfehler und kein stiller Fehler unter Last.

Threads aus der Standardbibliothek

Dieselben Regeln. Geteilte veränderliche Daten ohne Sperre oder Kanal kommen gar nicht erst durch den Compiler.

Rayon

Dieselben Regeln, und zusätzlich sorgt die Iterator-Schnittstelle dafür, dass sich die bearbeiteten Teilstücke nicht überschneiden.

Wie leicht findest du den Fehler, wenn etwas hängt?

Async mit Tokio

Aufgaben stehen nicht im Aufrufverlauf des Betriebssystems. Ein hängender Wartepunkt zeigt sich als Stillstand ohne Anhaltspunkt, dafür brauchst du eigene Instrumentierung.

Threads aus der Standardbibliothek

Jeder Thread hat einen echten Stapel. Ein Debugger oder ein Abzug des Prozesszustands zeigt unmittelbar, wo er steht.

Rayon

Die Arbeiter sind echte Threads, die Aufteilung passiert aber in der Bibliothek, und ein Fehler in einem einzelnen Teilstück ist entsprechend schwerer zuzuordnen.

Wie viel Abhängigkeit holst du dir ins Projekt?

Async mit Tokio

Die Standardbibliothek liefert nur die Sprachmittel, nicht die Laufzeitumgebung. Tokio bringt ein eigenes Ökosystem mit, und fremde Bibliotheken legen sich darauf fest.

Threads aus der Standardbibliothek

Threads, Kanäle und Sperren sind Teil der Standardbibliothek, es kommt keine einzige Abhängigkeit dazu.

Rayon

Eine zusätzliche Abhängigkeit, allerdings eine eng umrissene, die sich nicht durch die Signaturen des restlichen Codes zieht.

Was passt wann

Wenn der Dienst viele Verbindungen hält und pro Anfrage fast nur auf Netz und Datenbank wartet
nimm async mit Tokio und leg gleich zu Beginn fest, wo blockierende Aufrufe hindürfen.
Wenn eine überschaubare Zahl von Aufträgen die CPU auslastet, etwa beim Umrechnen oder Auswerten
bleib bei Threads aus der Standardbibliothek und verteil die Rechenschleifen mit Rayon.
Wenn ein asynchroner Dienst an einer Stelle rechnen oder eine blockierende Bibliothek aufrufen muss
verlager genau diese Stelle auf blockierende Arbeit oder einen eigenen Thread-Pool und lass den Rest unverändert.

Fünf Fragen, und das Modell steht

  1. 01 Wartet eine Anfrage überwiegend auf Netz oder Datenbank? Dann spricht das für async.
  2. 02 Läuft irgendwo eine Schleife, die die CPU auslastet? Die gehört auf eigene Threads.
  3. 03 Ruft dein Code eine blockierende Bibliothek? Dann muss sie aus der Laufzeit heraus.
  4. 04 Meldet der Compiler ein fehlendes Send? Dann kreuzt ein Rc eine Aufgabengrenze.
  5. 05 Teilen mehrere Aufgaben Zustand? Dann prüf zuerst, ob ein Kanal dafür reicht.
Was du mitnimmst

Was du danach am Anfang eines Dienstes festlegst

Sechs Festlegungen reichen, und alle sechs lassen sich in der ersten Woche treffen. Danach ist geklärt, wo blockierende Aufrufe hindürfen, wie Aufgaben miteinander reden und was passiert, wenn eine Warteschlange schneller wächst, als sie geleert wird.

Warten und Rechnen im Dienst trennen

Sieh nach, welcher Anteil einer Anfrage aus Warten auf Netz und Datenbank besteht und welcher aus eigener Rechenarbeit. Wartet eine Anfrage fast ausschließlich, zahlt sich async aus. Rechnet sie spürbar, gehört diese Arbeit auf Threads, unabhängig davon, was der Rest des Dienstes macht.

Eine Laufzeitumgebung festlegen

Die Standardbibliothek liefert die Sprachmittel für async, aber keine Laufzeitumgebung. Du wählst eine Bibliothek, in der Praxis Tokio, und diese Wahl bleibt: Fremde Bibliotheken legen sich auf eine Laufzeitumgebung fest, und ein Wechsel betrifft dann nicht nur deinen Code.

Blockierendes ausdrücklich verlagern

Ein blockierender Aufruf innerhalb einer asynchronen Aufgabe gehört in den dafür vorgesehenen Bereich für blockierende Arbeit oder auf einen eigenen Thread-Pool. Das ist die einzelne Regel dieser Seite, die im Betrieb am meisten Ärger verhindert.

Rechenarbeit nicht in Aufgaben stecken

Schleifen, die die CPU auslasten, gehören auf Threads. Rayon verteilt sie über alle Kerne, indem aus einem gewöhnlichen Iterator ein paralleler wird, ohne dass du Threads selbst verwaltest oder Sperren setzt.

Send und Sync lesen können

Wenn der Compiler meldet, ein Wert dürfe eine Aufgabengrenze nicht überschreiten, steckt fast immer ein Rc oder ein RefCell dahinter, oder eine gehaltene Sperre über ein await hinweg. Die Antwort ist Arc mit passender Sperre oder ein Umbau, bei dem die Daten die Grenze gar nicht überqueren.

Kanäle statt geteiltem Zustand

Der Normalweg zwischen Aufgaben und zwischen Threads sind Nachrichten: Jede Seite besitzt ihre Daten, und der Besitz wandert mit der Nachricht. Nimm begrenzte Kanäle, damit ein langsamer Empfänger den Sender ausbremst, statt den Speicher volllaufen zu lassen.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt

Warum async in Rust anders funktioniert als anderswo

Eine asynchrone Funktion gibt eine Future zurück, und diese Future tut zunächst gar nichts. Erst wenn sie abgewartet oder an eine Laufzeitumgebung übergeben wird, läuft etwas. Es steckt kein Thread dahinter und in der Standardbibliothek auch kein Planer. Der Compiler baut die Funktion in einen Zustandsautomaten um, bei dem jeder Wartepunkt eine Stelle ist, an der der Zustand gesichert und der Arbeiter-Thread für etwas anderes frei wird.

Daraus folgt beides, der Vorteil und der Preis. Der Vorteil: Eine wartende Aufgabe hält nur ihren Zustand und keinen Stapel, deshalb sind zehntausende davon kein Problem. Der Preis: Eine Future lässt sich nur innerhalb eines anderen asynchronen Zusammenhangs abwarten. Wer eine einzelne Funktion tief unten asynchron macht, schiebt async bis zur Hauptfunktion hoch, und das ist eine Änderung an jeder Signatur dazwischen.

Die unangenehmste Ecke sind Traits mit asynchronen Methoden. Seit Rust 1.75 lassen sich async fn direkt in einem Trait schreiben, das deckt aber nicht alles ab: Sobald der Trait als dyn-Objekt gebraucht wird oder die zurückgegebene Future ausdrücklich Send sein muss, greift man weiterhin zu einem Makro, das die Future in eine Box legt. Für den Entwurf heißt das: Wenn eine Schnittstelle asynchron werden soll, entscheide das früh, nicht nachdem zehn Implementierungen dagegen geschrieben wurden.

Der teuerste Fehler ist Blockieren im falschen Zusammenhang

Blockierend heißt: Ein Aufruf kehrt nicht zurück, bevor das Betriebssystem fertig ist. Dazu gehören Dateizugriffe über die gewöhnlichen Funktionen der Standardbibliothek, ein synchroner Datenbanktreiber, ein Wartebefehl auf Thread-Ebene und jede Schleife, die komprimiert, verschlüsselt oder Prüfsummen bildet.

Was dabei passiert: Der Arbeiter-Thread, auf dem die Aufgabe läuft, kann keine andere Aufgabe bedienen. Bei einer mehrfädigen Laufzeitumgebung stehen damit die Aufgaben still, die auf diesem Arbeiter einsortiert sind. Bei geringer Last fällt das niemandem auf. Unter Last steigt die Antwortzeit am oberen Ende der Verteilung, während die Auslastung der Maschine harmlos aussieht, und genau diese Kombination ist das verlässlichste Erkennungsmerkmal.

Der Ausweg ist unspektakulär: kurze blockierende Aufrufe in den dafür vorgesehenen Bereich für blockierende Arbeit verlagern, dauerhafte Rechenarbeit auf einen eigenen Thread-Pool, den Wartebefehl der Laufzeitumgebung statt des Wartebefehls auf Thread-Ebene benutzen, und dort, wo es sie gibt, asynchrone Treiber verwenden. Zum Finden hilft Instrumentierung, die die Laufzeitumgebung selbst mitbringt und die sichtbar macht, welche Aufgabe wie lange am Stück gelaufen ist, ohne abzugeben.

Send und Sync: was der Compiler garantiert und was nicht

Send bedeutet, dass ein Wert in einen anderen Thread verschoben werden darf. Sync bedeutet, dass eine Referenz darauf zwischen Threads geteilt werden darf. Beides ergibt sich automatisch für Typen, die nur aus entsprechenden Bestandteilen aufgebaut sind. Rc erfüllt bewusst keines von beidem, weil sein Zähler ohne Synchronisierung geführt wird. RefCell darf verschoben werden, aber nicht geteilt: Es ist Send, aber nicht Sync, weil seine Ausleihprüfung nicht für nebenläufigen Zugriff ausgelegt ist.

Die Meldung, die dir dazu begegnet, betrifft meistens eine abgegebene Aufgabe, in der irgendetwas steckt, das die Bedingung nicht erfüllt. In neun von zehn Fällen ist es ein Rc oder ein RefCell. Der zehnte Fall verdient einen eigenen Satz: eine gehaltene Sperre über einen Wartepunkt hinweg. Die Aufgabe kann an dieser Stelle auf einen anderen Thread wandern, und die Wache einer gewöhnlichen Sperre darf das nicht. Die Lösung ist, den kritischen Abschnitt so zu kürzen, dass die Wache vor dem Wartepunkt endet, oder die asynchrone Variante der Sperre zu benutzen, die genau für diesen Fall gedacht ist.

Was ausdrücklich nicht ausgeschlossen ist: Verklemmungen durch zwei Sperren in unterschiedlicher Reihenfolge, endlos wachsende Warteschlangen, Logikfehler und blockierende Aufrufe an der falschen Stelle. Die Zusage lautet, dass es keine Datenrennen gibt, also keinen unsynchronisierten gleichzeitigen Zugriff. Sie lautet nicht, dass es keine Nebenläufigkeitsfehler gibt. Dieser Unterschied wird häufiger falsch wiedergegeben als jeder andere Punkt zu Rust.

Kanäle als Standardweg zwischen Aufgaben

Das Modell dahinter: Jede Aufgabe besitzt ihre eigenen Daten, die Verständigung läuft über Nachrichten, und mit der Nachricht wandert der Besitz. Damit fällt ein großer Teil der Gründe weg, überhaupt etwas zu teilen, und die Fragen nach Sperren stellen sich erst gar nicht. Die Standardbibliothek bringt Kanäle für Threads mit, die asynchrone Laufzeitumgebung eigene für Aufgaben, darunter auch Sonderformen für eine einmalige Antwort oder für den zuletzt gültigen Wert.

Die wichtigste Entscheidung dabei ist die Größe. Ein begrenzter Kanal erzeugt Gegendruck: Wenn der Empfänger nicht hinterherkommt, wartet der Sender. Ein unbegrenzter Kanal verwandelt einen langsamen Empfänger in wachsenden Speicherverbrauch, und das endet unter Last damit, dass das Betriebssystem den Prozess beendet. Die Voreinstellung sollte deshalb immer begrenzt sein, mit einer Größe, über die jemand nachgedacht hat.

Wo geteilter Zustand unvermeidbar ist, gelten zwei Regeln: eine Sperre pro Datenbestand statt einer Sperre für die ganze Anwendung, und der kritische Abschnitt so kurz wie möglich. Eine einzige große Sperre erzeugt Parallelität auf dem Papier und Ausführung nacheinander in der Messung, samt dem zusätzlichen Aufwand für das Sperren selbst.

Wann du von beidem nichts brauchst

Viele Aufgaben, die als Nebenläufigkeitsproblem daherkommen, sind in Wahrheit Stapelverarbeitung: eine Warteschlange, ein zeitgesteuerter Lauf, ein Arbeiter pro Prozess. Wenn die Arbeit sich in unabhängige Aufträge zerlegen lässt, ist die Vervielfachung von Prozessen oft die robustere Antwort als Nebenläufigkeit innerhalb eines Prozesses.

In einer Container-Umgebung kommt hinzu, dass die Ablaufsteuerung Neustarts, Verteilung und Skalierung ohnehin übernimmt. Ein Prozess, der einen Auftrag nach dem anderen abarbeitet und bei einem Problem sauber endet, ist einfacher zu betreiben und einfacher zu verstehen als ein Prozess, der intern zwanzig Aufgaben jongliert und dessen Zustand niemand von außen einsehen kann.

Der Punkt, an dem das kippt, ist die Zahl gleichzeitig gehaltener Verbindungen. Sobald ein Prozess tausende davon offen halten muss, weil sie den größten Teil der Zeit nur warten, führt kein Weg an async vorbei. Bis dahin lohnt sich die Frage, ob die einfachere Lösung nicht ausreicht.

Dazu passende Kurse

Weil diese Fehler sich erst unter Last zeigen, sind Rust-Kurse rund um Nebenläufigkeit der schnellere Weg als das Nachstellen im eigenen Projekt.

Wenn ein zweiter Prozess die einfachere Antwort ist als eine zweite Aufgabe im selben Prozess, führt der Weg über den Betrieb, und dazu gibt es Kurse zu Betrieb und Skalierung im Container .

Wissen prüfen

Wie sicher bist du beim Thema wirklich?

Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.

Sehr angenehmes, gut vorbereitetes Seminar, kann ich nur empfehlen.
Rust Fortgeschrittenenkurs

Häufige Fragen

Kann ich async und Threads mischen?
Ja, das ist der Normalfall. Die Laufzeitumgebung läuft selbst auf Threads. Bewusst gestalten musst du nur die Grenze: Blockierende Arbeit geht in den dafür vorgesehenen Bereich oder auf einen eigenen Pool, und das Ergebnis kommt über einen Kanal zurück. Was du vermeiden solltest, ist dieselbe Arbeit an zwei Stellen unterschiedlich zu behandeln, weil sich dann niemand mehr auf eine Regel berufen kann.
Braucht ein Kommandozeilenwerkzeug async?
Fast nie. Ein Werkzeug, das Dateien liest, umformt und schreibt, gewinnt nichts und bezahlt mit einer Abhängigkeit und gefärbten Signaturen. Der eine Fall, in dem es sich lohnt, ist ein Werkzeug, das viele Netzwerkanfragen gleichzeitig stellt und dabei überwiegend wartet.
Was ist der Unterschied zwischen einer Aufgabe und einem Thread?
Ein Thread ist ein Objekt des Betriebssystems mit eigenem Stapel, und der Kern entscheidet, wann er unterbrochen wird. Eine Aufgabe ist ein Zustandsautomat, den die Laufzeitumgebung auf einem Thread ausführt, und sie wechselt nur an einem Wartepunkt. Zwischen zwei Wartepunkten lässt sich eine Aufgabe von außen nicht unterbrechen, und genau daraus folgt die Regel, dass Rechenarbeit dort nicht hingehört.
Schließt Rust Verklemmungen aus?
Nein. Der Compiler schließt Datenrennen aus, also unsynchronisierten gleichzeitigen Zugriff. Zwei Sperren in unterschiedlicher Reihenfolge verklemmen in Rust genauso wie in jeder anderen Sprache, und dasselbe gilt für einen Kanal, dessen Empfänger verschwunden ist, oder für eine Aufgabe, die auf ein Ergebnis wartet, das nie kommt.

Passt thematisch dazu

Wer async aus JavaScript kennt, sollte sich vor Augen führen, was ein await in JavaScript wirklich anhält , denn dort läuft alles auf einem einzigen Ausführungsstrang und die Entscheidung zwischen nacheinander und gleichzeitig fällt schon beim Erzeugen des Promise.

Persönlich für dich da

Deine Ansprechpartner

Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.

Yves Hoppe

Yves Hoppe

Weiterbildung & Beratung

Hilft dir, aus dem Rust-Programm den passenden Kurs für deinen Stand zu finden.

Norbert Jansen

Norbert Jansen

Beratung & Inhouse

Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.

Nebenläufigkeit an einem echten Dienst durchspielen

Im Rust-Kurs zu Netzwerkdiensten bei cmt baust du genau diese Aufteilung auf und siehst, an welcher Stelle ein blockierender Aufruf die Antwortzeiten kippen lässt.