Zum Inhalt
wannacode

Threads

Kurz gesagt: Ein Thread ist ein eigener Ausführungsstrang im Programm; in Python lässt der GIL immer nur einen Thread Code ausführen, deshalb helfen Threads, wenn das Programm wartet.

Problem

Drei Berichte, einer nach dem anderen

Das Programm lädt drei Monatsberichte vom Server herunter. Der Download ist hier eine Pause: time.sleep(sekunden) hält das Programm für so viele Sekunden an.
reports.py
import time
def download(file, seconds):
time.sleep(seconds)
print(f"Heruntergeladen: {file}")
download("maerz.pdf", 0.6)
download("april.pdf", 0.2)
download("mai.pdf", 0.4)
print("Fertig")
Ausgabe
Heruntergeladen: maerz.pdf
Heruntergeladen: april.pdf
Heruntergeladen: mai.pdf
Fertig
Die Pausen sind 0,6, 0,2 und 0,4 Sekunden, zusammen 1,2 Sekunden. Die ganze Zeit wartet das Programm nur auf das Netzwerk. Gäbe es einen Button „Abbrechen“, würde er nicht reagieren. Und die drei Berichte hängen nicht voneinander ab, sie könnten also gut gleichzeitig heruntergeladen werden.

Die Idee

Prozess und Thread

Wenn du das Programm startest, gibt ihm das Betriebssystem einen Prozess: eigenen Speicher, eigene geöffnete Dateien. Im Prozess laufen ein oder mehrere Threads. Ein Thread ist eine Folge von Befehlen, die nacheinander ausgeführt werden. Bisher hatte jedes deiner Programme einen Thread: den, der die Datei von oben nach unten ausführt.

Die Threads eines Prozesses teilen sich ihren Speicher: Sie sehen dieselben Objekte. Das macht sie praktisch (sie können einander leicht Daten geben) und gefährlich (sie können einander durcheinanderbringen, wie du gleich sehen wirst).

  • Parallel heißt wirklich gleichzeitig, auf verschiedenen Kernen des Prozessors.
  • Nebenläufig (concurrent) heißt auf einem Kern, abwechselnd und so schnell, dass es gleichzeitig aussieht.

Für dich als Programmierer spielt der Unterschied selten eine Rolle: In beiden Fällen weißt du nicht, welcher Thread wann weitermacht.

Python (CPython, das von python.org) hat einen Haken: den GIL (global interpreter lock). Immer nur ein Thread führt Python-Code aus, auch wenn der Prozessor viele Kerne hat. Deshalb helfen Threads, wenn das Programm wartet (Netzwerk, Dateien, sleep): Während einer wartet, arbeitet ein anderer. Bei schweren Berechnungen machen Threads nichts schneller. Dort nimmt man Prozesse, die du in der Übung siehst.

Genauer, für Neugierige
  • Der Browser Chrome startet für jeden Tab einen eigenen Prozess. Deshalb hält eine hängende Seite die anderen nicht auf, und deshalb braucht der Browser auch viel Speicher.
  • Threads benutzt jedes Programm, das mehrere Dinge auf einmal tut: Ein Spiel spielt den Ton in einem Thread ab und führt die Logik in einem anderen aus; ein Webserver bedient jeden Client in seinem eigenen Thread.
  • Der Preis: Jeder Thread hat seinen eigenen Stack, und der Speicher dafür wird extra reserviert. Tausende Threads kosten viel. Deshalb nehmen echte Programme einen Thread-Pool (die letzte Idee der Lektion). - Python 3.13 gibt es auch als eigene Version ohne GIL (free-threaded). Sie ist noch experimentell, deshalb geht es hier um das gewöhnliche Python.

Sieh es dir an

Der erste Thread

Die Klasse Thread aus dem Modul threading ist ein Thread. Mit target sagst du ihr, welche Funktion sie ausführen soll, und mit args, welche Argumente sie übergibt (ein Tupel):

worker = threading.Thread(target=download, args=("april.pdf", 0.2))
worker.start()
print("Das Programm wartet nicht")
worker.join()
print("Der Bericht ist heruntergeladen")
Ausgabe
Das Programm wartet nicht
Heruntergeladen: april.pdf
Der Bericht ist heruntergeladen
  • start() startet den neuen Thread und kehrt sofort zurück. Das Programm macht weiter, ohne zu warten, deshalb steht „Das Programm wartet nicht“ zuerst da.
  • join() lässt das Programm warten, bis der Thread fertig ist.

