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 1:40–2:30
Der Bon für heute Nacht
#03 · Dritte Nacht
Problem
Vier Köche haben je 1000 Gerichte gemacht und nach jedem Gericht 1 zur Zahl auf der Küchentafel addiert. Auf der Tafel steht aber nicht 4000, sondern 1086.
Ziel
Die Zahl auf der Tafel muss stimmen, ganz egal, wie viele Köche es sind. Und die Küche darf dabei nicht langsamer werden.
Nachtlokal Dongsi
Dritte Nacht, zwei Uhr früh, der Service ist vorbei. Heute hat ein Freund von Herrn Tak einen Tag lang ausgeholfen, es waren also vier Köche. Die Chefin hat eine Regel aufgestellt: „Für jede Schüssel, die rausgeht, zählt ihr auf der Tafel 1 dazu.“
Laut Bons hat jeder der vier 1000 Schüsseln rausgegeben, zusammen 4000. Auf der Tafel steht aber 1086.
Minho kratzt sich am Kopf. „Ich hab echt jedes Mal dazugezählt!“ Sua und Herr Tak sagen dasselbe. Die Chefin nimmt die Kreide in die Hand. „Gelogen hat keiner. Ihr habt nur alle vier gleichzeitig vor der Tafel gestanden.“
Auf der Tafel 1 dazuzuzählen sind eigentlich drei Schritte: die Tafel lesen, im Kopf 1 addieren, auf die Tafel schreiben. Übertragen wir diese drei Schritte eins zu eins in Code.
import threading
import time
board = 0 # die Zahl auf der Tafel
def cook(times):
global board
for _ in range(times):
seen = board # 1. die Tafel lesen
time.sleep(0) # 2. kurz abgelenkt sein (eine Lücke, in der ein anderer Koch drankommt)
board = seen + 1 # 3. den gelesenen Wert plus 1 hinschreiben
threads = [threading.Thread(target=cook, args=(1000,)) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
print(board)
# z. B. 1086 (bei jedem Lauf anders; 4000 kommt fast nie heraus)time.sleep(0) heißt „0 Sekunden schlafen“, hat aber den Effekt, dass der Thread seinen Platz gerade an einen anderen Thread abgibt. Wir bauen damit absichtlich eine Lücke zwischen Lesen und Schreiben, in die sich ein anderer Koch drängen kann.sleep, sondern ganz normaler Code: eine Logausgabe, eine Datenbankabfrage, ein Funktionsaufruf. Irgendwo gibt es immer eine Lücke.Wenn das Ergebnis davon abhängt, in welcher Reihenfolge mehrere Threads denselben Wert lesen und überschreiben, nennt man das eine Race Condition (Wettlaufsituation). Probier es im Tafel-Experiment unten selbst aus. Die Köche machen genau wie im Code oben „lesen → time.sleep(0) → schreiben“.
Tafel-Experiment
Köche
4
Mal pro Koch
1000
Lock verwenden
Angenommen, auf der Tafel steht 41, und Minho und Sua geben fast gleichzeitig je eine Schüssel raus. Zerlegt man die Zeit in einzelne Takte, sieht das so aus:
| Takt | Minho | Sua | Tafel |
|---|---|---|---|
| 1 | liest die Tafel: 41 | 41 | |
| 2 | (ist abgelenkt) | liest die Tafel: 41 | 41 |
| 3 | schreibt 41 + 1 = 42 | 42 | |
| 4 | schreibt 41 + 1 = 42 | 42 |
Zwei Leute haben je eine Schüssel rausgegeben, also zwei insgesamt; auf der Tafel müsste 43 stehen. Es steht aber 42 da. Sua hat den Wert gelesen, bevor Minho ihn geändert hat, und Minhos 42 mit ihrer eigenen 42 überschrieben. Minhos Schüssel ist verschwunden.
Die Lösung ist einfach. Vor der Tafel darf immer nur eine Person stehen. Es gibt nur ein einziges Stück Kreide, und nur wer die Kreide in der Hand hat, darf an die Tafel schreiben. Die Kreide in Python heißt threading.Lock.
board = 0
chalk = threading.Lock() # es gibt nur ein Stück Kreide
def cook(times):
global board
for _ in range(times):
with chalk: # nur wer die Kreide hält, steht vor der Tafel
seen = board
time.sleep(0)
board = seen + 1
threads = [threading.Thread(target=cook, args=(1000,)) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
print(board)
# 4000with chalk: wird der Lock genommen (acquire), beim Verlassen wieder freigegeben (release). Hält ihn schon ein anderer Thread, wartet man an dieser Stelle, bis er frei wird.time.sleep(0) seinen Platz abgibt. Ein anderer Koch kommt zwar dran, hat aber keine Kreide und kann die Tafel nicht anrühren.Du kannst den Lock auch ohne with selbst nehmen und freigeben. Dann gehört das unbedingt in ein try/finally, damit die Kreide auch bei einer Ausnahme wieder hingelegt wird.
chalk = threading.Lock()
print(chalk.locked())
chalk.acquire() # die Kreide nehmen
try:
print(chalk.locked())
print(chalk.acquire(blocking=False)) # hält sie schon jemand: sofort False
print(chalk.acquire(timeout=0.1)) # 0,1 Sekunden warten, sonst False
finally:
chalk.release() # komme, was wolle: hinlegen
print(chalk.locked())
chalk.release()
# False
# True
# False
# False
# False
# RuntimeError: release unlocked lock| Methode | Was sie tut |
|---|---|
acquire() | nimmt den Lock. Hält ihn jemand, wird gewartet, bis er frei ist. Bei Erfolg True |
acquire(blocking=False) | wartet nicht. Klappt es nicht, sofort False |
acquire(timeout=0.1) | wartet höchstens 0,1 Sekunden. Klappt es nicht, False |
release() | gibt den Lock frei. Ist er gar nicht gesperrt, gibt es einen RuntimeError |
locked() | ob er gerade gesperrt ist |
Ein Lock lässt andere warten. Also sollte er nur die Zeilen umschließen, die es wirklich brauchen. Was passiert, wenn man auch beim Kochen die Kreide in der Hand behält?
board = 0
chalk = threading.Lock()
def cook_wide():
global board
with chalk:
time.sleep(0.1) # auch beim Kochen die Kreide in der Hand
board += 1
def cook_narrow():
global board
time.sleep(0.1) # gekocht wird jeder für sich
with chalk:
board += 1 # gesperrt wird nur vor der Tafel
for fn in (cook_wide, cook_narrow):
board = 0
threads = [threading.Thread(target=fn) for _ in range(4)]
start = time.perf_counter()
for th in threads:
th.start()
for th in threads:
th.join()
print(fn.__name__, board, f"{time.perf_counter() - start:.2f}s")
# cook_wide 4 0.40s
# cook_narrow 4 0.10sIn beiden Fällen stimmt die Zahl: genau 4. Aber bei cook_wide stehen die anderen Schlange, bis die Person mit der Kreide fertig gekocht hat, und so dauert es trotz vier Köchen 0,4 Sekunden, genauso lange wie allein. Sperre nur die Zeilen, die den gemeinsamen Wert anfassen.
Tipp der Chefin
Ein Lock heißt: eine Tafel, ein Stück Kreide. Wenn jeder seine eigene Kreide nimmt oder einer ganz ohne Kreide schreibt, bringt das nichts. Jeder Code, der denselben Wert anfasst, muss durch denselben Lock. Fehlt auch nur einer, läuft die Zahl durch genau dieses Loch aus.
Python (genauer gesagt CPython) hat einen großen Lock namens GIL (Global Interpreter Lock). Die Regel: Python-Code führt immer nur ein Thread gleichzeitig aus. Ausführlich geht es darum in Lektion 8, aber hier entsteht oft ein Missverständnis: „Wenn immer nur einer läuft, gibt es doch auch keine Race Conditions?“
Doch. Der GIL wechselt Threads nur zwischen einzelnen Bytecode-Anweisungen; dein „lesen → rechnen → schreiben“ schützt er nicht als Block. Selbst die eine Zeile board += 1 besteht, wenn man sie aufmacht, aus mehreren Schritten.
import dis
count = 0
def add():
global count
count += 1
dis.dis(add)
# Ausschnitt der Ausgabe (3.14):
# LOAD_GLOBAL 0 (count) ← lesen
# LOAD_SMALL_INT 1
# BINARY_OP 13 (+=) ← rechnen
# STORE_GLOBAL 0 (count) ← schreibenWechselt der Thread zwischen Lesen und Schreiben, passiert genau das aus der Tabelle in 3-2. Wann gewechselt wird, entscheidet der Interpreter; den Takt dafür kannst du mit sys.getswitchinterval() ansehen.
import sys
print(sys.getswitchinterval())
# 0.005Der Standardwert ist 0,005 Sekunden. Selbst wenn ein Thread ununterbrochen läuft, wird er ungefähr in diesem Abstand aufgefordert, an einen anderen Thread abzugeben. Trifft er auf Warten wie time.sleep oder I/O, gibt er schon früher ab.
Was passiert, wenn wir ohne time.sleep(0) einfach 4 Millionen Mal count += 1 machen?
count = 0
def cook(times):
global count
for _ in range(times):
count += 1
threads = [threading.Thread(target=cook, args=(1_000_000,)) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
print(count, sys._is_gil_enabled())
# Python 3.14: 4000000 True
# Python 3.14t: z. B. 1127695 False (bei jedem Lauf anders)+= atomar (als einen unteilbaren Block) auszuführen. Das kann mit einer neuen Version kaputtgehen oder schon, wenn in der Schleife eine Zeile dazukommt.Lock ist es egal, wer ihn genommen hat. Wenn also jemand, der die Kreide schon hält, noch einmal nach der Kreide greift, wartet er darauf, dass er sie selbst hinlegt, und bleibt für immer stehen. Für immer stehen bleiben wäre ungünstig, also warten wir nur 0,5 Sekunden.
def probe(name, lock):
with lock: # einmal sperren
start = time.perf_counter()
ok = lock.acquire(timeout=0.5) # derselbe Thread noch einmal
waited = time.perf_counter() - start
print(name, ok, f"{waited:.1f}s")
if ok:
lock.release()
probe("Lock", threading.Lock())
probe("RLock", threading.RLock())
# Lock False 0.5s
# RLock True 0.0sLock wartet die vollen 0,5 Sekunden und liefert False. Ohne timeout wäre dieser Thread hier für immer stehen geblieben.RLock (reentrant lock, wiedereintrittsfähiger Lock) merkt sich, welcher Thread ihn hält. Derselbe Thread darf ihn beliebig oft erneut nehmen und muss ihn genauso oft freigeben, bis er wirklich frei ist. Andere Threads warten wie gewohnt.Das passiert oft, wenn eine Methode, die sperrt, eine andere Methode aufruft, die ebenfalls sperrt.
class Board:
def __init__(self):
self.lock = threading.RLock()
self.count = 0
def add(self):
with self.lock:
self.count += 1
def add_table(self, dishes):
with self.lock: # einen ganzen Tisch am Stück
for _ in range(dishes):
self.add() # darin wird derselbe Lock noch einmal genommen
board = Board()
threads = [threading.Thread(target=board.add_table, args=(250,)) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
print(board.count)
# 1000Wäre self.lock ein Lock, bliebe add_table in dem Moment stehen, in dem es add aufruft. RLock ist etwas schwerer und macht es manchmal unübersichtlich, wie viele Schichten Lock gerade aktiv sind. Eine gute Alternative ist, das Design umzubauen und eine eigene „interne Methode ohne Lock“ (_add) einzuführen.
Das Ziel für heute Nacht war: Die Zahl auf der Tafel stimmt, egal wie viele Köche es sind, und die Küche wird nicht langsamer. Wir machen aus der Tafel eine Klasse und sperren nur die Zeile, die die Tafel ändert. Gekocht (time.sleep) wird von jedem für sich außerhalb des Locks.
class Chalkboard:
def __init__(self):
self._lock = threading.Lock()
self.count = 0
def add(self, n=1):
with self._lock: # nur vor der Tafel
self.count += n
board = Chalkboard()
def cook(dishes):
for _ in range(dishes):
time.sleep(0.001) # gekocht wird außerhalb des Locks
board.add()
threads = [threading.Thread(target=cook, args=(100,)) for _ in range(4)]
start = time.perf_counter()
for th in threads:
th.start()
for th in threads:
th.join()
print(board.count, f"{time.perf_counter() - start:.2f}s")
# z. B. 400 0.15s (die Zahl ist immer 400, die Zeit nur ungefähr)count) zu ändern, ist add(). Wenn der Lock in der Klasse versteckt ist, passiert es seltener, dass jemand aus Versehen ohne Kreide schreibt.Die Chefin wischt die Tafel ab und schreibt die 400 neu hin. „Vor der Tafel reicht einer nach dem anderen. Aber morgen haben wir drei Herdplatten und sechs Köche. Lassen wir immer nur einen ran, bleiben zwei Platten kalt. Gibt es keine Kreide, die bis zu drei Leute ranlässt?“
Schichtbuch — Dritte Nacht. Vorfall: Die 4000 auf der Tafel sind auf 1086 geschrumpft. Ursache: andere Köche, die sich zwischen Lesen und Schreiben gedrängt haben. Gelöst mit nur einem Stück Kreide (Lock) an der Tafel; gesperrt wird nur die Zeile, die die Tafel ändert.
time.sleep(0) und lass ihn noch einmal laufen. Erkläre mit Hilfe von 3-4, warum man ihn nicht für sicher halten darf, selbst wenn die Zahl stimmt.Board aus 3-5 auf Lock um und führe eine interne Methode _add() ohne Lock ein, sodass weder add() noch add_table() stehen bleibt.Rezeptkarte der Chefin
Race Condition: Zwischen lesen → rechnen → schreiben drängt sich ein anderer Thread, und Werte gehen verloren. Das Ergebnis ist bei jedem Lauf anders.
lock = threading.Lock() / with lock: nur die Zeilen umschließen, die den gemeinsamen Wert ändern. Von Hand: acquire() … finally: release()
acquire(blocking=False) und acquire(timeout=sekunden) liefern False, wenn es nicht klappt. Zustand prüfen mit locked().
RLock: Derselbe Thread darf erneut sperren. Wer einen Lock zweimal nimmt, blockiert sich selbst.
Auch mit GIL ist count += 1 nicht atomar. Unter 3.14t liegt es noch weiter daneben. Gemeinsame Werte immer mit Lock.
sys.getswitchinterval() = 0.005: ungefähr in diesem Takt kann der Thread wechseln.
← Zurück
Lektion 2 · Mehr Köche einstellen: Grundlagen von threading
Weiter →
Lektion 4 · Nur drei Herdplatten: Synchronisationswerkzeuge
Ü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.