Threads in Python

Allgemeine Definition eines Threads

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.

Threads und gemeinsam verwendete Daten

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:

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

Ping im Netzwerk

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