Zum Inhalt
wannacode

async ist kein Thread

Kurz gesagt: Asynchroner Code ist kein Thread: Beim Warten gibt er den Thread frei, und die Fortsetzung läuft später; in Java ist das CompletableFuture, und ein Wettlauf zwischen zwei Wartezeiten ist schon auf einem Thread 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“:

// Ein Thread spielt den Event Loop: Die Fortsetzungen laufen darin nacheinander.
static final ExecutorService loop = Executors.newSingleThreadExecutor();
// after(ms): führt die Aufgabe in loop nach ms Millisekunden aus.
static Executor after(long ms) {
return CompletableFuture.delayedExecutor(ms, TimeUnit.MILLISECONDS, loop);
}
// Die Bank bestätigt die Zahlung in 100 ms.
static CompletableFuture<Void> pay(String buyer) {
return CompletableFuture.runAsync(() -> {}, after(100));
}
static int stock = 1;
static int sold = 0;
static CompletableFuture<Void> checkout(String buyer) {
int left = stock;
if (left == 0) {
return CompletableFuture.completedFuture(null);
}
return pay(buyer).thenRunAsync(() -> {
stock = left - 1;
sold++;
}, loop);
}
var first = checkout("Anna");
var second = checkout("Ben");
CompletableFuture.allOf(first, second).join();
System.out.println("Verkauft: " + sold + ", Bestand: " + stock);
Ausgabe
Verkauft: 2, Bestand: 0

Zwei 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.

Alles hier läuft in einem Thread: loop ist Executors.newSingleThreadExecutor(). In der Lektion Race Condition sind zwei Threads bei count++ zusammengestoßen. Hier gibt es keinen zweiten Thread.

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.

Java hat kein async und await. Am nächsten kommt CompletableFuture: ein Versprechen auf ein Ergebnis, an das du eine Fortsetzung mit thenRunAsync oder thenApplyAsync hängst. Du sagst, in welchem Executor die Fortsetzung läuft. Hier laufen alle in loop, einem Executor mit einem Thread, der den Event Loop spielt. So arbeiten Netty und Vert.x, und viele Java-Server sind darauf gebaut.

Drei Anfragen an drei Dienste, gleichzeitig gestartet:

static int waiting = 0;
static int most = 0;
static CompletableFuture<Void> call(String name, int ms) {
waiting++;
most = Math.max(most, waiting);
return CompletableFuture.runAsync(() -> {
waiting--;
System.out.println(name + ": fertig nach " + ms + " ms");
}, after(ms));
}
CompletableFuture.allOf(
call("Zahlung", 300),
call("E-Mail", 100),
call("Lager", 200)
).join();
System.out.println("Gleichzeitig gewartet: " + most);
Ausgabe
E-Mail: fertig nach 100 ms
Lager: fertig nach 200 ms
Zahlung: fertig nach 300 ms
Gleichzeitig gewartet: 3
after(ms) liefert einen Executor, der die Aufgabe nach ms Millisekunden in loop ausführt: Das ist der Timer. call erhöht waiting und kehrt sofort zurück. allOf(...).join() wartet auf alle drei.

„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?

call("Zahlung", 300).join();
CompletableFuture.allOf(call("E-Mail", 100), call("Lager", 200)).join();
System.out.println("Gleichzeitig gewartet: " + most);
Antwort

Sieh es dir an

Ein Wettlauf zwischen zwei awaits

Zurück zum letzten Artikel. So greifen die beiden Bestellungen ineinander:

static int stock = 1;
static int sold = 0;
static CompletableFuture<Void> checkout(String buyer) {
int left = stock;
if (left == 0) {
return CompletableFuture.completedFuture(null);
}
return pay(buyer).thenRunAsync(() -> {
stock = left - 1;
sold++;
}, loop);
}
Schritt für Schritt, in Worten
  1. Ein Artikel ist übrig. Zwei Bestellungen kommen im selben Moment: die von Anna und die von Ben.
  2. Der checkout von Anna liest den Bestand: left ist 1. Der Rest steht in thenRunAsync: Er läuft, wenn die Bank antwortet, und bis dahin ist der Thread frei.
  3. Der Thread ist frei, also läuft der checkout von Ben. Auch er liest 1: Anna hat noch nichts geschrieben.
  4. Die Bank antwortet Anna. Ihr Code läuft nach dem thenRunAsync weiter: stock = left - 1 = 0, sold = 1. Bis hierhin stimmt es.
  5. Ben macht mit dem alten left = 1 weiter: wieder stock = 0, sold = 2. Ein Artikel, zwei Verkäufe, und das mit nur einem Thread.

