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
Der Bon für heute Nacht
#05 · Fünfte Nacht
Problem
Minho hält das Messer und wartet aufs Brett, Sua hält das Brett und wartet aufs Messer. Keiner denkt daran, zuerst loszulassen. Die Küche steht still.
Ziel
Drei Köche, die sich Messer und Brett teilen, kochen je 100-mal, ohne ein einziges Mal hängen zu bleiben.
Nachtlokal Dongsi
Fünfte Nacht, elf Uhr. In der Küche ist es merkwürdig still. Minho hält das Messer in der Hand und starrt aufs Schneidebrett, Sua drückt das Brett an sich und starrt aufs Messer. Beide sagen immer nur: „Ich bin gleich fertig, dann kriegst du's.“
Herr Tak hört auf, Lauchzwiebeln zu schneiden, und murmelt: „Wenn beide nur rumstehen, wird keiner fertig.“
Chefin Yoon Seoyeon hält den Timer an ihrem Hals an. „Legt beide ab. Das ist nicht passiert, weil jemand zu langsam war. Das ist passiert, weil es keine Regel gibt.“
Wie die Herdplatten-Regel von gestern Nacht sind auch Messer und Brett Locks, die immer nur eine Person gleichzeitig benutzen kann. Minho greift in der Reihenfolge Messer → Brett zu, Sua in der Reihenfolge Brett → Messer.
import threading
import time
knife = threading.Lock()
board = threading.Lock()
def minho():
with knife: # nimmt das Messer
time.sleep(0.1)
with board: # wartet aufs Brett
print("minho done")
def sua():
with board: # nimmt das Brett
time.sleep(0.1)
with knife: # wartet aufs Messer
print("sua done")
a = threading.Thread(target=minho, daemon=True)
b = threading.Thread(target=sua, daemon=True)
a.start(); b.start()
a.join(timeout=1); b.join(timeout=1)
print("alive:", a.is_alive(), b.is_alive())
# alive: True Truejoin(timeout=1) wartet höchstens eine Sekunde und kehrt dann zurück. Weil die Threads mit daemon=True gestartet wurden, endet das Programm trotzdem ganz normal. Wenn du selbst mit Deadlocks experimentierst, bau dir immer so einen Notausgang ein.time.sleep(0.1) sorgt dafür, dass beide ihr erstes Werkzeug fast gleichzeitig greifen. In echten Programmen entsteht diese Lücke nur sehr selten, und genau das macht den Deadlock zu einem besonders fiesen Bug, der „nur manchmal“ auftritt.Damit das Programm selbst merkt, dass es feststeckt, nimmst du acquire(timeout=...). Es liefert True, wenn es den Lock bekommt, und False, wenn es ihn in der Zeit nicht bekommt. Bei with lock: kannst du kein Zeitlimit angeben, also rufst du hier acquire und release selbst auf.
knife = threading.Lock() # neues Messer, neues Brett
board = threading.Lock()
def cook(name, first, second, patience):
with first:
time.sleep(0.1)
if second.acquire(timeout=patience): # höchstens patience Sekunden warten
second.release()
print(name, "done")
else:
print(name, f"gave up after {patience}s")
a = threading.Thread(target=cook, args=("minho", knife, board, 1.0))
b = threading.Thread(target=cook, args=("sua", board, knife, 0.5))
a.start(); b.start()
a.join(); b.join()
# sua gave up after 0.5s
# minho doneSua gibt nach 0,5 Sekunden auf und legt das Brett ab. Minho, der noch wartet, schnappt sich das Brett und kocht fertig. Es reicht, wenn einer nachgibt, und der Deadlock löst sich. Warum das so ist, erklären die vier Bedingungen im nächsten Abschnitt.
Ein Deadlock entsteht nur, wenn die folgenden vier Bedingungen alle gleichzeitig erfüllt sind. Umgekehrt heißt das: Brichst du nur eine davon, kann kein Deadlock entstehen.
| Bedingung | Bedeutung | Heute Nacht in der Küche | So brichst du sie |
|---|---|---|---|
| Gegenseitiger Ausschluss | Eine Ressource kann immer nur einer benutzen | Das Messer liegt nur in einer Hand | Mehr Messer kaufen oder so planen, dass nichts geteilt werden muss |
| Halten und Warten | Man hält eines fest und wartet auf ein anderes | Mit dem Messer in der Hand aufs Brett warten | Beides auf einmal greifen oder ablegen, wenn man es nicht bekommt |
| Keine Verdrängung | Was ein anderer hält, kann man ihm nicht wegnehmen | Minho kann man das Messer nicht aus der Hand reißen | Mit einem Zeitlimit dafür sorgen, dass man selbst ablegt (Lösung 2) |
| Zyklisches Warten | Das Warten bildet einen Kreis | Minho → Sua → Minho | Alle greifen in derselben Reihenfolge zu (Lösung 1) |
Tipp der Chefin
Ein Deadlock frisst keine CPU. Er wirft keinen Fehler. Er schreibt nichts ins Log. Er steht einfach still da. Wenn also jemand meldet ‚Es ist langsam geworden', tippe ich als Allererstes auf einen Deadlock.
Die Chefin schreibt eine Regel an die Tafel: „Jedes Werkzeug bekommt eine Nummer. Messer 1, Brett 2. Gegriffen wird immer mit der kleineren Nummer zuerst.“
Greifen Minho und Sua beide zuerst nach dem Messer, wartet nur derjenige aufs Brett, der das Messer schon hat. Wer das Messer nicht bekommen hat, wartet mit leeren Händen, also kann kein Kreis entstehen. Verlassen wir uns bei dieser Regel nicht auf das Gedächtnis der Leute, sondern packen sie in eine Funktion.
from contextlib import contextmanager
KNIFE = (1, threading.Lock()) # (Nummer, Lock)
BOARD = (2, threading.Lock())
@contextmanager
def hold(*tools):
ordered = sorted(tools, key=lambda t: t[0]) # kleinste Nummer zuerst
for _, lock in ordered:
lock.acquire()
try:
yield
finally:
for _, lock in reversed(ordered): # beim Loslassen umgekehrt
lock.release()
done = []
def minho():
for _ in range(100):
with hold(KNIFE, BOARD):
time.sleep(0)
done.append("minho")
def sua():
for _ in range(100):
with hold(BOARD, KNIFE): # anders hingeschrieben, aber gleich gegriffen
time.sleep(0)
done.append("sua")
ts = [threading.Thread(target=f, daemon=True) for f in (minho, sua)]
for t in ts: t.start()
for t in ts: t.join(timeout=2)
print(sorted(done), [t.is_alive() for t in ts])
# ['minho', 'sua'] [False, False]hold(BOARD, KNIFE) schreibt: hold sortiert nach Nummer, also wird tatsächlich in der Reihenfolge Messer → Brett gegriffen.time.sleep(0) nichts, das jedes Mal Gelegenheit gibt, den Platz zu wechseln.id(lock). Entscheidend ist, dass der gesamte Code dasselbe Kriterium verwendet.Sobald irgendwo mehr als ein Lock gebraucht wird, schreib diese Regel in einen Kommentar ganz oben im Code oder in die Doku. In dem Moment, in dem jemand Neues die Reihenfolge nicht kennt und andersherum greift, ist der Deadlock wieder da.
Manchmal lässt sich keine Reihenfolge festlegen, nämlich dann, wenn man vorher nicht weiß, welche Locks man brauchen wird. Dann gilt die Regel: „Wenn ich es nach einer gewissen Zeit nicht bekomme, lege ich auch das ab, was ich schon halte, und fange von vorne an.“
import random
tries = {}
def polite_cook(name, first, second):
tries[name] = 0
while True:
tries[name] += 1
with first:
time.sleep(0.05)
if second.acquire(timeout=0.1): # wenn nicht innerhalb von 0,1 s
try:
return # beides gegriffen: Gericht fertig
finally:
second.release()
# beim Verlassen von with wurde first abgelegt
time.sleep(random.uniform(0, 0.1)) # zufällig kurz warten, dann neu versuchen
a = threading.Thread(target=polite_cook, args=("minho", knife, board))
b = threading.Thread(target=polite_cook, args=("sua", board, knife))
a.start(); b.start()
a.join(); b.join()
print(tries)
# z. B.: {'minho': 2, 'sua': 3} (bei jedem Lauf anders)Nach ein paar Zusammenstößen werden am Ende beide fertig. Nimmst du hier random.uniform(0, 0.1) heraus, kann ein neues Problem auftauchen: der Livelock.
| Moment | Minho | Sua |
|---|---|---|
| 1 | nimmt das Messer | nimmt das Brett |
| 2 | kein Brett → legt das Messer ab | kein Messer → legt das Brett ab |
| 3 | wartet genau 0,1 s und nimmt wieder das Messer | wartet genau 0,1 s und nimmt wieder das Brett |
| … | 1–3 endlos von vorn | 1–3 endlos von vorn |
Das ist wie bei zwei Leuten, die sich im Flur begegnen und immer wieder zur selben Seite ausweichen. Anders als beim Deadlock sind beide eifrig in Bewegung, aber keiner kocht je ein Gericht fertig. Mischst du eine zufällige Pause hinein, bewegt sich irgendwann einer zuerst, und der Kreislauf bricht.
Bei echten Threads schwankt der Zeitpunkt der Ausführung immer ein wenig, deshalb lässt sich ein Livelock mit einem kurzen Beispiel kaum zuverlässig nachstellen. Genau das macht ihn gefährlicher: Er zeigt sich nur an Tagen mit hoher Last. Wenn du Lösung 2 verwendest, bau unbedingt die zufällige Pause mit ein.
In der Informatik gibt es ein berühmtes Problem: das Philosophenproblem (die speisenden Philosophen). Fünf Personen sitzen an einem runden Tisch, zwischen je zweien liegt eine Gabel. Wer essen will, braucht beide Gabeln links und rechts. In die Küche übertragen: fünf Köche und fünf Messer an einer runden Arbeitsplatte.
N = 5
def service(ordered):
knives = [threading.Lock() for _ in range(N)] # fünf Messer
meals = [0] * N
def cook(i):
left, right = i, (i + 1) % N
if ordered:
first, second = min(left, right), max(left, right)
else:
first, second = left, right
for _ in range(3):
with knives[first]:
time.sleep(0.05) # kurz mit dem ersten Messer in der Hand
with knives[second]:
meals[i] += 1
ts = [threading.Thread(target=cook, args=(i,), daemon=True) for i in range(N)]
for t in ts: t.start()
for t in ts: t.join(timeout=1)
return sum(t.is_alive() for t in ts), meals
print("left first:", service(ordered=False))
print("small first:", service(ordered=True))
# left first: (5, [0, 0, 0, 0, 0])
# small first: (0, [3, 3, 3, 3, 3])Der Versuchsaufbau unten zeigt diese Situation Schritt für Schritt. Würden wir echte Threads auf dem Server in einen Deadlock schicken, kämen sie nie wieder heraus, deshalb läuft ausgerechnet dieser Aufbau als Simulation. Die Zahl über einem Messer ist die Nummer des Kochs, der es gerade hält.
Messer und Bretter
1
2
3
4
5
·
·
·
·
·
Schritte 0
Fertige Gerichte: 1:0 2:0 3:0 4:0 5:0
Wenn ein Programm hängt und du nicht weißt, wo, hilft dir faulthandler aus der Standardbibliothek. Es gibt aus, in welcher Zeile jeder Thread gerade steht. Speichere den Code unten als stuck.py und führ ihn aus.
import faulthandler
import threading
import time
knife = threading.Lock()
board = threading.Lock()
def minho():
with knife:
time.sleep(0.1)
with board:
pass
def sua():
with board:
time.sleep(0.1)
with knife:
pass
faulthandler.dump_traceback_later(1) # steht es nach 1 s noch hier, alle Threads ausgeben
crew = [threading.Thread(target=f, name=f.__name__, daemon=True) for f in (minho, sua)]
for t in crew: t.start()
for t in crew: t.join(timeout=2)
faulthandler.cancel_dump_traceback_later() # rechtzeitig fertig? dann Auftrag löschenTimeout (0:00:01)!
Thread 0x00009560 [sua] (most recent call first):
File "stuck.py", line 17 in sua
...
Thread 0x0000a670 [minho] (most recent call first):
File "stuck.py", line 11 in minho
...Die Ausgabe ist gekürzt (Adressen und Pfade sind bei jedem Lauf anders). Sua hängt in Zeile 17 bei with knife:, Minho in Zeile 11 bei with board:. Auf einen Blick siehst du, wer auf was wartet.
faulthandler.dump_traceback_later(timeout): plant ein, nach timeout Sekunden die Position aller Threads auf die Standardfehlerausgabe zu schreiben. Mit repeat=True wiederholt es sich, mit exit=True beendet es nach der Ausgabe das Programm.faulthandler.dump_traceback(all_threads=True): gibt ohne Planung sofort aus. Praktisch, um mitten in einer langen Aufgabe den Zustand festzuhalten.dump_traceback_later an den Anfang des Testlaufs. Dann bleibt an dem Tag, an dem es passiert, der Täter zurück.Tipp der Chefin
Pass auf, wenn du mit einem Lock in der Hand eine andere Funktion aufrufst. Du weißt doch nicht, welchen Lock die da drin noch greift. Im Lock machst du nur Kurzes, Offensichtliches, so wie eine Zahl an die Tafel schreiben. Das kommt noch vor jeder Reihenfolge-Regel.
Das Ziel für heute Nacht war, dass drei Köche, die sich Messer und Brett teilen, je 100-mal kochen, ohne ein einziges Mal hängen zu bleiben. Herr Tak braucht sogar drei Werkzeuge, inklusive Pfanne. Gibst du jedem Werkzeug eine Nummer und lässt alle über hold greifen, ist es egal, wer was in welcher Reihenfolge hinschreibt.
PAN = (3, threading.Lock())
meals = {}
def cook(name, *tools):
for _ in range(100):
with hold(*tools): # immer das Werkzeug mit der kleineren Nummer zuerst
time.sleep(0)
meals[name] = meals.get(name, 0) + 1
crew = [threading.Thread(target=cook, args=a, daemon=True) for a in [
("minho", KNIFE, BOARD), ("sua", BOARD, KNIFE), ("tak", PAN, BOARD, KNIFE)]]
for t in crew: t.start()
for t in crew: t.join(timeout=3)
print([meals[n] for n in ("minho", "sua", "tak")], sum(t.is_alive() for t in crew))
# [100, 100, 100] 0Alle drei je 100-mal, hängen geblieben ist niemand. Die Chefin schreibt neben die Regel an der Tafel noch eine Zeile: „Messer 1, Brett 2, Pfanne 3. Kleinere Nummer zuerst.“
„Heute standen wir wegen der Werkzeuge still. Morgen stehen wir wegen der Leute still. Wenn die Bestellungen nur so reinprasseln: Wer entscheidet dann, wer welche Bestellung macht?“
Schichtbuch — Fünfte Nacht. Messer und Brett wurden in entgegengesetzter Reihenfolge gegriffen, die Küche stand still. Seit die Werkzeuge Nummern haben und die kleinere zuerst gegriffen wird, kochten alle drei je 100-mal ohne Stillstand.
time.sleep(0.1) und führ es mehrmals aus. Gibt es jedes Mal einen Deadlock? Schreib in einem Satz auf, warum ein „Bug, der nur manchmal hängt“ gefährlicher ist.print, dass in service aus 5-5 bei ordered=True nur Koch 4 in einer anderen Reihenfolge greift. Erklär mit den vier Bedingungen, warum sich der Deadlock schon löst, wenn nur ein einziger Koch seine Reihenfolge ändert.Rezeptkarte der Chefin
Die vier Bedingungen für einen Deadlock: gegenseitiger Ausschluss, Halten und Warten, keine Verdrängung, zyklisches Warten. Eine davon brechen reicht.
Lösung 1: Locks nummerieren, alle greifen mit der kleineren Nummer zuerst. Mit sorted(tools) in eine Funktion packen.
Lösung 2: if not lock.acquire(timeout=0.1): Gehaltenes ablegen, zufällig kurz warten, neu versuchen.
Livelock: Alle weichen im selben Takt aus, und keiner wird fertig. Mit zufälligen Pausen aufbrechen.
Beim Experimentieren mit daemon=True + join(timeout=1) + is_alive() einen Notausgang einbauen.
Wo hängt es? faulthandler.dump_traceback_later(1), dump_traceback(all_threads=True).
← Zurück
Lektion 4 · Nur drei Herdplatten: Synchronisationswerkzeuge
Weiter →
Lektion 6 · Die Bonschiene: queue
Ü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.