download ist die Funktion aus der ersten Karte. Python hat keine geprüften Exceptions, also gibt es hier nichts zu deklarieren.

Genauer, für Neugierige
target ist der kürzeste Weg, aber nicht der einzige. Dir begegnen auch eine Unterklasse von Thread, die ihr eigenes run() schreibt, und ein Lambda:
class ReportThread(threading.Thread):
def run(self):
print("1. eine Unterklasse von Thread")
def report_job():
print("2. eine Funktion als target")
a = ReportThread()
b = threading.Thread(target=report_job)
c = threading.Thread(target=lambda: print("3. ein Lambda"))
for t in [a, b, c]:
t.start()
t.join()
Ausgabe
1. eine Unterklasse von Thread
2. eine Funktion als target
3. ein Lambda
Anonyme Klassen gibt es in Python nicht. Flexibler ist es, die Aufgabe (die Funktion) vom Thread zu trennen: So kann dieselbe Funktion an einen Thread-Pool gehen.

Sieh es dir an

Drei auf einmal

Jetzt die drei Berichte, jeder in seinem eigenen Thread:
start = time.time()
march = threading.Thread(target=download, args=("maerz.pdf", 0.6))
april = threading.Thread(target=download, args=("april.pdf", 0.2))
may = threading.Thread(target=download, args=("mai.pdf", 0.4))
march.start()
april.start()
may.start()
march.join()
april.join()
may.join()
seconds = time.time() - start
print(f"Fertig in unter einer Sekunde: {seconds < 1}")
Ausgabe
Heruntergeladen: april.pdf
Heruntergeladen: mai.pdf
Heruntergeladen: maerz.pdf
Fertig in unter einer Sekunde: True

Die Threads starten in der Reihenfolge März, April, Mai, werden aber in der Reihenfolge der Pausen fertig: April (0,2 s), Mai (0,4 s), März (0,6 s). Das ganze Warten dauert so lange wie das längste, 0,6 Sekunden statt 1,2.

In welcher Reihenfolge die Threads bei print ankommen, entscheidet das Betriebssystem. Hier sind die Pausen unterschiedlich genug, also ist die Reihenfolge immer gleich. Wären sie gleich lang, könnte sie bei jedem Lauf anders sein.

Probier es aus

start oder run

Was gibt das Programm aus? (name ist der Name des Threads)

class Report(threading.Thread):
def run(self):
print(f"Läuft in: {threading.current_thread().name}")
t = Report(name="downloader")
t.run()
t.start()
t.join()
Antwort

Die Idee

Das Leben eines Threads

is_alive() sagt dir, ob der Thread gerade läuft: von start() bis zum Ende seiner Funktion:
t = threading.Thread(target=download, args=("mai.pdf", 0.5))
print(t.is_alive())
t.start()
time.sleep(0.1)
print(t.is_alive())
t.join()
print(t.is_alive())
try:
t.start()
except RuntimeError as e:
print(f"RuntimeError: {e}")
Ausgabe
False
True
Heruntergeladen: mai.pdf
False
RuntimeError: threads can only be started once

Mehr verrät Python nicht: Ob der Thread wartet oder rechnet, sieht man nicht. Ein Thread kann nur einmal gestartet werden. Ein zweites start() auf demselben Thread wirft RuntimeError.

Genauer, für Neugierige
  • Ein Thread in Python lässt sich nicht von außen anhalten. Muss er aufhören, prüft er selbst ein Signal, zum Beispiel ein threading.Event, das ein anderer Thread mit set() setzt.
  • name und native_id sind Name und Nummer des Threads. threading.current_thread() gibt dir den Thread, in dem der Code gerade läuft.
  • time.sleep(0) deutet an, dass der Thread einem anderen den Vortritt lassen kann. Das benutzen wir gleich.

Problem

Die verlorenen Besuche

Die Website zählt ihre Besuche. Zwei Threads bedienen Besucher, und jeder ruft 10 000-mal increment() auf. Zwischen dem Lesen und dem Schreiben des Zählers tut der Thread etwas anderes. Hier ist das time.sleep(0): „lass die anderen Threads kurz laufen“:
visits.py
class Counter:
def __init__(self):
self.count = 0
def increment(self):
value = self.count
time.sleep(0) # hier lässt der Thread dem anderen den Vortritt
self.count = value + 1
visits = Counter()
def work():
for i in range(10_000):
visits.increment()
first = threading.Thread(target=work)
second = threading.Thread(target=work)
first.start()
second.start()
first.join()
second.join()
print(f"Besuche: {visits.count}")

