Nebenläufigkeit in der Nachtküche
·
Kursüberblick
1
Die Schlange am ersten Abend: Nebenläufigkeit und Parallelität
2
Mehr Köche einstellen: Grundlagen von threading
3
Die Zahl auf der Tafel stimmt nicht: Race Conditions und Lock
4
Nur drei Herdplatten: Synchronisationswerkzeuge
5
Messer und Bretter: Deadlock
6
Die Bonschiene: queue
7
Leihköche: concurrent.futures
8
Warum der Teig nicht schneller wird: der GIL und Parallelität
9
Eine Servicechefin für alle Tische: erste Schritte mit asyncio
10
Die Gruppenreservierung platzt: strukturierte Nebenläufigkeit
11
Der Gastraum steht still: blockierende Aufrufe und Kombinieren
12
Die Nacht der Kritikerin: alles zusammen
Zeit 6:40–7:30
Der Bon für heute Nacht
#09 · Neunte Nacht
Problem
Erster Abend mit dem neuen Gastraum. Eine einzige Servicekraft, Lou, und zehn Tische. Wartet sie an der Küche auf das Essen für einen Tisch, stehen die anderen neun still.
Ziel
Das Essen für alle zehn Tische in unter 0,5 Sekunden rausbringen, ohne mehr Personal einzustellen.
Nachtlokal Dongsi
Ab heute Nacht ist der Gastraum anders organisiert. Es gibt eine einzige Servicekraft: Lou. Und zehn Tische. Die Chefin hat niemanden zusätzlich eingestellt.
Lou dreht ohne Pause ihre Runden durch den Gastraum. Sie nimmt eine Bestellung auf, gibt sie in die Küche und geht, statt auf das Essen zu warten, gleich zum nächsten Tisch. Klingelt die Glocke in der Küche, holt sie den Teller und bringt ihn raus. Eine Runde, noch eine Runde. Weil sie auf Glockenzeichen (Events) reagiert und dabei immer denselben Weg abläuft, haben ihr die Köche schon am ersten Abend einen Spitznamen gegeben: die Event-Loop.
Lou bindet sich die Schürze zu. „Am meisten hasse ich es, vor einem Tisch zu stehen und einfach nur zu warten. Während ich warte, warten die anderen Tische auf mich.“
Die Chefin nickt. „Bis gestern haben wir mehr Leute eingestellt. Heute geht es darum, wie eine einzige Person ihre Wartezeit nutzt. In Python heißt das asyncio.“
Setzt du async vor eine Funktion, wird sie zur Koroutinenfunktion. Fangen wir mit dem Seltsamen an: Wenn du sie aufrufst, läuft sie nicht.
import asyncio
async def take_order(table):
print("order", table)
return f"T{table}"
c = take_order(1)
print(type(c).__name__)
print(c)
# coroutine
# <coroutine object take_order at 0x...>
# <sys>:0: RuntimeWarning: coroutine 'take_order' was never awaitedtake_order(1) hat nicht "order 1" ausgegeben. Vom Funktionsrumpf ist keine einzige Zeile gelaufen.was never awaited aus. Siehst du diese Warnung, hast du fast immer ein await vergessen.Es gibt zwei Wege, den Zettel auszuführen. Auf oberster Ebene gibst du ihn an asyncio.run(), innerhalb einer anderen Koroutine machst du await.
print(asyncio.run(take_order(1)))
# order 1
# T1
c = take_order(2)
asyncio.run(c)
asyncio.run(c)
# order 2
# RuntimeError: cannot reuse already awaited coroutineEin Koroutinenobjekt lässt sich nur einmal verwenden. Für dieselbe Bestellung noch einmal rufst du take_order(2) erneut auf und bekommst einen neuen Zettel.
await heißt: „Ich warte hier, bis diese Sache fertig ist. Die Loop darf in der Zwischenzeit etwas anderes machen.“ await darf nur innerhalb von async def stehen. In einem normalen def gibt es SyntaxError: 'await' outside async function.
import asyncio
import time
async def table(n, wait):
print(f"T{n} order")
await asyncio.sleep(wait) # während sie auf die Küche wartet, ist Lou frei
print(f"T{n} served")
return f"T{n}"
async def one_by_one():
a = await table(1, 0.3)
b = await table(2, 0.3)
return [a, b]
start = time.perf_counter()
print(asyncio.run(one_by_one()), f"{time.perf_counter() - start:.2f}s")
# T1 order
# T1 served
# T2 order
# T2 served
# ['T1', 'T2'] 0.62sasyncio.sleep(0.3) ist die asyncio-Version von time.sleep(0.3). Während des Wartens gibt sie den Platz an die Loop ab.await table(1, ...) fertig ist. Als würde Lou vor Tisch 1 stehen bleiben, bis das Essen kommt.await heißt nicht „gleichzeitig“, sondern „warten“. Um gleichzeitig zu warten, brauchst du die Werkzeuge aus dem nächsten Abschnitt.asyncio.run(main()) ist der Eingang zum Programm.
main() laufen, bis sie fertig ist.Normalerweise rufst du es im Programm genau einmal auf, auf oberster Ebene. Rufst du es an einer Stelle auf, an der schon eine Loop läuft, gibt es RuntimeError: asyncio.run() cannot be called from a running event loop.
Die Event-Loop ist eine Schleife, die in einem einzigen Thread läuft. Sie tut dasselbe wie Lou auf ihrer Runde durch den Gastraum.
await ausführen.Übernimmt Lou drei Tische gleichzeitig (mit gather aus dem nächsten Abschnitt), sieht ihr Weg so aus. Tisch 1 braucht 0,3 Sekunden, Tisch 2 0,2 Sekunden, Tisch 3 0,1 Sekunden.
| Zeit | Was Lou macht | Ausgabe |
|---|---|---|
| 0,0 s | gibt die Bestellung von T1 auf und geht beim await weg | T1 order |
| 0,0 s | gibt die Bestellung von T2 auf und geht weg | T2 order |
| 0,0 s | gibt die Bestellung von T3 auf und geht weg | T3 order |
| 0,0–0,1 s | nichts zu tun, also auf die früheste Glocke warten | |
| 0,1 s | Glocke für T3. Teller holen und raus damit | T3 served |
| 0,2 s | Glocke für T2 | T2 served |
| 0,3 s | Glocke für T1 | T1 served |
Zwei Dinge solltest du dir merken.
await. Threads unterbricht das Betriebssystem jederzeit (Lektion 3), Koroutinen räumen ihren Platz nur, wenn sie selbst await machen. Code zwischen zwei await wird deshalb nicht mittendrin unterbrochen.asyncio.gather(koroutine, koroutine, ...) setzt mehrere Koroutinen auf einmal auf die Loop und wartet, bis alle fertig sind.
async def together():
return await asyncio.gather(table(1, 0.3), table(2, 0.2), table(3, 0.1))
start = time.perf_counter()
print(asyncio.run(together()), f"{time.perf_counter() - start:.2f}s")
# T1 order
# T2 order
# T3 order
# T3 served
# T2 served
# T1 served
# ['T1', 'T2', 'T3'] 0.31sexecutor.map aus Lektion 7.gather(*coros).Schalte im Versuchsaufbau unten zwischen „await Tisch für Tisch“ und „Alles auf einmal mit gather“ hin und her. Eine Reihe ist ein Tisch, und alle Balken haben die Farbe für „Warten“. Lou ist fast nie selbst mit den Händen beschäftigt, die meiste Zeit wartet sie auf die Küche.
Küchen-Zeitleiste
Bestellungen
6
Kochen
Warten
Abgebrochen
Fehlgeschlagen
Wirft eine der an gather übergebenen Koroutinen eine Ausnahme, reicht gather diese Ausnahme direkt weiter. Willst du trotzdem alle Ergebnisse bekommen, gib return_exceptions=True an.
async def table(n):
await asyncio.sleep(0.1 * n)
if n == 2:
raise ValueError(f"T{n} wrong dish")
return f"T{n}"
async def main():
results = await asyncio.gather(table(1), table(2), table(3), return_exceptions=True)
print(results)
try:
await asyncio.gather(table(1), table(2), table(3))
except ValueError as e:
print("ValueError:", e)
asyncio.run(main())
# ['T1', ValueError('T2 wrong dish'), 'T3']
# ValueError: T2 wrong dishreturn_exceptions=True landet auch die Ausnahme als Eintrag in der Ergebnisliste. Prüfe die Ergebnisse einzeln mit isinstance(r, Exception).False) werden die übrigen Tische nicht abgebrochen, auch wenn die Ausnahme hochkommt; sie laufen weiter. „Wenn einer scheitert, stopp den Rest“ erledigt die TaskGroup aus Lektion 10.Tipp der Chefin
Die Warnung was never awaited heißt: Du hast den Bon geschrieben, aber nie in die Küche gegeben. Wenn du eine Koroutinenfunktion aufrufst, musst du sie auch awaiten oder einen Task draus machen. Sonst kommt das Gericht nie raus.
asyncio.create_task(koroutine) verpackt eine Koroutine in einen Task und plant sie sofort auf der Loop ein. Der Task startet, sobald Lou die Hände frei hat (wenn die aktuelle Koroutine await macht). Es wird nicht auf das Ende gewartet, sondern gleich zur nächsten Zeile gegangen; du kannst also Bestellungen schon mal aufgeben und etwas anderes erledigen.
async def table(n, wait):
print(f"T{n} order")
await asyncio.sleep(wait)
print(f"T{n} served")
return f"T{n}"
async def main():
start = time.perf_counter()
t1 = asyncio.create_task(table(1, 0.3))
t2 = asyncio.create_task(table(2, 0.2))
print("Lou: 2 tasks created")
await asyncio.sleep(0.1) # Lou erledigt kurz etwas anderes
print("Lou: back", t1.done(), t2.done())
print(await t1, await t2, f"{time.perf_counter() - start:.2f}s")
asyncio.run(main())
# Lou: 2 tasks created
# T1 order
# T2 order
# Lou: back False False
# T2 served
# T1 served
# T1 T2 0.31s"Lou: 2 tasks created" erscheint vor T1 order. Tasks werden gleich beim Erzeugen eingeplant, laufen aber erst los, wenn main mit await seinen Platz räumt.await t1 wartet darauf, dass ein bereits laufender Task fertig wird. Beide Tasks haben gemeinsam gewartet, also sind es nicht 0,5, sondern etwa 0,3 Sekunden.gather verpackt die übergebenen Koroutinen intern selbst in Tasks. Brauchst du nur die gesammelten Ergebnisse, nimm gather; willst du Tasks einzeln in der Hand behalten, ihren Zustand ansehen oder später auf sie warten, nimm create_task.Die Event-Loop hält Tasks nur über eine schwache Referenz. Ein Task, den du in keiner Variablen und keiner Collection ablegst, kann von der Garbage Collection eingesammelt werden, bevor er fertig ist. Auch wenn du Arbeit abgibst, deren Ergebnis du nicht brauchst, etwa Wasser nachschenken, musst du eine Referenz festhalten.
background = set()
async def refill_water(table):
await asyncio.sleep(0.1)
print(f"T{table} water")
async def main():
for n in (1, 2, 3):
task = asyncio.create_task(refill_water(n))
background.add(task) # Referenz festhalten
task.add_done_callback(background.discard) # nach dem Ende entfernt er sich selbst
print("pending", len(background))
await asyncio.sleep(0.2)
print("pending", len(background))
asyncio.run(main())
# pending 3
# T1 water
# T2 water
# T3 water
# pending 0Eine Zeile wie asyncio.create_task(refill_water(n)), die das Ergebnis wegwirft, funktioniert meistens, aber genau dieses „meistens“ ist das Problem. Tasks, die gelegentlich verschwinden, sind die am schwersten zu findenden Bugs.
Beides heißt „kurz warten“, aber für Lou ist es etwas völlig anderes.
async def polite(n):
await asyncio.sleep(0.3) # beim Warten den Platz an die Loop abgeben
async def stubborn(n):
time.sleep(0.3) # hält die ganze Loop an
async def main(fn):
start = time.perf_counter()
await asyncio.gather(*(fn(n) for n in range(3)))
print(fn.__name__, f"{time.perf_counter() - start:.2f}s")
asyncio.run(main(polite))
asyncio.run(main(stubborn))
# polite 0.32s
# stubborn 0.90sasyncio.sleep sagt der Loop im Moment des await: „Weck mich in 0,3 Sekunden“, und räumt den Platz.time.sleep hält den Thread selbst an. Die Loop läuft in nur einem Thread, also ist das, als würde Lou vor einem Tisch stehen und einfach nur warten. Um die anderen Tische kümmert sich in der Zeit niemand.gather nicht.Nicht nur time.sleep: Auch lange Berechnungen und alte Bibliotheken, die beim Warten den Platz nicht abgeben, halten die Loop genauso an. Dieses Problem und seine Lösung sind Thema von Lektion 11.
In asyncio tauchen drei ähnliche Namen auf.
| Name | In der Küche | Was es tut |
|---|---|---|
| Koroutine (coroutine) | der Bestellzettel | entsteht, wenn du ein async def aufrufst. Läuft nicht von allein |
| Task | die Bestellung, die in der Küche ist | verpackt eine Koroutine und setzt sie auf die Loop. done(), result(), cancel() |
| Future | eine Glocke, die später klingelt | „ein Platz, an dem irgendwann ein Wert ankommt“. Ein Task ist eine Art Future |
print(issubclass(asyncio.Task, asyncio.Future)) # True
async def main():
coro = take_order(1)
task = asyncio.create_task(coro)
print(type(coro).__name__, type(task).__name__) # coroutine TaskDie Idee ist dieselbe wie bei concurrent.futures.Future aus Lektion 7. Nur kann man auf ein asyncio-Future await machen, und man verwendet es nur innerhalb derselben Loop. Futures selbst anzulegen ist selten nötig. Meistens reichen Koroutinen und Tasks.
Threads erzeugt das Betriebssystem, und jeder bringt Stack-Speicher und Verwaltungsaufwand im Betriebssystem mit. Ein Task ist bloß ein einzelnes Python-Objekt.
async def table(n):
await asyncio.sleep(0.5)
return n
async def main():
return await asyncio.gather(*(table(n) for n in range(10_000)))
# 10,000 tasks: etwa 0.57 s (10.000 Tische, die 0,5 s warten)
# 1,000 threads: etwa 0.62 s (1.000 Threads, die 0,5 s warten)
# auf diesem PC gemessen10.000 Tasks zu starten kostet weniger als 1.000 Threads. Darum passt asyncio so gut, wenn es Tausende oder Zehntausende Wartearbeiten gibt (Chatserver, Anfragen an viele Adressen).
Das Ziel für heute Nacht war, das Essen für alle zehn Tische in unter 0,5 Sekunden rauszubringen, ohne mehr Personal einzustellen. Dafür reichen eine Lou, eine Event-Loop und ein gather.
import asyncio
import time
async def table(n):
await asyncio.sleep(0.3) # warten, bis das Essen aus der Küche kommt
return f"T{n}"
async def hall():
return await asyncio.gather(*(table(n) for n in range(1, 11)))
start = time.perf_counter()
served = asyncio.run(hall())
print(served)
print(f"{time.perf_counter() - start:.2f}s")
# ['T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8', 'T9', 'T10']
# 0.30sWir haben keinen einzigen Thread angelegt. Zehn Tische zu je 0,3 Sekunden waren in etwa 0,3 Sekunden fertig.
| Threads (Lektion 2–7) | asyncio (ab heute) | |
|---|---|---|
| Wer arbeitet | mehrere Köche | eine Lou |
| Wann gewechselt wird | jederzeit, nach Belieben des Betriebssystems | nur bei await |
| Kosten pro Stück | hoch (Betriebssystem-Thread) | gering (Python-Objekt) |
| Worauf achten | Race Conditions, Deadlocks | blockierende Aufrufe, vergessenes await |
Lou stellt das leere Tablett ab. „Zehn Tische, in einer Runde erledigt.“ Die Chefin schaut auf ihren Timer. „Gut. Aber morgen kommt die Gruppenreservierung. Wenn von sechs Tischen einer absagt, wie stoppst du dann das Essen für die anderen fünf?“
Schichtbuch — Neunte Nacht. Lou hat allein zehn Tische übernommen. Beim Warten den Platz räumen (await) und auf alle auf einmal warten (gather): in 0,3 Sekunden serviert. Morgen: die Gruppenreservierung.
take_order aus 9-1 ohne await aufruft, und eine, die es mit asyncio.run() aufruft. Vergleiche Ausgabe und Warnung.main aus 9-5 das await asyncio.sleep(0.1) löschst? Schreib deine Vermutung zuerst auf und prüfe sie dann.Rezeptkarte der Chefin
async def erzeugt eine Koroutine. Nur aufgerufen, läuft sie nicht (Warnung was never awaited).
asyncio.run(main()): öffnet die Loop, lässt main bis zum Ende laufen und schließt sie. Einmal, auf oberster Ebene.
await: wartet, bis etwas fertig ist, und gibt dabei den Platz an die Loop ab. Gewechselt wird nur bei await.
await asyncio.gather(*coros): gleichzeitig warten, Ergebnisse in Übergabereihenfolge. return_exceptions=True.
task = asyncio.create_task(coro): schon mal abgeben. Referenz festhalten (schwache Referenz).
asyncio.sleep gibt den Platz ab, time.sleep hält die Loop an.
Koroutine (Zettel) → Task (aufgegebene Bestellung) ⊂ Future (Glocke, die später klingelt). Auch 10.000 Tasks sind leicht.
← Zurück
Lektion 8 · Warum der Teig nicht schneller wird: der GIL und Parallelität
Weiter →
Lektion 10 · Die Gruppenreservierung platzt: strukturierte Nebenläufigkeit
Über neue Lektionen informiert werden
Wir schicken dir eine E-Mail, wenn dieser Kurs neue Lektionen bekommt oder ein Folgekurs erscheint.
R
ReflexKR
Python und Reflex zum Anfassen. Alle Beispiele wurden mit Reflex 0.10.0 ausgeführt und geprüft.
ReflexKR ist kostenlos und ohne Werbebanner. Wenn es dir geholfen hat, kannst du es mit einem Kaffee unterstützen. ☕ Spendier uns einen Kaffee
Hinweis zu Affiliate-Links© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
Fragen & Antworten
Frag alles zu dieser Lektion. Fragen aus allen Sprachversionen dieser Seite erscheinen hier gemeinsam.