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 7:30–8:20
Der Bon für heute Nacht
#10 · Zehnte Nacht
Problem
Eine Gruppenreservierung, sechs Tische. Tisch 3 storniert sein Menü wegen einer Allergie, und die Gruppe beschließt, geschlossen an einem anderen Tag wiederzukommen. Aber auf den Herden der anderen fünf Tische köcheln die Töpfe weiter.
Ziel
Scheitert ein Tisch, wird alles andere sofort gestoppt, und kein Gericht überschreitet die festgelegte Zeit.
Nachtlokal Dongsi
Halb elf in der Nacht. Sechs Tische einer Gruppe nehmen gleichzeitig Platz. Lou nimmt die Bestellungen auf und gibt sie in die Küche, und die Küche beginnt alle sechs Menüs gleichzeitig. Zehn Minuten später hebt jemand an Tisch 3 die Hand: Die Erdnussallergie wurde zu spät erwähnt. Der Organisator der Gruppe sagt: „Dann brechen wir heute alle zusammen auf.“
Minho hat das nicht mitbekommen und lässt den Eintopf für Tisch 5 weiterköcheln. Sua dreht ihm die Platte ab und sagt: „Nirgendwo stand die Regel, dass alles stoppt, wenn ein Tisch rausfällt.“
Chefin Yoon Seoyeon drückt auf ihren Timer. „Was zusammen anfängt, muss auch zusammen aufhören. Das im Code festzuschreiben, ist die Arbeit für heute Nacht.“
Wickeln wir die Gruppenreservierung mit asyncio.gather aus Lektion 9 ab. Tisch 2 scheitert schon nach 0,1 Sekunden.
import asyncio
async def table(n, sec, fail=False):
await asyncio.sleep(sec)
if fail:
raise ValueError(f"T{n} allergy")
print(f"T{n} served")
async def main():
try:
await asyncio.gather(table(1, 0.3), table(2, 0.1, fail=True), table(3, 0.5))
except ValueError as e:
print("caught:", e)
await asyncio.sleep(0.6) # das Lokal noch ein wenig offen lassen
asyncio.run(main())
# caught: T2 allergy
# T1 served
# T3 servedgather reicht die erste Exception sofort nach oben weiter. Deshalb erscheint caught: zuerst.served aus.gather(return_exceptions=True) packt Exceptions nur in die Ergebnisliste, stoppt den Rest aber auch nicht. Wenn du „scheitert einer, hören alle auf“ willst, brauchst du ein anderes Werkzeug.
asyncio.TaskGroup (3.11) behandelt Tasks als ein Bündel. Diese Art, Tasks gebündelt zu verwalten, nennt man strukturierte Nebenläufigkeit (structured concurrency).
import asyncio
async def table(n, sec):
await asyncio.sleep(sec)
return f"T{n}"
async def main():
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(table(n, 0.1 * n)) for n in range(1, 4)]
# hier angekommen, sind alle drei Tasks fertig
print([t.result() for t in tasks])
asyncio.run(main())
# ['T1', 'T2', 'T3']TaskGroup verspricht drei Dinge.
async with-Block verlässt, sind alle darin erzeugten Tasks fertig. Ein extra await ist nicht nötig.ExceptionGroup gesammelt nach oben gereicht.Weil tg.create_task den Task festhält, musst du dir auch keine Sorgen um die „Tasks, die verschwinden, weil die Referenz verloren geht“ aus Lektion 9 machen. Jetzt die Gruppenreservierung, bei der Tisch 3 scheitert.
import asyncio
async def table(n, sec, fail=False):
try:
await asyncio.sleep(sec)
if fail:
raise ValueError(f"T{n} allergy")
print(f"T{n} served")
except asyncio.CancelledError:
print(f"T{n} cancelled")
raise # einen empfangenen Abbruch wieder werfen
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(table(1, 0.05))
tg.create_task(table(2, 0.3))
tg.create_task(table(3, 0.1, fail=True))
tg.create_task(table(4, 0.5))
except* ValueError as eg:
print("group failed:", eg.exceptions)
await asyncio.sleep(0.6)
asyncio.run(main())
# T1 served
# T2 cancelled (die Reihenfolge von T2 und T4 ändert sich von Lauf zu Lauf)
# T4 cancelled
# group failed: (ValueError('T3 allergy'),)served mehr. Anders als bei gather köchelt im Hintergrund heimlich kein Topf weiter.await ein asyncio.CancelledError. Deshalb kannst du ihn mit except abfangen und aufräumen. Einen empfangenen Abbruch wirfst du aber unbedingt wieder (10-5).Die Zeitleiste unten lässt auf dem Server einen echten TaskGroup laufen. Je höher die Tischnummer, desto ein bisschen länger dauert das Gericht (T1 0,3 s, T2 0,345 s, … T6 etwa 0,53 s). Der Tisch, den du als scheiternden Tisch wählst, scheitert ungefähr dann, wenn sein Gericht fertig wäre.
Küchen-Zeitleiste
Bestellungen
6
Tisch, der scheitert
Keine
Zeitlimit (s)
aus
Kochen
Warten
Abgebrochen
Fehlgeschlagen
Mehrere Tasks können auch gleichzeitig scheitern. Deshalb wirft TaskGroup nicht eine einzelne Exception, sondern ein Bündel von Exceptions.
import asyncio
async def boom(exc, sec):
await asyncio.sleep(sec)
raise exc
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(boom(ValueError("T2 allergy"), 0.1))
tg.create_task(boom(KeyError("T5"), 0.1))
except Exception as e:
print(type(e).__name__, "|", e)
print(e.exceptions)
asyncio.run(main())
# ExceptionGroup | unhandled errors in a TaskGroup (2 sub-exceptions)
# (ValueError('T2 allergy'), KeyError('T5'))ExceptionGroup (3.11) ist eine Exception, die eine Liste von Exceptions in .exceptions trägt. Um aus dem Bündel nach Art getrennt abzufangen, nimmst du except*.
eg = ExceptionGroup("kitchen", [ValueError("T2 allergy"), KeyError("T5"), ValueError("T6 salt")])
try:
raise eg
except* ValueError as g:
print("ValueError:", [str(e) for e in g.exceptions])
except* KeyError as g:
print("KeyError:", g.exceptions)
# ValueError: ['T2 allergy', 'T6 salt']
# KeyError: (KeyError('T5'),)except läuft nur ein Block. Bei except* laufen alle passenden Blöcke. Oben laufen beide.g ist immer eine ExceptionGroup: ein kleineres Bündel, in dem nur die passenden Exceptions stecken.except* passen, werden als Restbündel weiter nach oben geworfen.eg = ExceptionGroup("kitchen", [ValueError("T2"), KeyError("T5")])
try:
try:
raise eg
except* ValueError:
print("handled ValueError")
except ExceptionGroup as rest:
print("left:", rest.exceptions)
print(isinstance(eg, Exception), eg.subgroup(ValueError).exceptions)
# handled ValueError
# left: (KeyError('T5'),)
# True (ValueError('T2'),)Für except* gelten zwei Regeln. Bei Verstößen gegen beide stoppt 3.14 schon vor der Ausführung mit einem Syntaxfehler.
src1 = '''
def f():
try:
pass
except* ValueError:
return 1
'''
src2 = '''
try:
pass
except ValueError:
pass
except* KeyError:
pass
'''
for src in (src1, src2):
try:
compile(src, "<menu>", "exec")
except SyntaxError as e:
print("SyntaxError:", e.msg)
# SyntaxError: 'break', 'continue' and 'return' cannot appear in an except* block
# SyntaxError: cannot have both 'except' and 'except*' on the same 'try'except*-Block sind return, break und continue verboten. Mehrere Blöcke sollen nacheinander laufen, da darf niemand mittendrin abhauen. Speichere das Ergebnis in einer Variablen und gib es außerhalb des try zurück.try kannst du except und except* nicht mischen. Brauchst du beide, teil das try in zwei Schichten auf. Die Engine hinter den Versuchsaufbauten dieses Kurses macht es genauso.Tipp der Chefin
Wenn du in except* ein return schreiben willst, schreib das Ergebnis in eine Variable und gib es außerhalb vom try zurück. In der Küche ist es genauso: Erst hörst du dir alle Unfallmeldungen an, dann machst du Feierabend. Nach der ersten Meldung die Tür zuziehen und gehen, das geht nicht.
Die zweite Regel heute Nacht lautet: „Kein Gericht überschreitet die festgelegte Zeit.“ asyncio.timeout() (3.11) legt ein Zeitlimit über einen ganzen Block.
import asyncio, time
async def stew(sec):
await asyncio.sleep(sec)
return "stew"
async def main():
start = time.perf_counter()
try:
async with asyncio.timeout(0.2):
await stew(1.0)
except TimeoutError:
print(f"timeout after {time.perf_counter() - start:.2f}s")
try:
print(await asyncio.wait_for(stew(0.1), timeout=0.2))
print(await asyncio.wait_for(stew(1.0), timeout=0.2))
except TimeoutError:
print("wait_for: timeout")
print(asyncio.TimeoutError is TimeoutError)
asyncio.run(main())
# timeout after 0.20s
# stew
# wait_for: timeout
# TrueTimeoutError an. Seit 3.11 ist asyncio.TimeoutError dasselbe wie das eingebaute TimeoutError.asyncio.wait_for(Koroutine, timeout=) ist der altbekannte Weg, eine einzelne Koroutine zu begrenzen. Praktisch, wenn du das Ergebnis direkt zurückbekommen willst.asyncio.timeout kann mehrere await zu einem Block zusammenfassen. Etwa so: „Von der Bestellung bis zum Servieren insgesamt 20 Minuten.“Das Limit kannst du statt als „in so vielen Sekunden“ auch als „um so und so viel Uhr“ setzen, und du kannst es sogar ändern, nachdem du den Block betreten hast.
import asyncio, time
async def main():
loop = asyncio.get_running_loop()
start = time.perf_counter()
deadline = loop.time() + 0.3 # der „Zeitpunkt“ 0,3 s ab jetzt
try:
async with asyncio.timeout_at(deadline):
await asyncio.sleep(1)
except TimeoutError:
print(f"timeout_at: {time.perf_counter() - start:.1f}s")
start = time.perf_counter()
try:
async with asyncio.timeout(None) as cm: # zuerst ohne Limit
cm.reschedule(loop.time() + 0.2) # später auf 0,2 s festgelegt
print(cm.when() is not None)
await asyncio.sleep(1)
except TimeoutError:
print(f"reschedule: {time.perf_counter() - start:.1f}s, expired={cm.expired()}")
asyncio.run(main())
# timeout_at: 0.3s
# True
# reschedule: 0.2s, expired=Truetimeout_at(Zeitpunkt) nimmt einen absoluten Zeitpunkt, gemessen an loop.time(). Gut, wenn sich mehrere Schritte eine einzige „Deadline“ teilen.timeout(None) beginnt ohne Limit. Mit cm.reschedule(Zeitpunkt) legst du das Limit später fest oder verschiebst es.TaskGroup und Zeitlimits machen am Ende dasselbe: Sie schicken einem Task einen Abbruch. Selbst schickst du ihn mit task.cancel().
import asyncio
async def braise():
print("stove on")
try:
await asyncio.sleep(10)
finally:
print("stove off") # auch bei Abbruch die Platte ausmachen
async def main():
task = asyncio.create_task(braise())
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("cancelled:", task.cancelled())
asyncio.run(main())
# stove on
# stove off
# cancelled: Truecancel() tötet den Task nicht auf der Stelle. Es sorgt nur dafür, dass beim nächsten await, an dem der Task wartet, ein CancelledError fliegt.try/finally. Das läuft auf jeden Fall, ob Abbruch, Erfolg oder Fehlschlag.CancelledError erbt nicht von Exception, sondern von BaseException. So ist es gedacht, damit except Exception: einen Abbruch nicht aus Versehen verschluckt.Was passiert, wenn du einen Abbruch verschluckst?
import asyncio, time
async def stubborn():
try:
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass # den Abbruch verschluckt
await asyncio.sleep(0.5) # und einfach weitergekocht
return "late"
async def main():
start = time.perf_counter()
try:
async with asyncio.timeout(0.1):
print(await stubborn())
except TimeoutError:
print("timeout")
print(f"{time.perf_counter() - start:.1f}s")
asyncio.run(main())
# late
# 0.6sWir haben ein Limit von 0,1 Sekunden gesetzt, und trotzdem dauert es 0,6 Sekunden, und ein TimeoutError gibt es auch nicht. Das Zeitlimit arbeitet mit einem Abbruch, und diesen Abbruch hat die Koroutine aufgefressen. Das „Abbrechen der übrigen“ bei TaskGroup bricht genauso in sich zusammen. Hast du ein CancelledError empfangen, räumst du also nur auf und wirfst es wieder (raise).
Tipp der Chefin
Kommt ein Abbruch, machst du die Platte aus, wischst das Brett ab und hebst die Hand. In dem Moment, in dem du denkst ‚nur noch ein bisschen, dann bin ich fertig‘ und weiterköchelst, fallen alle Küchenregeln in sich zusammen. Minho, ich rede von dir.
Manche Arbeit darf nicht mittendrin abreißen, etwa das Speichern einer Quittung. asyncio.shield() verhindert, dass ein Abbruch von außen bis zum inneren Task durchdringt.
import asyncio
async def save_receipt():
await asyncio.sleep(0.3)
print("receipt saved")
return "ok"
async def main():
inner = asyncio.create_task(save_receipt())
try:
async with asyncio.timeout(0.1):
await asyncio.shield(inner)
except TimeoutError:
print("waiter gave up")
print(await inner) # der innere Task lief einfach weiter
asyncio.run(main())
# waiter gave up
# receipt saved
# okWer gewartet hat, hat nach 0,1 Sekunden aufgegeben, aber der innere Task lief bis zum Ende. Nur wenn du die Referenz auf den inneren Task behältst, kannst du später sein Ergebnis abholen.
Task.cancelling() liefert die Zahl der Abbruchanfragen, die bei diesem Task eingegangen sind, und Task.uncancel() senkt sie um eins. Das sind Werkzeuge, mit denen asyncio.timeout und TaskGroup intern unterscheiden: „Kommt dieser Abbruch von mir?“ Selbst brauchst du sie selten.
import asyncio
async def worker():
try:
await asyncio.sleep(1)
except asyncio.CancelledError:
me = asyncio.current_task()
print("cancelling:", me.cancelling())
me.uncancel()
print("after uncancel:", me.cancelling())
return "rescued"
async def main():
t = asyncio.create_task(worker())
await asyncio.sleep(0.05)
t.cancel()
print(await t)
asyncio.run(main())
# cancelling: 1
# after uncancel: 0
# rescuedWir bringen die beiden Regeln von heute Nacht in einer Küche zusammen. Jeder Tisch bekommt ein Limit von 0,6 Sekunden, und alle sechs Tische werden in einem TaskGroup gebündelt.
import asyncio, time
async def table(n, sec, allergy, stopped):
try:
async with asyncio.timeout(0.6): # kein Gericht dauert länger als 0,6 s
await asyncio.sleep(sec)
if n == allergy:
raise ValueError(f"T{n} allergy")
return f"T{n}"
except asyncio.CancelledError:
stopped.append(f"T{n}") # Platte aus, Hand heben
raise
async def service(menu, allergy=None):
start, stopped = time.perf_counter(), []
try:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(table(n, s, allergy, stopped)) for n, s in menu.items()]
print("served:", [t.result() for t in tasks])
except* ValueError as eg:
print("cancelled by:", [str(e) for e in eg.exceptions], "stopped:", sorted(stopped))
except* TimeoutError as eg:
print("too slow:", len(eg.exceptions))
print(f"{time.perf_counter() - start:.1f}s")
MENU = {1: 0.30, 2: 0.35, 3: 0.20, 4: 0.40, 5: 0.45, 6: 0.50}
asyncio.run(service(MENU))
asyncio.run(service(MENU, allergy=3))
asyncio.run(service({**MENU, 6: 0.9}))
# served: ['T1', 'T2', 'T3', 'T4', 'T5', 'T6']
# 0.5s
# cancelled by: ['T3 allergy'] stopped: ['T1', 'T2', 'T4', 'T5', 'T6']
# 0.2s
# too slow: 1
# 0.6sTimeoutError dieses Tisches im Bündel nach oben, und auch dann stoppt der Rest.Gegen Küchenschluss leert die Frau am Fenster ihre Nudelschüssel und klappt ihr kleines Notizbuch zu. Beim Bezahlen sagt sie kurz zur Chefin: „Nächsten Freitag komme ich wieder.“
Die Chefin nickt und dreht sich zu dir um. „Abbruch und Zeitlimit passieren am Ende immer an einem await. Aber der alte Ofen in der Ecke kennt kein await. Was wird aus dem Gastraum, wenn Lou davor steht und wartet?“
Schichtbuch — Zehnte Nacht. Die sechs Tische der Gruppe in einem TaskGroup gebündelt; als Tisch 3 ausfiel, gingen alle anderen Töpfe gleichzeitig aus. Jeder Tisch bekam ein Zeitlimit, und die Regel „Abbrüche nicht verschlucken“ hängt jetzt an der Küchenwand.
gather-Beispiel aus 10-1 auf TaskGroup um, sodass Tisch 1 und Tisch 3 cancelled ausgeben, wenn Tisch 2 scheitert.table aus dem Küchenschluss-Code das raise, setz an seine Stelle await asyncio.sleep(0.3) (Abbruch ignorieren und weiterköcheln) und lass es mit allergy=3 laufen. Schau, wie sich die Laufzeit ändert, und schreib auf, was schiefgegangen ist.Rezeptkarte der Chefin
gather stoppt den Rest nicht, wenn einer scheitert. Was zusammen enden muss, kommt in einen TaskGroup (3.11).
async with asyncio.TaskGroup() as tg: tg.create_task(...) — nach dem Block ist alles fertig; scheitert einer, wird der Rest abgebrochen.
Fehlschläge kommen gesammelt als ExceptionGroup. Mit except* ValueError nach Art abfangen (kein return, nicht mit except mischen).
Zeitlimit: async with asyncio.timeout(Sekunden) oder wait_for(Koroutine, timeout=). Wird es überschritten, kommt das eingebaute TimeoutError.
Ein Abbruch kommt als CancelledError am await an. Mit finally aufräumen, den empfangenen Abbruch wieder mit raise werfen.
Was nicht abreißen darf: asyncio.shield(task). cancelling()/uncancel() sind Werkzeuge für alle, die selbst Werkzeuge bauen.
← Zurück
Lektion 9 · Eine Servicechefin für alle Tische: erste Schritte mit asyncio
Weiter →
Lektion 11 · Der Gastraum steht still: blockierende Aufrufe und Kombinieren
Ü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.