Du erwartest 20 000. Fünf Läufe:

Ausgabe (jedes Mal anders)
Besuche: 10000
Besuche: 10001
Besuche: 10001
Besuche: 10000
Besuche: 10000

Jedes Mal weniger, fast die Hälfte. Der Grund ist, dass das Erhöhen kein einzelner Schritt ist, sondern drei:

value = self.count # 1. lesen
value = value + 1 # 2. 1 addieren
self.count = value # 3. schreiben

class Counter:
def __init__(self):
self.count = 5
self.lock = threading.Lock()
def increment(self):
self.count += 1 # lesen, 1 addieren, schreiben
def safe_increment(self):
with self.lock:
self.count += 1
Schritt für Schritt, in Worten
  1. count ist 5. Die Threads A und B rufen gleichzeitig increment() auf.
  2. A liest count: 5.
  3. B liest count, bevor A geschrieben hat: auch 5.
  4. A addiert 1 in seiner eigenen Kopie und schreibt 6.
  5. B schreibt auch 6. Zwei Aufrufe, und count ist 6, nicht 7: Eine der Additionen geht verloren.
  6. Noch einmal, jetzt mit with self.lock: A nimmt den Lock, liest 5 und schreibt 6.
  7. B erreicht with self.lock und wartet, bis A den Lock freigibt.
  8. B nimmt den Lock, liest 6 und schreibt 7: Nichts geht verloren.

Wenn beide Threads denselben Wert lesen, bevor einer von ihnen geschrieben hat, geht eine der Additionen verloren. Das nennt man eine Race Condition (Wettlaufsituation). Ob sie eintritt, hängt davon ab, wann die Threads gewechselt werden, deshalb ist das Ergebnis bei jedem Lauf anders.

Genauer, für Neugierige

Ohne time.sleep(0), mit self.count += 1 und 100 000 Erhöhungen pro Thread ergaben fünf Läufe in Python 3.12 fünfmal 200 000:

class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
Ausgabe
Besuche: 200000
Besuche: 200000
Besuche: 200000
Besuche: 200000
Besuche: 200000

Das liegt nicht daran, dass es sicher ist. Der GIL wechselt die Threads nur an bestimmten Stellen der Ausführung, und hier liegt zufällig keine zwischen dem Lesen und dem Schreiben. Python verspricht das nicht: Eine andere Version, anderer Code in der Methode oder Python ohne GIL verlieren Besuche. Deshalb werden gemeinsame Daten immer mit einem Lock geschützt.

Die Idee

Lock: einer nach dem anderen

Die Lösung ist, dass immer nur ein Thread diese drei Schritte macht. Das tut ein Lock (eine Sperre), threading.Lock: Der Block with self.lock: ist immer nur für einen Thread da:
class Counter:
def __init__(self):
self.count = 0
self.lock = threading.Lock()
def increment(self):
with self.lock:
value = self.count
time.sleep(0)
self.count = value + 1
Ausgabe
Besuche: 20000

Der Thread nimmt den Lock, wenn er with betritt, und gibt ihn frei, wenn er es verlässt, auch bei einer Exception. Der andere Thread wartet vor dem with. Jetzt ist das Ergebnis bei jedem Lauf 20 000. Einen fertigen atomaren Zähler wie in Java hat Python nicht: Der Lock ist der Weg.

Genauer, für Neugierige
  • Condition: wait() und notify_all(): Ein Thread im with-Block einer Condition kann den Lock freigeben und schlafen, bis ein anderer Thread ihn weckt. So wartet die Küche auf eine Bestellung, und die Kasse wartet, bis die Küche die vorige genommen hat (das Problem „Producer und Consumer“):
