async ist kein Thread
Kurz gesagt: async und await erzeugen keinen Thread: await gibt den Thread beim Warten frei, und der Code danach läuft im Thread-Pool weiter, deshalb ist ein Wettlauf zwischen zwei Wartezeiten möglich.
Problem
Der letzte Artikel, zweimal verkauft
Der Sale läuft auf Hochtouren. Ein Paar Kopfhörer ist übrig. checkout prüft den Bestand, wartet, bis die Bank die Zahlung bestätigt, und verringert den Bestand erst dann. Zwei Kunden drücken im selben Moment auf „Kaufen“:
int stock = 1;int sold = 0;
async Task Checkout(string buyer){ int left = stock; if (left == 0) return; await Pay(buyer); stock = left - 1; Interlocked.Increment(ref sold);}
await Task.WhenAll(Checkout("Anna"), Checkout("Ben"));Console.WriteLine($"Verkauft: {sold}, Bestand: {stock}");
// Die Bank bestätigt die Zahlung in 100 ms.async Task Pay(string buyer){ await Task.Delay(100);}Verkauft: 2, Bestand: 0Zwei sind verkauft, und es gibt einen Artikel. Ein Kunde bekommt eine E-Mail „Deine Bestellung wurde versandt“ für Kopfhörer, die es nicht gibt.
Hier gibt es kein Thread und kein Parallel. In der Lektion Race Condition sind zwei Threads bei count++ zusammengestoßen. Hier sind die beiden Bestellungen nicht einmal gleichzeitig im Prozessor: Beide warten nur auf die Bank.
Die Ausgabe ist bei jedem Lauf gleich: Das ist kein Pech, sondern die Reihenfolge, in der der Code läuft. Um sie zu sehen, müssen wir wissen, was await tut.
Die Idee
Ein Thread und viele Wartezeiten
Der größte Teil der Arbeit in einem Onlineshop ist Arbeit, die wartet (siehe Arbeit, die wartet, und Arbeit, die rechnet): Die Zahlung wartet auf die Bank, die E-Mail auf den Mailserver, der Bestand auf das Lager. Ein Thread, der dasitzt und wartet, ist verschwendet.
Ein Event Loop ist ein Thread und eine Warteschlange von Codestücken, die bereit sind. Der Thread nimmt das nächste Stück und führt es bis zum Ende aus. Kommt der Code an eine Wartestelle, hält er den Thread nicht fest: Er sagt „weck mich, wenn die Bank antwortet“ und lässt los. Der Thread nimmt das nächste bereite Stück. Antwortet die Bank, kommt der Rest der Funktion (die Fortsetzung) in die Warteschlange und wartet auf seinen Einsatz.
In C# gibt await den Thread ebenfalls frei, solange die Task wartet. Aber in einem Konsolenprogramm und in ASP.NET Core gibt es keinen einzelnen Event-Loop-Thread. Die Fortsetzung nach await läuft im Thread-Pool (siehe die Lektion Thread-Pool und Aufgaben): in irgendeinem Thread, der frei ist. Zwei Fortsetzungen können also wirklich gleichzeitig laufen. WPF und WinForms sind anders: Dort hat das Fenster einen eigenen Thread mit einer Nachrichtenwarteschlange, und die Fortsetzung kehrt in ihn zurück, wie bei einem Event Loop.
Drei Anfragen an drei Dienste, gleichzeitig gestartet:
int waiting = 0;int most = 0;
async Task<string> Call(string name, int ms){ int now = Interlocked.Increment(ref waiting); most = Math.Max(most, now); await Task.Delay(ms); Interlocked.Decrement(ref waiting); return $"{name}: fertig nach {ms} ms";}
string[] done = await Task.WhenAll( Call("Zahlung", 300), Call("E-Mail", 100), Call("Lager", 200));foreach (string line in done){ Console.WriteLine(line);}Console.WriteLine($"Gleichzeitig gewartet: {most}");Zahlung: fertig nach 300 msE-Mail: fertig nach 100 msLager: fertig nach 200 msGleichzeitig gewartet: 3Call läuft bis await Task.Delay(ms) und gibt eine unfertige Task zurück. Task.WhenAll wartet auf alle drei und liefert die Ergebnisse in der Reihenfolge der Tasks, deshalb steht die Ausgabe in dieser Reihenfolge. waiting wird mit Interlocked verringert, weil die Fortsetzungen im Pool laufen und gleichzeitig ausgeführt werden können.„Gleichzeitig gewartet: 3“: Alle drei Anfragen warten zur selben Zeit, und es gibt nur einen Thread. Alles dauert etwa 300 ms (die längste), nicht 600 (die Summe). Der Thread hat auf keine von ihnen gewartet.
Probier es aus
Wie viele warten gleichzeitig
Dasselbe call, aber auf die erste Anfrage wird allein gewartet:
Was gibt das Programm aus?
string first = await Call("Zahlung", 300);string[] rest = await Task.WhenAll(Call("E-Mail", 100), Call("Lager", 200));Console.WriteLine($"Gleichzeitig gewartet: {most}");await Call(...) wartet die Zahlung bis zum Ende ab: Dann ist waiting wieder 0. Erst danach startet Task.WhenAll die anderen beiden gleichzeitig, also ist das Maximum 2. Das Ganze dauert etwa 500 ms: 300, dann 200.await direkt nach dem Aufruf heißt „warte darauf, bevor du weitermachst“. Damit sie gemeinsam warten, starte zuerst alle, dann warte.
Sieh es dir an
Ein Wettlauf zwischen zwei awaits
Zurück zum letzten Artikel. So greifen die beiden Bestellungen ineinander:
int stock = 1;int sold = 0;
async Task Checkout(string buyer){ int left = stock; if (left == 0) return; await Pay(buyer); stock = left - 1; Interlocked.Increment(ref sold);}Schritt für Schritt, in Worten
- Ein Artikel ist übrig. Zwei Bestellungen kommen im selben Moment: die von Anna und die von Ben.
- Der checkout von Anna liest den Bestand: left ist 1. Am await gibt er den Thread frei, bis die Bank antwortet.
- Der Thread ist frei, also läuft der checkout von Ben. Auch er liest 1: Anna hat noch nichts geschrieben.
- Die Bank antwortet Anna. Ihr Code läuft nach dem await weiter: stock = left - 1 = 0, sold = 1. Bis hierhin stimmt es.
- Ben macht mit dem alten left = 1 weiter: wieder stock = 0, sold = 2. Ein Artikel, zwei Verkäufe, und die Lücke ist das await, nicht ein zweiter Thread.
Zwischen zwei awaits läuft der Code ohne Unterbrechung. Aber an einem await gibt die Funktion den Thread frei, und währenddessen kann alles andere laufen. Wenn sie weitermacht, kann die Welt eine andere sein: left ist ein Wert, der vor dem Warten gelesen wurde.
Das ist dasselbe „lesen, ändern, schreiben“ wie bei Threads, nur ist die Unterbrechung nicht irgendwo, sondern genau am await. Deshalb ist die Ausgabe auch bei jedem Lauf gleich: Die Unterbrechung ist nicht zufällig.
C# hat noch etwas: Die Fortsetzungen von Anna und Ben laufen im Pool und können gleichzeitig in zwei Threads laufen. Hier antwortet die Bank ihnen zu verschiedenen Zeiten, deshalb stoßen sie nicht zusammen. Aber sold++ in zwei Threads gleichzeitig verliert Erhöhungen, wie in der Lektion über Race Conditions. Deshalb wird sold mit Interlocked.Increment erhöht.
Die Idee
Die Lösung: den Artikel vor dem await reservieren
Prüfung und Verringerung müssen ein Stück Code ohne await dazwischen sein. Die Bestellung reserviert den Artikel sofort und wartet erst dann auf die Bank:
int stock = 1;int sold = 0;object sync = new object();
async Task Checkout(string buyer){ lock (sync) { if (stock == 0) return; stock--; // den Artikel vor dem await reservieren } await Pay(buyer); Interlocked.Increment(ref sold);}Verkauft: 1, Bestand: 0stock-- stehen in einem lock, weil Checkout von mehreren Threads gleichzeitig aufgerufen werden kann: Die Anfragen eines Webservers kommen aus dem Pool. C# erlaubt kein await innerhalb eines lock (Fehler CS1996): Der Lock gehört einem Thread, und nach await kann die Fortsetzung in einem anderen sein. Der lock schützt also nur das Stück ohne await.Scheitert die Zahlung, muss der Artikel zurückgegeben werden. Das ist die Übung am Ende.
Genauer, für Neugierige
Manchmal muss das ganze Stück zusammen mit dem Warten gesperrt werden: zum Beispiel darf nur eine Bestellung gleichzeitig in ein altes Lagersystem schreiben, das nicht zwei verträgt. Dann brauchst du einen Lock, der await versteht:
SemaphoreSlim(1, 1): await gate.WaitAsync() wartet, ohne einen Thread festzuhalten, und gate.Release() steht in finally. So kann auch ein Stück mit await darin geschützt werden.Wie es in den anderen Sprachen ist: In JavaScript und in asyncio gibt es einen Thread, und das Stück zwischen zwei awaits läuft allein. In C# liegen die Fortsetzungen im Thread-Pool, deshalb braucht man dort auch einen lock. Java hat kein await: Dort macht man dasselbe mit CompletableFuture, und seit Java 21 auch mit virtuellen Threads, bei denen du gewöhnlichen blockierenden Code schreibst und die JVM den Thread selbst abhängt, solange er wartet.
Probier es aus
Finde den Fehler
Der Warenkorb addiert die Preise dreier Bestellungen. Der Preis kommt mit await aus dem Lager. Die drei Bestellungen kosten je 25 €, und die Summe kommt als 25 heraus:
int total = 0;
async Task AddOrder(int id){ total += await PriceOf(id);}
await Task.WhenAll(AddOrder(1), AddOrder(2), AddOrder(3));Console.WriteLine($"Gesamt: {total} €");Gesamt: 25 €Wähle die Zeile mit dem Fehler.
total += await PriceOf(id) ist total = total + await PriceOf(id): C# liest total vor dem await. Alle drei lesen 0 und schreiben 0 + 25. Erst warten, dann addieren: int price = await PriceOf(id); total += price;. (Hier liegen die Fortsetzungen auch im Pool, nimm im echten Code also zusätzlich Interlocked.Add.)Der Fehler liegt nicht in den Threads, sondern in der Reihenfolge: Ein Wert, der vor einem await gelesen wurde, ist danach veraltet.
Die Idee
Blockiere den Event Loop nicht
Der Event Loop funktioniert nur, wenn kein Codestück den Thread lange festhält. Der Verkaufsbericht wird mit Thread.Sleep(300) erstellt: Es schläft ohne await. Gleichzeitig öffnet ein Kunde eine Produktseite, die 50 ms auf das Lager wartet:
var watch = Stopwatch.StartNew();await Task.WhenAll(Report(), ProductPage("Anna"));
async Task Report(){ Thread.Sleep(300); // blockiert: wartet 300 ms ohne await Console.WriteLine("Der Bericht ist fertig"); await Task.Delay(10);}
async Task ProductPage(string buyer){ await Task.Delay(50); Console.WriteLine($"{buyer} wartet über 0,25 s: {watch.ElapsedMilliseconds > 250}");}Der Bericht ist fertigAnna wartet über 0,25 s: TrueDie Seite hat über 0,25 s gewartet statt 50 ms. Solange der Bericht den Thread festhält, kann keine andere Fortsetzung laufen: nicht diese Seite, nicht die Zahlungen, nicht die Timer. Auf einem Server mit Tausenden Kunden frieren alle gleichzeitig ein.
In C# blockiertThread.Sleep in einer async-Methode den Thread, der sie ausführt. Hier ist das der Hauptthread, vor dem ersten await, deshalb hat ProductPage gar nicht erst angefangen. Synchrones Warten (Thread.Sleep, .Result, .Wait()) in async-Code ist derselbe Fehler: Nimm await Task.Delay und await. In einer WPF- oder WinForms-Anwendung friert dasselbe das Fenster ein: Die Schaltflächen reagieren nicht, solange der Thread des Fensters beschäftigt ist.Die Lösung: Halte die schwere Arbeit aus dem Thread des Event Loops heraus. Sie geht in einen Thread-Pool, und der Event Loop wartet nur auf das Ergebnis:
await Task.WhenAll(Report(), ProductPage("Anna"));
async Task Report(){ await Task.Run(() => Thread.Sleep(300)); Console.WriteLine("Der Bericht ist fertig");}
async Task ProductPage(string buyer){ await Task.Delay(50); Console.WriteLine($"{buyer}: die Seite ist fertig");}Anna: die Seite ist fertigDer Bericht ist fertigawait Task.Run(...) schickt die Arbeit in den Pool und gibt den Thread frei. Die Seite ist nach 50 ms fertig, der Bericht nach 300.Probier es aus
Ergänze
Starte die drei Anfragen gleichzeitig und warte gemeinsam auf sie:
string[] done = await (
Call("Zahlung", 300),
Call("E-Mail", 100),
Call("Lager", 200));
foreach (string line in done)
{
Console.WriteLine(line);
}
Console.WriteLine($"Gleichzeitig gewartet: {most}");Task.WhenAll wartet auf alle Promises. Die drei Anfragen warten schon gemeinsam, also dauert das Ganze so lange wie die längste.
Übung
Reservieren, bezahlen, zurückgeben
Löse die Aufgabe in deinem eigenen Editor.
Es sind zwei Artikel übrig. Vier Kunden bestellen gleichzeitig: Anna, Ben, Vera und Greta. Die Zahlung dauert 100 ms. Bens Karte wird abgelehnt: Die Zahlung wirft eine Exception mit dem Text „Karte abgelehnt“.
checkout reserviert den Artikel vor dem await und bezahlt dann. Scheitert die Zahlung, wird der Artikel zurückgegeben. Gib das Ergebnis jedes Kunden in der Reihenfolge der Bestellungen aus und am Ende den Bestand und die Zahl der Verkäufe:
Anna: bezahltBen: Karte abgelehnt, der Artikel wird zurückgegebenVera: nicht auf LagerGreta: nicht auf LagerBestand: 1, verkauft: 1Vera und Greta sehen „nicht auf Lager“, weil Bens Artikel reserviert bleibt, bis die Bank antwortet. Das ist fair: Der zurückgegebene Artikel geht an den nächsten Kunden.
Weitere Aufgaben
- Noch einmal versuchen. Findet ein Kunde keinen Bestand, aber ein anderer könnte einen Artikel zurückgeben, lass ihn bis zu 200 ms warten und es noch einmal versuchen.
- Timeout. Die Bank antwortet manchmal nicht. Dauert die Zahlung über 1 s, lehne die Bestellung ab und gib den Artikel zurück. (Task.WaitAsync)
- Bericht. Berechne die Summe der Verkäufe (je 25 €) und gib sie am Ende aus.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Warum Threads · Race Condition · Sperren · Thread-Pool und Aufgaben
- Geht weiter in: Nebenläufigen Code testen
- Aufgaben zum Thema: Nebenläufigkeit und Race Conditions