Threads in Python
Allgemeine Definition eines Threads
Ein Thread wird häufig auch als leichtgewichtiger Ausführungsstrang innerhalb eines Prozesses bezeichnet. Ein Prozess besteht aus mindestens einem Thread, kann aber weitere Threads starten. Mehrere Threads desselben Prozesses können nebenläufig (concurrent) ausgeführt werden.
Threads eines Prozesses teilen sich denselben Adressraum. Dadurch können sie auf dieselben Python-Objekte und beispielsweise auf gemeinsam verwendete Modulvariablen zugreifen. Gleichzeitig besitzt jeder Thread seinen eigenen Ausführungszustand, beispielsweise seinen eigenen Aufruf-Stack und die lokalen Variablen der gerade ausgeführten Funktionen.
Das gemeinsame Nutzen von Daten ist einerseits ein Vorteil von Threads, kann aber andererseits zu Problemen führen. Wenn mehrere Threads gleichzeitig dieselben Daten verändern, können sogenannte Race Conditions entstehen. Für solche Fälle stellt Python Synchronisationsmechanismen wie Locks bereit.
Threads sind besonders für I/O-lastige Aufgaben geeignet. Dazu gehören beispielsweise Netzwerkzugriffe, Dateioperationen oder das Warten auf externe Programme. Während ein Thread auf eine solche Operation wartet, kann ein anderer Thread weiterarbeiten. Dadurch kann ein Programm deutlich reaktionsfähiger sein und mehrere Wartevorgänge überlappen.
Threads, CPU-Kerne und der GIL
Bei der üblichen CPython-Version muss man zwischen Nebenläufigkeit und echter Parallelität unterscheiden. Der Global Interpreter Lock (GIL) sorgt im normalen CPython dafür, dass zu einem bestimmten Zeitpunkt nur ein Thread Python-Bytecode ausführt. CPU-intensive Python-Berechnungen werden deshalb durch zusätzliche Threads normalerweise nicht schneller.
Für CPU-intensive Aufgaben sind beispielsweise multiprocessing oder
concurrent.futures.ProcessPoolExecutor häufig besser geeignet, weil sie
mehrere Prozesse verwenden und damit mehrere CPU-Kerne tatsächlich parallel nutzen
können.
Daneben gibt es seit Python 3.13 sogenannte free-threaded builds von CPython, bei denen der GIL deaktiviert werden kann. In einem solchen Python können Threads tatsächlich gleichzeitig Python-Code auf mehreren CPU-Kernen ausführen. Diese Variante ist jedoch nicht mit einer normalen CPython-Installation gleichzusetzen. Thread-sicherer Code sollte deshalb nicht darauf vertrauen, dass der GIL den Zugriff auf gemeinsam verwendete Daten schützt.
Kernel-Threads und User-Threads
In der Informatik wird grundsätzlich zwischen Threads unterschieden, die vom
Betriebssystem verwaltet werden, und Threads, deren Verwaltung überwiegend im
Anwendungsprogramm oder in einer Laufzeitumgebung erfolgt. Für die praktische
Python-Programmierung ist diese Unterscheidung meist weniger wichtig. Das
threading-Modul von CPython arbeitet mit nativen Threads des
Betriebssystems.
Threads in Python
Für die Arbeit mit Threads stellt Python mehrere Abstraktionsebenen zur Verfügung:
_thread: eine sehr einfache Low-Level-Schnittstelle,threading: die übliche objektorientierte Thread-Schnittstelle,concurrent.futures.ThreadPoolExecutor: eine besonders komfortable High-Level-Schnittstelle für viele gleichartige Aufgaben.
Für neue Programme sollte man normalerweise threading oder
ThreadPoolExecutor verwenden.
Das Low-Level-Modul _thread
In Python 2 hieß das Low-Level-Modul thread. In Python 3 wurde es in
_thread umbenannt. Älteren Python-Code findet man deshalb gelegentlich
noch in der Form
from thread import start_new_thread
Unter Python 3 müsste dies beispielsweise heißen:
from _thread import start_new_thread
Für neue Programme ist diese Schnittstelle jedoch normalerweise nicht notwendig.
Das Modul threading baut auf _thread auf und bietet eine
komfortablere und besser strukturierte Schnittstelle.
Das threading-Modul
Wir beginnen mit einem einfachen Beispiel. Zehn Threads warten jeweils fünf Sekunden und geben vor und nach dem Warten eine Meldung aus:
import time
from threading import Thread
def sleeper(number):
print(f"Thread {number} sleeps for 5 seconds")
time.sleep(5)
print(f"Thread {number} woke up")
threads = []
for number in range(10):
thread = Thread(target=sleeper, args=(number,))
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
Mit Thread(...) wird zunächst ein Thread-Objekt erzeugt. Über
target wird angegeben, welche Funktion ausgeführt werden soll.
Die Argumente dieser Funktion werden als Tupel über args übergeben.
Die Methode start() startet den neuen Thread. Intern ruft der Thread
anschließend seine Methode run() auf. Wenn target angegeben
wurde, führt die Standardimplementierung von run() diese Funktion aus.
Man muss run() also nicht selbst überschreiben.
Mit join() wartet der aufrufende Thread, bis der betreffende Thread
beendet ist. Im vorigen Beispiel wartet das Hauptprogramm dadurch auf alle zehn
Threads.
Die Reihenfolge der letzten zehn Ausgaben ist nicht festgelegt. Beispielsweise könnte die Ausgabe so aussehen:
Thread 0 sleeps for 5 seconds Thread 1 sleeps for 5 seconds Thread 2 sleeps for 5 seconds Thread 3 sleeps for 5 seconds Thread 4 sleeps for 5 seconds Thread 5 sleeps for 5 seconds Thread 6 sleeps for 5 seconds Thread 7 sleeps for 5 seconds Thread 8 sleeps for 5 seconds Thread 9 sleeps for 5 seconds Thread 2 woke up Thread 0 woke up Thread 4 woke up Thread 1 woke up Thread 7 woke up Thread 3 woke up Thread 9 woke up Thread 5 woke up Thread 6 woke up Thread 8 woke up
Gemeinsam verwendete Daten und Race Conditions
Da mehrere Threads auf dieselben Objekte zugreifen können, muss man bei Änderungen gemeinsam verwendeter Daten vorsichtig sein. Betrachten wir beispielsweise:
counter += 1
Diese Anweisung ist logisch eine Read-Modify-Write-Operation: Der bisherige Wert wird gelesen, erhöht und anschließend wieder gespeichert. Man sollte sich bei solchen Operationen nicht darauf verlassen, dass mehrere Threads sie ohne Synchronisation korrekt gegeneinander abgrenzen.
Ein Lock sorgt für gegenseitigen Ausschluss (Mutual Exclusion).
Nur ein Thread kann gleichzeitig einen mit demselben Lock geschützten kritischen
Abschnitt ausführen:
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock:
counter += 1
threads = [threading.Thread(target=increment) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(counter)
Die Anweisung with lock: ruft beim Betreten des Blocks sinngemäß
lock.acquire() und beim Verlassen lock.release() auf.
Diese Schreibweise ist vorzuziehen, weil das Lock auch dann wieder freigegeben wird,
wenn innerhalb des Blocks eine Exception auftritt.
Für komplexere Kommunikationsmuster zwischen Threads bietet das
threading-Modul weitere Synchronisationsobjekte wie
Event, Condition, Semaphore und
Barrier. Für den sicheren Austausch von Daten zwischen Threads eignet
sich außerdem häufig queue.Queue.
Eigene Thread-Klassen
Statt eine Funktion über target zu übergeben, kann man auch von
threading.Thread ableiten und die Methode run()
überschreiben. Das folgende Beispiel prüft Zahlen auf ihre Primzahleigenschaft:
import threading
class PrimeNumber(threading.Thread):
def __init__(self, number):
super().__init__()
self.number = number
self.is_prime = None
def run(self):
if self.number < 2:
self.is_prime = False
return
divisor = 2
while divisor * divisor <= self.number:
if self.number % divisor == 0:
self.is_prime = False
return
divisor += 1
self.is_prime = True
numbers = [97, 99, 101, 1733]
threads = [PrimeNumber(number) for number in numbers]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
if thread.is_prime:
print(f"{thread.number} ist eine Primzahl")
else:
print(f"{thread.number} ist keine Primzahl")
Dieses Beispiel demonstriert die Definition einer eigenen Thread-Klasse. Als Beschleunigungstechnik wäre es für große Primzahlberechnungen bei einem normalen CPython mit GIL allerdings ungeeignet, denn die Primzahlprüfung ist CPU-intensiv. Für solche Aufgaben wären Prozesse oder andere Formen echter Parallelität zweckmäßiger.
ThreadPoolExecutor
Wenn viele gleichartige Aufgaben parallel oder nebenläufig ausgeführt werden sollen,
ist concurrent.futures.ThreadPoolExecutor häufig einfacher als das
manuelle Erzeugen und Verwalten einzelner Thread-Objekte.
Ein Thread-Pool enthält eine begrenzte Zahl von Worker-Threads. Neue Aufgaben werden dem Pool übergeben und von freien Worker-Threads ausgeführt. Nach Abschluss können die Ergebnisse wieder eingesammelt werden.
from concurrent.futures import ThreadPoolExecutor
import time
def work(number):
time.sleep(1)
return number, number * number
with ThreadPoolExecutor(max_workers=4) as executor:
results = executor.map(work, range(10))
for number, square in results:
print(number, square)
Der with-Block sorgt dafür, dass am Ende auf die noch laufenden Aufgaben
gewartet und der Executor ordnungsgemäß beendet wird.
Thread-Beispiel aus der Praxis: Rechner im Netzwerk finden
Ein typischer Anwendungsfall für Threads ist das Warten auf Netzwerkoperationen. Nehmen wir an, wir möchten in einem lokalen Netzwerk feststellen, welche IP-Adressen auf einen Ping antworten.
Ohne Threads könnte man die Rechner nacheinander prüfen:
import subprocess
def ping(ip):
result = subprocess.run(
["ping", "-c", "1", "-W", "1", ip],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return result.returncode == 0
for suffix in range(20, 30):
ip = f"192.168.178.{suffix}"
print(f"{ip}: {'alive' if ping(ip) else 'no response'}")
Die Optionen -c 1 und -W 1 entsprechen dem üblichen
Linux-Programm ping. Auf anderen Betriebssystemen unterscheiden sich
die Kommandozeilenoptionen.
Die sequenzielle Variante wartet bei jeder Adresse auf das Ergebnis, bevor die nächste Adresse geprüft wird. Das ist gerade bei nicht erreichbaren Rechnern unnötig langsam.
Mit einem ThreadPoolExecutor können viele Ping-Aufrufe gleichzeitig
warten:
from concurrent.futures import ThreadPoolExecutor
import subprocess
def ping(ip):
result = subprocess.run(
["ping", "-c", "1", "-W", "1", ip],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return ip, result.returncode == 0
addresses = [
f"192.168.178.{suffix}"
for suffix in range(20, 70)
]
with ThreadPoolExecutor(max_workers=20) as executor:
for ip, alive in executor.map(ping, addresses):
status = "alive" if alive else "no response"
print(f"{ip}: {status}")
Dieses Beispiel zeigt einen Bereich, in dem Threads besonders nützlich sind. Die
Python-Threads führen nur wenig eigene Rechenarbeit aus. Die meiste Zeit warten sie
auf die Antwort des externen ping-Programms. Während ein Thread wartet,
können andere Threads weitere Rechner prüfen.
Ein fehlgeschlagener Ping bedeutet allerdings nicht zwingend, dass ein Rechner ausgeschaltet ist. Firewalls können ICMP-Pakete blockieren oder Ping-Antworten unterdrücken.
Zusammenfassung
- Threads sind mehrere Ausführungsstränge innerhalb eines Prozesses.
- Threads teilen sich den Adressraum des Prozesses.
- Für gemeinsam veränderbare Daten können Synchronisationsmechanismen wie
Locknotwendig sein. threading.Threadist die übliche Python-Schnittstelle für einzelne Threads.join()wartet auf die Beendigung eines Threads.ThreadPoolExecutorist komfortabel, wenn viele ähnliche Aufgaben ausgeführt werden sollen.- Bei normalem CPython sind Threads besonders für I/O-lastige Aufgaben geeignet.
- CPU-intensive Aufgaben werden wegen des GIL durch normale CPython-Threads in der Regel nicht schneller.
- Free-threaded CPython-Builds ermöglichen dagegen echte parallele Ausführung von Threads, erfordern aber besonders sorgfältigen Umgang mit gemeinsam verwendeten Daten.

Buch kaufen
Buch kaufen
Buch kaufen