class OrderBox:
def __init__(self):
self.order = None
self.changed = threading.Condition()
def put(self, new_order):
with self.changed:
while self.order is not None:
self.changed.wait()
self.order = new_order
self.changed.notify_all()
def take(self):
with self.changed:
while self.order is None:
self.changed.wait()
result = self.order
self.order = None
self.changed.notify_all()
return result
box = OrderBox()
def kitchen():
for i in range(3):
print(f"Die Küche nimmt {box.take()}")
cook = threading.Thread(target=kitchen)
cook.start()
box.put("Pizza")
box.put("Salat")
box.put("Suppe")
cook.join()
Ausgabe
Die Küche nimmt Pizza
Die Küche nimmt Salat
Die Küche nimmt Suppe

wait() steht in einem while, nicht in einem if: Der Thread kann auch ohne Grund aufwachen, deshalb prüft er die Bedingung noch einmal. Dasselbe macht queue.Queue(maxsize=1) fertig mit: put wartet, wenn die Warteschlange voll ist, und get wartet, wenn sie leer ist.

  • Deadlock (Verklemmung): Zwei Threads warten für immer aufeinander. Das Büro hält den Drucker und wartet auf den Scanner, und der Empfang hält den Scanner und wartet auf den Drucker:
printer = threading.Lock()
scanner = threading.Lock()
def office():
with printer:
time.sleep(0.1)
with scanner:
print("Das Büro kopiert")
def reception():
with scanner:
time.sleep(0.1)
with printer:
print("Der Empfang kopiert")

Das Programm hängt und gibt nie etwas aus. Vermeiden lässt sich das, indem alle Threads die Locks in derselben Reihenfolge nehmen.

  • Prioritäten haben Threads in Python nicht. Es gibt Daemon-Threads (daemon=True): Das Programm endet, ohne auf sie zu warten:
report = threading.Thread(target=download, args=("jahresbericht.pdf", 0.1), daemon=True)
print(report.name, report.daemon)
Ausgabe
Thread-1 (download) True

Probier es aus

Zwei Geldautomaten

Zwei Personen heben im selben Moment je 80 € von einem Konto mit 100 € ab. Manchmal bekommen beide das Geld, und der Kontostand wird -60 €. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Thread-Pool und Future

Für jede Aufgabe einen Thread zu erzeugen ist teuer, und bei tausend Aufgaben auch gefährlich. Ein Thread-Pool besteht aus ein paar Threads, die sich Aufgaben aus einer Warteschlange nehmen. In Python ist das ThreadPoolExecutor (aus concurrent.futures):
def size_of(file, seconds):
time.sleep(seconds)
return len(file) * 100
files = ["maerz.pdf", "april.pdf", "mai.pdf", "juni.pdf"]
with ThreadPoolExecutor(max_workers=2) as pool:
sizes = [pool.submit(size_of, file, 0.3) for file in files]
for file, size in zip(files, sizes):
print(f"{file}: {size.result()} KB")
Ausgabe
maerz.pdf: 900 KB
april.pdf: 900 KB
mai.pdf: 700 KB
juni.pdf: 800 KB
  • ThreadPoolExecutor(max_workers=2) erzeugt einen Pool aus 2 Threads. Die vier Aufgaben wechseln sich darin ab.
  • submit(funktion, argumente) nimmt eine Aufgabe und gibt dir sofort ein Future: das Versprechen, dass das Ergebnis kommt.
  • result() wartet, bis die Aufgabe fertig ist, und gibt das Ergebnis zurück. Hat die Aufgabe eine Exception geworfen, wirft result() sie noch einmal.
  • with beendet den Pool am Ende: Es wartet, bis die Aufgaben fertig sind, und beendet seine Threads.
Genauer, für Neugierige
Gibt die Aufgabe nichts zurück, behältst du das Future einfach nicht. Das Ende von with wartet auf alle:
with ThreadPoolExecutor(max_workers=2) as pool:
for i in range(1, 6):
pool.submit(download, f"bericht-{i}.pdf", 0.1)
print("Alles fertig.")
Ausgabe eines Laufs
Heruntergeladen: bericht-2.pdf
Heruntergeladen: bericht-1.pdf
Heruntergeladen: bericht-3.pdf
Heruntergeladen: bericht-4.pdf
Heruntergeladen: bericht-5.pdf
Alles fertig.
Die ersten zwei Berichte laufen gleichzeitig, deshalb ändert sich ihre Reihenfolge von Lauf zu Lauf. Ohne with schreibst du am Ende pool.shutdown(wait=True).

Probier es aus

Welches Ergebnis kommt zuerst?

