R

ReflexKR

Reflex-Dokumentation

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

Nebenläufigkeit in der Nachtküche
Lektion 3

Zeit 1:40–2:30

Die Zahl auf der Tafel stimmt nicht: Race Conditions und Lock

Der Bon für heute Nacht

#03 · Dritte Nacht

Die Zahl auf der Tafel stimmt nicht

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

3-1. Aus 4000 wird 1086 (8 Min.)

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.
  • In echten Programmen ist diese Lücke kein sleep, sondern ganz normaler Code: eine Logausgabe, eine Datenbankabfrage, ein Funktionsaufruf. Irgendwo gibt es immer eine Lücke.
  • Die Zahl ist bei jedem Lauf anders. Unter Python 3.14 auf diesem PC kam ungefähr etwas zwischen 1000 und 1200 heraus.

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

4 Köche zählen je 1000-mal 1 auf der Tafel dazu. Lass sie mit ausgeschaltetem „Lock verwenden“ ein paarmal schreiben, dann schalte es ein und lass sie noch einmal schreiben. Achte darauf, ob die verlorenen Bestellungen auf 0 fallen.
  • Ohne Lock liegt die tatsächliche Zahl weit unter dem Sollwert und ist bei jedem Durchgang anders. Das Feld „Verlorene Bestellungen“ zeigt die Differenz.
  • Mit nur 1 Koch stimmt die Zahl auch ohne Lock exakt. Race Conditions entstehen nur, wenn zwei oder mehr denselben Wert anfassen.
  • Mit Lock stimmt sie mit dem Sollwert überein, egal wie viele Köche und wie viele Durchgänge. Was dieser Lock ist, steht in 3-3.

3-2. Eine Race Condition Schritt für Schritt (8 Min.)

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:

TaktMinhoSuaTafel
1liest die Tafel: 4141
2(ist abgelenkt)liest die Tafel: 4141
3schreibt 41 + 1 = 4242
4schreibt 41 + 1 = 4242

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.

  • Das Problem ist, dass sich zwischen „lesen → rechnen → schreiben“ ein anderer Thread drängt. Niemand garantiert, dass die drei Schritte als ein Block ausgeführt werden.
  • Lesen vier Leute gleichzeitig, können auf einen Schlag drei Schüsseln verschwinden. Lesen alle vier 41 und schreiben alle vier 42, schrumpfen vier Schüsseln auf eine. Darum fällt 4000 bis auf 1086.
  • Passt die Reihenfolge zufällig, stimmt die Zahl manchmal. Deshalb gehören Race Conditions zu den am schwersten zu findenden Bugs: Im Test geht alles gut, und erst in einer vollen Nacht knallt es.

3-3. Es gibt nur ein Stück Kreide: Lock (10 Min.)

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)
# 4000
  • Beim Betreten des Blocks with 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.
  • Es macht nichts, wenn der Thread im Block mit time.sleep(0) seinen Platz abgibt. Ein anderer Koch kommt zwar dran, hat aber keine Kreide und kann die Tafel nicht anrühren.
  • Egal wie oft du es laufen lässt: 4000. Das „lesen → rechnen → schreiben“ im Lock ist zu einem Block geworden, in den sich kein anderer Thread drängen kann.

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
MethodeWas 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

Den Lock-Bereich kurz halten: nur vor der Tafel

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.10s

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

3-4. Auch mit GIL gibt es Race Conditions (8 Min.)

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)     ← schreiben

Wechselt 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.005

Der 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)
  • Unter 3.14 (mit GIL) kam auf diesem PC auch nach mehreren Läufen 4000000 heraus. Aber nur, weil der Threadwechsel zufällig nie zwischen diese vier Schritte fiel. Python hat nie versprochen, += atomar (als einen unteilbaren Block) auszuführen. Das kann mit einer neuen Version kaputtgehen oder schon, wenn in der Schleife eine Zeile dazukommt.
  • Im Free-Threading-Build 3.14t ohne GIL laufen die Threads wirklich gleichzeitig, und von 4 Millionen bleibt nur gut eine Million übrig. Code, der im GIL-Build zufällig stimmte, ist unter 3.14t sofort falsch.
  • Der Tafel-Code aus 3-1 lieferte unter 3.14t von 4000 zum Beispiel 2410. Andere Zahl, aber genauso falsch.
  • Die Schlussfolgerung ist eindeutig: Wenn mehrere Threads denselben Wert überschreiben, gehört ein Lock drumherum, mit oder ohne GIL. Erst recht bei Code, der auf 3.14t umziehen soll.

3-5. Wenn dieselbe Person noch einmal sperrt: RLock (6 Min.)

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.0s
  • Lock 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)
# 1000

Wä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.

Küchenschluss (10 Min.)

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)
  • Vier Köche zu je 100 Schüsseln, und auf der Tafel steht immer 400. Auch unter 3.14t: 400.
  • Es dauert nur wenig länger, als eine Person für 100 Schüsseln braucht (auf diesem PC etwa 0,11 Sekunden). Die vier kochen gemeinsam und stehen nur vor der Tafel kurz Schlange.
  • Der einzige Weg, die Tafel (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.

Übungen

  1. Schalte im Tafel-Experiment den Lock aus und vergleiche 2 Köche × 2000-mal mit 8 Köchen × 500-mal. Insgesamt sind es beide Male 4000, aber wo gehen mehr Bestellungen verloren? Warum?
  2. Lösche im Code aus 3-1 das 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.
  3. Stell 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.

Erhoben: deine E-Mail-Adresse · Zweck: Hinweise auf neue Lektionen oder den Start dieses Kurses · Speicherdauer: bis du die Löschung verlangst. Keine andere Nutzung, keine Weitergabe. Antworte einfach auf eine Benachrichtigung, dann löschen wir sie sofort.

Fragen & Antworten

Frag alles zu dieser Lektion. Fragen aus allen Sprachversionen dieser Seite erscheinen hier gemeinsam.

Wird als reiner Text angezeigt, ohne Links oder Formatierung.

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
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex