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
time.sleep(sekunden) hält das Programm für so viele Sekunden an.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")Heruntergeladen: maerz.pdfHeruntergeladen: april.pdfHeruntergeladen: mai.pdfFertigDie 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")Das Programm wartet nichtHeruntergeladen: april.pdfDer Bericht ist heruntergeladenstart()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()1. eine Unterklasse von Thread2. eine Funktion als target3. ein LambdaSieh es dir an
Drei auf einmal
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() - startprint(f"Fertig in unter einer Sekunde: {seconds < 1}")Heruntergeladen: april.pdfHeruntergeladen: mai.pdfHeruntergeladen: maerz.pdfFertig in unter einer Sekunde: TrueDie 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 beiprint 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()t.run() ist ein gewöhnlicher Methodenaufruf: Der Code läuft sofort, im Hauptthread, den Python MainThread nennt. Einen neuen Thread gibt es nicht. Erst t.start() startet den Thread „downloader“, der dasselbe run() ausführt. run() statt start() zu schreiben ist ein häufiger Fehler: Das Programm funktioniert, nur läuft nichts gleichzeitig.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}")FalseTrueHeruntergeladen: mai.pdfFalseRuntimeError: threads can only be started onceMehr 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 mitset()setzt. nameundnative_idsind 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
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“: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:
Besuche: 10000Besuche: 10001Besuche: 10001Besuche: 10000Besuche: 10000Jedes Mal weniger, fast die Hälfte. Der Grund ist, dass das Erhöhen kein einzelner Schritt ist, sondern drei:
value = self.count # 1. lesenvalue = value + 1 # 2. 1 addierenself.count = value # 3. schreibenclass 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 += 1Schritt für Schritt, in Worten
- count ist 5. Die Threads A und B rufen gleichzeitig increment() auf.
- A liest count: 5.
- B liest count, bevor A geschrieben hat: auch 5.
- A addiert 1 in seiner eigenen Kopie und schreibt 6.
- B schreibt auch 6. Zwei Aufrufe, und count ist 6, nicht 7: Eine der Additionen geht verloren.
- Noch einmal, jetzt mit with self.lock: A nimmt den Lock, liest 5 und schreibt 6.
- B erreicht with self.lock und wartet, bis A den Lock freigibt.
- 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 += 1Besuche: 200000Besuche: 200000Besuche: 200000Besuche: 200000Besuche: 200000Das 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
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 + 1Besuche: 20000Der 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()undnotify_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()Die Küche nimmt PizzaDie Küche nimmt SalatDie Küche nimmt Suppewait() 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)Thread-1 (download) TrueProbier es aus
Zwei Geldautomaten
Wähle die Zeile mit dem Fehler.
Die Prüfung self.balance >= amount und die Abhebung sind kein Ganzes. Wird der Thread zwischen ihnen gewechselt, sehen beide 100, und beide heben ab. Mit dem GIL passiert das selten, und genau das macht den Fehler tückisch: Er läuft tausendmal durch und schlägt dann beim Kunden zu. Die Korrektur ist ein Lock um die zwei Zeilen:
class Account: def __init__(self): self.balance = 100 self.lock = threading.Lock()
def withdraw(self, amount): with self.lock: if self.balance >= amount: self.balance -= amountDann wartet der zweite Geldautomat auf den ersten, sieht 20 € und lehnt ab. Der Kontostand bleibt 20 €.
Die Idee
Thread-Pool und Future
ThreadPoolExecutor (aus concurrent.futures):def size_of(file, seconds): time.sleep(seconds) return len(file) * 100files = ["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")maerz.pdf: 900 KBapril.pdf: 900 KBmai.pdf: 700 KBjuni.pdf: 800 KBThreadPoolExecutor(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 einFuture: 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, wirftresult()sie noch einmal.withbeendet den Pool am Ende: Es wartet, bis die Aufgaben fertig sind, und beendet seine Threads.
Genauer, für Neugierige
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.")Heruntergeladen: bericht-2.pdfHeruntergeladen: bericht-1.pdfHeruntergeladen: bericht-3.pdfHeruntergeladen: bericht-4.pdfHeruntergeladen: bericht-5.pdfAlles fertig.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()}")Die kleine Datei ist wirklich zuerst fertig. Aber big.result() steht in der ersten Zeile und wartet auf die große. Wenn die da ist, gibt small.result() sofort zurück, weil das Ergebnis schon da ist. Die Reihenfolge der Ausgabe ist die Reihenfolge der result()-Aufrufe, deshalb ist sie bei jedem Lauf gleich.
Ü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:
Teile: [12375000, 12375000, 12375000, 12375000]Gesamt: 49500000Genauer, 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)}")Teile: [12375000, 12375000, 12375000, 12375000]Gesamt: 49500000if __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):
maerz.pdf: 900 KBapril.pdf: 900 KBmai.pdf: 700 KBjuni.pdf: 800 KBjuli.pdf: 800 KBaugust.pdf: 1000 KBGesamt: 5100 KBUnter 1,5 Sekunden: True3. 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.
Besuche: 200000Weitere Aufgaben
- Ein Test für den Zähler. Schreib einen unittest-Test, der 8 Threads mit je 10 000 Erhöhungen startet und mit
assertEqualauf 80 000 prüft. Lass ihn ein paarmal gegen den Zähler mittime.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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Listen: append, remove und sort · Klassen und Daten in Klassen · Lambdas · Tests mit unittest
- Geht weiter in: Netzwerkprogrammierung · Quicksort und der schlechte Pivot · Der Aufrufstapel von innen · Von der Rekursion zur Schleife
- Aufgaben zum Thema: Programme in der echten Welt