„archiv.zip“ braucht 0,6 s zum Herunterladen, „notiz.txt“ 0,1 s. Was gibt das Programm aus?

with ThreadPoolExecutor(max_workers=2) as pool:
big = pool.submit(size_of, "archiv.zip", 0.6)
small = pool.submit(size_of, "notiz.txt", 0.1)
print(f"Groß: {big.result()}")
print(f"Klein: {small.result()}")
Antwort

Übung

Programme mit Threads

Löse die Aufgaben in deinem Editor.

1. Parallele Summe. Die Liste sales enthält 1 000 000 Verkäufe, sales[i] = i % 100. Teile sie in 4 gleiche Teile und summiere jeden in seinem eigenen Thread. Jeder Thread schreibt an seinen eigenen Platz in der Liste partial, deshalb brauchst du keine Synchronisierung. Warte mit join auf sie und addiere die Teile:

Ausgabe
Teile: [12375000, 12375000, 12375000, 12375000]
Gesamt: 49500000
Genauer, für Neugierige

Wegen des GIL rechnen die vier Threads nicht schneller als einer. Wirklich parallel geht es mit Prozessen: ProcessPoolExecutor startet eigene Kopien von Python, jede mit ihrem eigenen GIL. Die Teile werden an sie übergeben, und das Ergebnis kommt zurück:

from concurrent.futures import ProcessPoolExecutor
if __name__ == "__main__":
sales = [i % 100 for i in range(1_000_000)]
size = len(sales) // 4
chunks = [sales[p * size:(p + 1) * size] for p in range(4)]
with ProcessPoolExecutor(max_workers=4) as pool:
partial = list(pool.map(sum, chunks))
print(f"Teile: {partial}")
print(f"Gesamt: {sum(partial)}")
Ausgabe
Teile: [12375000, 12375000, 12375000, 12375000]
Gesamt: 49500000

if __name__ == "__main__": verhindert, dass der Hauptcode in jedem Prozess noch einmal läuft: Unter Windows und macOS startet ein Prozess, indem er die Datei von vorn lädt.

2. Herunterladen mit einem Pool. Lade sechs Berichte (maerz.pdf, april.pdf, mai.pdf, juni.pdf, juli.pdf, august.pdf) mit einem Pool aus 3 Threads herunter. Benutze size_of(file, 0.3) aus der Karte zum Pool. Gib die Größen in der Reihenfolge der Dateien aus, die Summe und ob alles in unter 1,5 Sekunden fertig war (nacheinander wären es 1,8 s):

Ausgabe
maerz.pdf: 900 KB
april.pdf: 900 KB
mai.pdf: 700 KB
juni.pdf: 800 KB
juli.pdf: 800 KB
august.pdf: 1000 KB
Gesamt: 5100 KB
Unter 1,5 Sekunden: True

3. Ein Zähler ohne Verluste. Vier Threads mit je 50 000 Besuchen erhöhen einen gemeinsamen Zähler. Schreib den Zähler so, dass das Ergebnis immer 200 000 ist. Probier es zuerst ohne Lock und mit time.sleep(0) zwischen dem Lesen und dem Schreiben und sieh, wie viel verloren geht, dann korrigiere es mit einem Lock.

Ausgabe
Besuche: 200000

Weitere Aufgaben

  • Ein Test für den Zähler. Schreib einen unittest-Test, der 8 Threads mit je 10 000 Erhöhungen startet und mit assertEqual auf 80 000 prüft. Lass ihn ein paarmal gegen den Zähler mit time.sleep(0) und ohne Lock laufen. Ein Test, der nur manchmal fehlschlägt, ist das typische Zeichen einer Race Condition.
  • Der Deadlock. Führe das Beispiel mit dem Drucker und dem Scanner aus „Genauer“ aus (mit zwei Threads und join) und sieh zu, wie es hängt. Korrigiere es so, dass beide Threads zuerst den Drucker nehmen.

Test

Teste dich

1. Was ist der Unterschied zwischen t.start() und t.run()?
2. Warum ruft das Programm join() auf, bevor es das Ergebnis ausgibt?
3. Zwei Threads erhöhen einen gemeinsamen Zähler ohne Lock. Was verspricht Python über das Ergebnis?
4. Was garantiert with self.lock: um ein paar Zeilen?
5. Du startest drei Aufgaben mit pool.submit und behältst für jede ein Future. Was macht future.result()?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·