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 5:50–6:40
Der Bon für heute Nacht
#08 · Achte Nacht
Problem
Vier Klumpen Nudelteig für morgen. Vier Köche sind darauf angesetzt, und es dauert genauso lange, als würde einer allein kneten.
Ziel
Die vier Teigklumpen mindestens doppelt so schnell fertig bekommen wie allein.
Nachtlokal Dongsi
Ein Uhr früh, der Service ist vorbei. Auf der Arbeitsfläche liegen vier Klumpen Nudelteig für morgen. Minho, Sua, Herr Tak und du. Jeder hat einen Klumpen übernommen, also müsste es viermal so schnell gehen.
Doch der Timer der Chefin bleibt bei fast derselben Zahl stehen wie beim Kneten allein. Minho legt den Kopf schief. „Wir hatten doch alle die Hände voll?“
Chefin Yoon Seoyeon zeigt auf den Messerblock auf der Arbeitsfläche. Darin steckt genau ein Messer. „In dieser Küche gibt es eine alte Regel. Schneiden und kneten darf immer nur einer. Heute reden wir über diese Regel.“
Die Bestellungen in Lektion 1 waren Warten, bis die Suppe kocht. Der Teig heute ist anders. Eine Schleife, die zwei Millionen Mal Zahlen addiert: Arbeit, bei der die Hände nie stillstehen (CPU-Arbeit).
import threading
import time
def knead(n=2_000_000):
s = 0
for i in range(n): # ununterbrochen rechnen: Teig kneten
s += i
return s
start = time.perf_counter()
for _ in range(4):
knead()
print(f"sequential {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
cooks = [threading.Thread(target=knead) for _ in range(4)]
for th in cooks:
th.start()
for th in cooks:
th.join()
print(f"4 threads {time.perf_counter() - start:.2f}s")
# sequential 0.24s
# 4 threads 0.23s (12-Kern-PC, gemessen mit Python 3.14)CPython (das ganz normale Python, das wir verwenden) hat einen GIL (Global Interpreter Lock). Es gibt genau eine Regel:
In einem Prozess führt immer nur ein Thread gleichzeitig Python-Bytecode aus.
In der Küche heißt das: „Es gibt nur ein Messer.“ Auch mit vier Köchen kann nur schneiden, wer das Messer hält; die anderen warten, bis es zurückkommt. Das Messer wandert etwa alle 0,005 Sekunden zum Nächsten (sys.getswitchinterval(), Lektion 3).
| Zeit | Minho | Sua | Herr Tak | Du |
|---|---|---|---|---|
| 0–5 ms | knetet mit dem Messer | wartet | wartet | wartet |
| 5–10 ms | wartet | knetet mit dem Messer | wartet | wartet |
| 10–15 ms | wartet | wartet | knetet mit dem Messer | wartet |
| 15–20 ms | wartet | wartet | wartet | knetet mit dem Messer |
Die vier kneten abwechselnd, also kommt jeder Klumpen ein bisschen voran. Nebenläufigkeit ja, Parallelität nein (Lektion 1). Die Gesamtarbeit bleibt gleich, also bleibt auch die Zeit gleich.
Warum gibt es diese Regel? Python zählt bei jedem Objekt, „an wie vielen Stellen es gerade benutzt wird“ (Referenzzähler). Ändern mehrere Threads diese Zahl gleichzeitig, geht sie kaputt wie die Tafel aus Lektion 3. Vor langer Zeit hat CPython den einfachen Weg gewählt, den ganzen Interpreter mit einem einzigen Lock zu schützen, und das ist der GIL.
Der GIL hat eine wichtige Ausnahme. Während ein Thread wartet (time.sleep, Dateien lesen, Netzwerkantworten), gibt er den GIL ab. Wer darauf wartet, dass die Suppe kocht, braucht kein Messer.
def simmer():
time.sleep(0.3) # warten, bis die Suppe kocht: währenddessen wird der GIL abgegeben
start = time.perf_counter()
cooks = [threading.Thread(target=simmer) for _ in range(4)]
for th in cooks:
th.start()
for th in cooks:
th.join()
print(f"4 threads {time.perf_counter() - start:.2f}s")
# 4 threads 0.30s (nacheinander wären es 1,2 Sekunden)Auch in C geschriebene Funktionen können den GIL während schwerer Berechnungen abgeben. So macht es hashlib, wenn es große Datenmengen hasht. Das erledigt nicht eine menschliche Hand, sondern die Knetmaschine (C-Code), und sie steht damit außerhalb der Messerregel.
import hashlib
dough = b"x" * 50_000_000 # 50 MB
def press():
hashlib.sha256(dough).hexdigest() # während der C-Code rechnet, wird der GIL abgegeben
# genauso gemessen wie oben (auf diesem PC ungefähr)
# sequential 0.11s
# 4 threads 0.04s| Art der Arbeit | Mit Threads und GIL | Grund |
|---|---|---|
| Warten (sleep, Dateien, Netzwerk) | wird schneller | beim Warten wird der GIL abgegeben |
| schwere Berechnungen in C-Erweiterungen (hashlib, zlib usw.) | meist schneller | der C-Code gibt den GIL ab und rechnet |
| reine Python-Berechnungen (Schleifen, Strings zusammensetzen) | wird nicht schneller | Bytecode läuft immer nur in einem Thread |
Und der GIL schützt deinen Code nicht. Er schützt nur das Innere des Interpreters; mehrstufige Vorgänge wie count += 1 werden mittendrin unterbrochen. Deshalb lag die Tafel in Lektion 3 trotz GIL falsch.
Der Versuchsaufbau unten ist eine Küche, in der auf dem Produktionsserver echte Threads time.sleep aufrufen. Der Server läuft mit einem Python mit GIL, und trotzdem überlappen sich die Balken, weil es Warten ist.
Küchen-Zeitleiste
Bestellungen
6
Köche
3
Kochen
Warten
Abgebrochen
Fehlgeschlagen
Tipp der Chefin
Wenn du mehr Threads hast und es trotzdem nicht schneller wird, frag dich zuerst: Ist das Suppe oder Teig? Bei Suppe reichen Threads. Bei Teig brauchst du nicht mehr Leute, sondern mehr Küchen.
Wenn es nur ein Messer gibt, richtest du einfach noch eine Küche mit eigenem Messer ein. Das ist ein Prozess. Jeder Prozess hat seinen eigenen Python-Interpreter und auch seinen eigenen GIL.
import multiprocessing as mp
import os
def branch(name):
print(name, "pid", os.getpid(), "parent", os.getppid())
if __name__ == "__main__":
print("main pid", os.getpid())
p = mp.Process(target=branch, args=("branch-1",))
p.start()
p.join()
print("exitcode", p.exitcode)
# zum Beispiel:
# main pid 39516
# branch-1 pid 40268 parent 39516
# exitcode 0Process benutzt man genau wie threading.Thread (target, args, start, join). Der Unterschied ist os.getpid(), die Prozessnummer. Die Filiale ist ein eigenständiges Programm mit einer anderen Nummer als das Stammhaus.
Die Filiale teilt keinen Speicher. Was die Filiale auch in eine Liste steckt, die Liste im Stammhaus bleibt unverändert.
orders = []
def add(n):
orders.append(n)
print("in branch:", orders)
if __name__ == "__main__":
p = mp.Process(target=add, args=("#1",))
p.start()
p.join()
print("in main:", orders)
# in branch: ['#1']
# in main: []Das Tafel-Problem aus Lektion 3 entsteht so gar nicht erst, dafür musst du dir Ergebnisse ausdrücklich zurückgeben lassen. Wenn du mehrere Aufgaben verteilen willst, ist Pool praktisch.
def knead(n):
s = 0
for i in range(n):
s += i
return s
if __name__ == "__main__":
with mp.Pool(processes=4) as pool:
print(pool.map(knead, [10, 100, 1000, 10_000]))
# [45, 4950, 499500, 49995000]if __name__ == "__main__": unverzichtbar istWindows und macOS eröffnen Filialen per spawn. Dabei wird ein neues Python gestartet, und dieses Python importiert deine Datei noch einmal von vorn. Es muss die Funktionsdefinitionen neu lesen, um zu wissen, was knead ist.
Fehlt diese Prüfung, stößt die Filiale beim Lesen der Datei wieder auf die Zeile mp.Process(...).start() und versucht, selbst eine Filiale zu eröffnen. Python verhindert das und meldet einen Fehler.
# ohne Prüfung direkt auf oberster Ebene gestartet (Windows)
p = mp.Process(target=knead, args=(1,))
p.start()
# in der Filiale:
# RuntimeError:
# An attempt has been made to start a new process before the
# current process has finished its bootstrapping phase.
# ...Deshalb speicherst du multiprocessing-Beispiele als Datei und startest sie mit python datei.py. In einer interaktiven Shell oder einem Notebook findet die Filiale die Funktion oft nicht und scheitert.
Erinnerst du dich an ThreadPoolExecutor aus Lektion 7? ProcessPoolExecutor gibt Aufgaben auf genau dieselbe Weise an Prozesse ab. submit, Future, result(), map und as_completed sind alle gleich.
import os
from concurrent.futures import ProcessPoolExecutor
def knead(n):
s = 0
for i in range(n):
s += i
return os.getpid(), s
if __name__ == "__main__":
print("main", os.getpid())
with ProcessPoolExecutor(max_workers=2) as ex:
future = ex.submit(knead, 1000)
print(future.result())
for pid, s in ex.map(knead, [10, 100, 1000]):
print(pid, s)
# zum Beispiel:
# main 43452
# (42784, 499500)
# 42784 45
# 42784 4950
# 42784 499500Die Prozessnummer ist eine andere als bei main. Welche Filiale welchen Teig übernimmt, ändert sich von Lauf zu Lauf.
Filiale und Stammhaus teilen keinen Speicher, also werden Funktion, Argumente und Ergebnisse in Bytes verpackt (pickle) verschickt und empfangen. Was sich nicht verpacken lässt, kann nicht verschickt werden. Das typische Beispiel ist ein Lambda.
def double(x):
return x * 2
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=2) as ex:
print(ex.submit(double, 21).result())
try:
ex.submit(lambda x: x * 2, 21).result()
except Exception as e:
print(type(e).__name__, e)
# 42
# PicklingError Can't pickle <function <lambda> at 0x...>: it's not found as __main__.<lambda>double aus der Datei“ und benutzt es. Ein Lambda ohne Namen kann die Filiale nicht finden.def auf oberster Ebene der Datei. Funktionen in Funktionen und Lambdas gehen nicht.Eine Filiale gibt es nicht umsonst. Ein neues Python muss starten, die Datei muss neu importiert werden, und die Werte müssen verpackt hin- und hergehen. Ist der Teig klein, dauert diese Vorbereitung länger als die Arbeit selbst.
# die vier kleinen Teige aus 8-1 (je zwei Millionen) auf 4 Prozesse
with ProcessPoolExecutor(max_workers=4) as ex:
results = list(ex.map(knead, [2_000_000] * 4))
# 4 processes 0.31s (gemessen auf einem 12-Kern-PC, nacheinander 0.24 s)| Methode (4 kleine Teige) | Zeit (gemessen auf einem 12-Kern-PC) |
|---|---|
| nacheinander | 0,24 s |
| 4 Threads | 0,23 s |
| 4 Prozesse | 0,31 s |
Es ist sogar langsamer geworden. Macht man den Teig zehnmal so groß (zwanzig Millionen), sieht es anders aus. Auf diesem PC dauerte es nacheinander ungefähr 2,4–2,7 Sekunden, mit 4 Prozessen 0,9–1,1 Sekunden. Die Vorbereitungskosten bleiben fast gleich, aber die Arbeit ist größer geworden.
chunksize= bei map).with ProcessPoolExecutor() öffnet, richtet jedes Mal eine neue Filiale ein.Wie eine Filiale eingerichtet wird, nennt man die Startmethode (start method).
| Methode | Was sie tut | In der Küche |
|---|---|---|
spawn | startet ein neues Python und importiert die Datei neu | richtet in einem leeren Gebäude eine Küche von Grund auf ein |
fork | kopiert den aktuellen Prozess komplett | kopiert das Stammhaus, wie es ist (samt der Locks der Köche, die gerade arbeiten) |
forkserver | hält einen sauberen Serverprozess bereit und kopiert von dort | hält eine saubere Musterküche bereit und kopiert die |
forkserver der Standard. Unter Windows und macOS ist es spawn. Der frühere Linux-Standard fork wurde abgelöst, weil sich beim Kopieren eines Programms mit laufenden Threads die Locks verheddern können.spawn.import multiprocessing as mp
if __name__ == "__main__":
print(mp.get_start_method()) # spawn (Windows)
print(mp.get_all_start_methods()) # ['spawn']
ctx = mp.get_context("spawn") # nur dieser Pool mit spawn
with ctx.Pool(processes=2) as pool:
print(pool.map(knead, [10, 100])) # [45, 4950]mp.get_context("spawn") gilt nur für Pool und Process, die du aus diesem Kontext erzeugst. In Bibliotheken ist das der sichere Weg.if __name__ == "__main__": genau einmal mp.set_start_method("spawn") auf. Ein zweiter Aufruf ergibt RuntimeError: context has already been set.spawn ausdrücklich an und teste damit.Seit 3.14 wird der Free-Threading-Build offiziell unterstützt. Das ist ein Python ohne GIL, die ausführbare Datei heißt python3.14t (mit t am Ende). Die Küche wurde umgebaut, und jetzt gibt es so viele Messer wie Köche.
uv run --python 3.14t datei.py.sys._is_gil_enabled().# knead_threads.py: über den Code aus 8-1 nur zwei Zeilen setzen
import sys
print(sys._is_gil_enabled())
...
# python knead_threads.py
# True
# sequential 0.24s
# 4 threads 0.23s
# python3.14t knead_threads.py
# False
# ...
# 4 threads 0.09s (gemessen auf einem 12-Kern-PC, etwa 2,7-mal so schnell)Am Code wurde kein einziges Zeichen geändert. Derselbe Thread-Code wurde nur durch den Wechsel des Interpreters etwa 2,7-mal schneller. Dass es nicht viermal so schnell ist, liegt an den Kosten für Starten und Verwalten der Threads und an den internen Mechanismen, die ohne GIL für Sicherheit sorgen. Dafür kann Code mit nur einem Thread etwas langsamer sein als im normalen Build.
Zwei Dinge solltest du wissen.
PYTHON_GIL=0 (oder -X gil=0) kannst du ihn zwangsweise abschalten; mit PYTHON_GIL=1 ist der GIL auch unter 3.14t an.Die vier oberen Balken im Versuchsaufbau unten wurden bei der Vorbereitung des Kurses auf einem 12-Kern-PC gemessen. Darunter lässt „Auf dem Server kneten“ den Teig (600.000 × 4) direkt auf dem Produktionsserver dieser Seite laufen, einmal nacheinander, einmal mit 4 Threads. Der Produktionsserver ist ein Build mit GIL, und seine Kernzahl ist vorher nicht bekannt, also können die Zahlen von den gemessenen Balken abweichen. Achte darauf, ob die beiden Balken ähnlich lang sind.
Teig-Vergleich
Lokal gemessen (12-Kern-PC, Python 3.14)
Nacheinander
0.24s
4 Threads
0.23s
4 Prozesse
0.31s
4 Threads (3.14t, ohne GIL)
0.09s
Tipp der Chefin
Ohne GIL ist die Messerregel weg, aber nicht die Tafelregel. Wenn mehrere an dieselbe Tafel schreiben, schreiben sie jetzt wirklich gleichzeitig. Bevor du auf 3.14t umziehst, prüf zuerst deine Locks noch mal.
Filialen sind teuer, und beim Umbau sind noch nicht alle Bibliotheken mitgezogen. Dazwischen gibt es noch eine Möglichkeit: eine Küche im selben Gebäude, durch Trennwände abgeteilt, also Subinterpreter. In einem Prozess laufen mehrere Interpreter, und jedes Abteil hat seinen eigenen GIL (sein eigenes Messer). Mit 3.14 sind sie als Modul concurrent.interpreters in die Standardbibliothek gekommen.
from concurrent import interpreters
booth = interpreters.create()
booth.exec("menu = ['#1', '#2']; print('booth menu:', menu)")
booth.exec("menu.append('#3'); print('booth menu:', menu)")
print("main has menu?", "menu" in globals())
def knead(n):
s = 0
for i in range(n):
s += i
return s
print(booth.call(knead, 1000))
booth.close()
# booth menu: ['#1', '#2']
# booth menu: ['#1', '#2', '#3']
# main has menu? False
# 499500create() legst du ein Abteil an, mit exec(code_als_string) führst du darin Code aus, und mit call(funktion, argument) rufst du eine Funktion auf und bekommst das Ergebnis.menu) gibt es nur im Abteil. Wie bei der Filiale wird kein Speicher geteilt.In der Praxis ist der InterpreterPoolExecutor (3.14) bequem, der genauso aussieht wie in Lektion 7.
from concurrent.futures import InterpreterPoolExecutor
if __name__ == "__main__":
with InterpreterPoolExecutor(max_workers=4) as ex:
results = list(ex.map(knead, [2_000_000] * 4))
# auf diesem PC ungefähr 0.18 s (3.14 mit GIL, nacheinander 0.24 s)Weil jedes Abteil sein eigenes Messer hat, kommen die Teige auch im normalen Python parallel voran. Denk aber daran, dass viele C-Erweiterungen Subinterpreter noch nicht unterstützen.
Das Ziel für heute Nacht war, die vier Teigklumpen mindestens doppelt so schnell fertig zu bekommen wie allein. Es sind reine Python-Berechnungen, also helfen Threads nicht; und jeder Klumpen ist groß, also lohnen sich die Kosten für das Eröffnen von Filialen.
import time
from concurrent.futures import ProcessPoolExecutor
BIG = 20_000_000 # ein großer Teigklumpen
def knead(n):
s = 0
for i in range(n):
s += i
return s
if __name__ == "__main__":
start = time.perf_counter()
alone = [knead(BIG) for _ in range(4)]
print(f"sequential {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as ex:
branches = list(ex.map(knead, [BIG] * 4))
print(f"4 processes {time.perf_counter() - start:.2f}s", branches == alone)
# zum Beispiel (auf diesem PC):
# sequential 2.43s
# 4 processes 0.93s TrueEtwa 2,5-mal so schnell. Und das Ergebnis ist exakt dasselbe wie allein. Mit 3.14t erreichst du einen ähnlichen Effekt, wenn du knead mit vier Threads laufen lässt.
| Werkzeug | Messer (GIL) | Speicher | Startkosten | Passt zu |
|---|---|---|---|---|
| Threads (3.14) | einer pro Prozess | gemeinsam | gering | Warten |
| Threads (3.14t) | keiner | gemeinsam | gering | Warten + Rechnen |
| Prozesse | einer pro Filiale | getrennt (pickle) | hoch | große Berechnungen |
| Subinterpreter | einer pro Abteil | getrennt | mittel | Rechnen, reiner Python-Code |
Die Chefin bindet ihre Schürze ab. „Den Teig haben wir mit mehr Küchen gelöst. Aber was ist mit dem Gastraum? Stellen wir für zehn Tische zehn Leute ein, oder lassen wir eine Person alle zehn Tische betreuen?“
Schichtbuch — Achte Nacht. Mit Threads wurde der Teig nicht schneller, weil es nur ein Messer gibt. Auf vier Filialen verteilt, 2,5-mal so schnell fertig. Morgen wird der Gastraum umgebaut.
knead aus 8-1 die Schleife durch time.sleep(0.06) und miss noch einmal nacheinander und mit 4 Threads. Erkläre mit dem GIL, warum Threads diesmal schneller sind.ProcessPoolExecutor in eine Tabelle ein. Ab welcher Größe gewinnen auf deinem PC die Prozesse?submit eine Funktion, die innerhalb einer anderen Funktion definiert ist (verschachtelte Funktion), an ProcessPoolExecutor. Sieh dir an, welcher Fehler kommt, und behebe ihn.Rezeptkarte der Chefin
GIL: Python-Bytecode läuft immer nur in einem Thread gleichzeitig. Beim Warten (I/O) und in manchem C-Code wird der GIL abgegeben.
Rechenarbeit (CPU) wird mit Threads nicht schneller → Prozesse, 3.14t, Subinterpreter.
ProcessPoolExecutor benutzt man wie ThreadPoolExecutor. if __name__ == "__main__": ist Pflicht.
Was mit der Filiale ausgetauscht wird, läuft durch pickle:keine Lambdas, keine verschachtelten Funktionen. def auf oberster Ebene.
Prozesse sind teuer zu starten: nur für große Berechnungen. Startmethode: Windows und macOS spawn, Linux ab 3.14 forkserver.
sys._is_gil_enabled(): unter 3.14t (python3.14t) False. Derselbe Thread-Code läuft dann parallel.
concurrent.interpreters: create() → exec() / call(), InterpreterPoolExecutor (3.14).
← Zurück
Lektion 7 · Leihköche: concurrent.futures
Weiter →
Lektion 9 · Eine Servicechefin für alle Tische: erste Schritte mit asyncio
Ü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.