Zwischen zwei awaits (hier: zwischen dem Start der Methode und thenRunAsync) 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.

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:

static int stock = 1;
static int sold = 0;
static CompletableFuture<Void> checkout(String buyer) {
if (stock == 0) {
return CompletableFuture.completedFuture(null);
}
stock--; // den Artikel vor dem await reservieren
return pay(buyer).thenRunAsync(() -> sold++, loop);
}
Ausgabe
Verkauft: 1, Bestand: 0
Die Prüfung und stock-- stehen vor pay(...) und laufen in einem Zug. Hier wird checkout von einem Thread aufgerufen, das reicht also. Würden viele Threads es aufrufen (die Anfragen eines Webservers), bräuchtest du zusätzlich synchronized um die Prüfung und die Verringerung, wie in der Lektion Sperren.

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:

In einer Schleife mit einem Thread läuft jedes Stück zwischen zwei Fortsetzungen ohnehin allein. Bei vielen Threads nimmst du einen Semaphore oder eine Warteschlange mit einem Thread für das Lager.

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:

static int total = 0;
static CompletableFuture<Void> addOrder(int id) {
int before = total;
return priceOf(id).thenAcceptAsync(price -> {
total = before + price;
}, loop);
}
CompletableFuture.allOf(addOrder(1), addOrder(2), addOrder(3)).join();
System.out.println("Gesamt: " + total + " €");
Ausgabe
Gesamt: 25 €

Wähle die Zeile mit dem Fehler.

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) in loop erstellt. Gleichzeitig öffnet ein Kunde eine Produktseite, die 50 ms auf das Lager wartet:

static void buildReport() throws InterruptedException {
Thread.sleep(300); // blockiert: wartet 300 ms ohne await
}
long start = System.nanoTime();
CompletableFuture<Void> report = CompletableFuture.runAsync(() -> {
try {
buildReport();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Der Bericht ist fertig");
}, loop);
CompletableFuture<Void> page = CompletableFuture.runAsync(() -> {
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("Anna wartet über 0,25 s: " + (ms > 250));
}, after(50));
CompletableFuture.allOf(report, page).join();
Ausgabe
Der Bericht ist fertig
Anna wartet über 0,25 s: true

Die 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.

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:

static final ExecutorService workers = Executors.newFixedThreadPool(2);
static CompletableFuture<Void> report() {
return CompletableFuture.runAsync(() -> {
try {
buildReport();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, workers).thenRunAsync(() -> System.out.println("Der Bericht ist fertig"), loop);
}
CompletableFuture<Void> page = CompletableFuture.runAsync(
() -> System.out.println("Anna: die Seite ist fertig"), after(50));
CompletableFuture.allOf(report(), page).join();
workers.shutdown();
Ausgabe
Anna: die Seite ist fertig
Der Bericht ist fertig
runAsync(..., workers) erstellt den Bericht im Pool workers, und thenRunAsync(..., loop) bringt die Fortsetzung zurück in loop. loop bleibt frei, und die Seite ist nach 50 ms fertig.

Probier es aus

Ergänze

Starte die drei Anfragen gleichzeitig und warte gemeinsam auf sie:

(
    call("Zahlung", 300),
    call("E-Mail", 100),
    call("Lager", 200)
).join();
System.out.println("Gleichzeitig gewartet: " + most);

Ü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:

Ausgabe
Anna: bezahlt
Ben: Karte abgelehnt, der Artikel wird zurückgegeben
Vera: nicht auf Lager
Greta: nicht auf Lager
Bestand: 1, verkauft: 1

Vera 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. (orTimeout)
  • Bericht. Berechne die Summe der Verkäufe (je 25 €) und gib sie am Ende aus.

Test

Teste dich

1. In wie vielen Threads läuft dein Code in JavaScript (Node) und in Python asyncio?
2. Drei Anfragen warten 100, 200 und 300 ms. Wie lange dauert es insgesamt, wenn du sie nacheinander mit await abwartest, und wie lange mit Promise.all, gather, Task.WhenAll oder allOf?
3. In JavaScript und Python liest eine async-Funktion gemeinsame Daten, wartet mit await und schreibt dann. Wann kann anderer Code die Daten ändern?
4. Die Bearbeitung einer Anfrage enthält eine Berechnung, die 2 Sekunden dauert, ohne ein einziges await. Was passiert mit den anderen Anfragen in Node oder asyncio?
5. Wie vermeidest du, dass der letzte Artikel zweimal verkauft wird?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·