None

Python Threading erklärt

Ein Python Thread ist nicht genug?Whanz | shutterstock.com



Die Laufzeitumgebung von Python wird standardmäßig in einem einzelnen Thread ausgeführt. Der Traffic wird dabei durch das Global Interpreter Lock (GIL) gesteuert. Das erzeugt in den meisten Fällen kein nennenswertes Bottleneck – außer, Sie wollen viele Workloads parallel ausführen.



Dieses Problem adressiert Python auf zweierlei Weise: Mit Threading und Multiprocessing. Beide Ansätze ermöglichen es, langlaufende Jobs in parallele Batches aufzuteilen, die nebeneinander bearbeitet werden können. Das kann die Abläufe – je nach Workload – entscheidend beschleunigen. Mindestens ist jedoch sichergestellt, dass mehrere Tasks sich nicht gegenseitig blockieren, während sie darauf warten, abgearbeitet zu werden.



In diesem Artikel beleuchten wir einen der komfortabelsten Wege, um sowohl Threads als auch Subprozesse in Python zu nutzen: das Pool-Objekt. Außerdem werfen wir auch einen Blick auf zwei neuere Python-Mechanismen für Parallel Processing und Concurrency:




Die „free-threaded“ oder „No-GIL“-Version von Python sowie



das Subinterpreter-System.




Beide sind zwar noch weit davon entfernt, im Developer-Alltag Einzug zu halten, mit Blick auf die Python-Zukunft aber von Bedeutung.



Python-Threads vs. Python-Prozesse



Python-Threads sind Arbeitseinheiten, die unabhängig voneinander ausgeführt werden. In CPython werden sie als Threads auf Betriebssystemebene implementiert, dabei allerdings durch das GIL „serialized“ – also nacheinander ausgeführt. Das gewährleistet, dass jeweils nur ein einzelner Thread Python-Objekte zu einem bestimmten Zeitpunkt modifizieren kann und die Daten nicht beschädigt werden. 



Python-Threads eignen sich (zumindest derzeit) nicht besonders gut, um CPU-gebundene Tasks gleichzeitig auszuführen. Sie sind jedoch nützlich, um Aufgaben zu organisieren, die Wartezeiten beinhalten. So kann Python kann beispielsweise Thread A oder Thread C ausführen, während Thread B auf die Antwort eines externen Systems wartet.



Python-Prozesse stellen vollständige Instanzen des Python-Interpreters dar, die unabhängig voneinander ausgeführt werden. Jeder Python-Prozess hat sein eigenes GIL – und eine eigene Kopie der Daten, die verarbeitet werden sollen. Mehrere Python-Prozesse können also parallel auf separaten Kernen ausgeführt werden. Die Nachteile dabei: Einen Python-Prozess aufzusetzen, dauert länger als im Fall eines Python-Threads – und zwischen Interpretern Daten auszutauschen geht wesentlich langsamer vonstatten als bei Threads.



Um zwischen Python-Threads und -Prozessen zu wählen, stehen Ihnen einige einfache Regeln zur Verfügung:




Threads empfehlen sich, wenn es um langfristig angelegte, E/A-gebundene Prozesse geht, die auf einen Python-externen Service angewiesen sind. Beispiele hierfür wären etwa parallel laufende Web-Scraping- oder File-Processing-Jobs.  



Threads sollten auch zum Einsatz kommen, wenn langfristig angelegte, CPU-gebundene Tasks bearbeitet werden sollen, die von einer externen C-Bibliothek gehändelt werden.  



Prozesse eignen sich hingegen vor allem für langfristig angelegte, CPU-gebundene Prozesse innerhalb von Python.




Thread- und Prozess-Pools in Python



Der einfachste Weg Threads und Prozesse für diverse Tasks einzusetzen, führt über das Pool-Objekt von Python. Dieses ermöglicht, wahlweise ein Set von Threads oder Prozessen zu definieren.  



Im folgenden Code-Beispiel erstellen wir aus einer Liste mit Zahlen von 1 bis 100 URLs und rufen diese parallel ab. Hierbei handelt es sich um einen E/A-gebundenen Prozess – es sollte also keinen erkennbaren Performance-Unterschied zwischen Thread und Prozess geben.



from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

from urllib.request import urlopen
from time import perf_counter

def work(n):
with urlopen("https://www.google.com/#{n}") as f:
contents = f.read(32)
return contents

def run_pool(pool_type):
with pool_type() as pool:
start = perf_counter()
results = pool.map(work, numbers)
print ("Time:", perf_counter()-start)
print ([_ for _ in results])

if __name__ == '__main__':
numbers = [x for x in range(1,16)]

# Run the task using a thread pool
run_pool(ThreadPoolExecutor)

# Run the task using a process pool
run_pool(ProcessPoolExecutor)



Wie Python-Multiprocessing funktioniert



Im obigen Beispiel stellt das concurrent.futures-Modul Pool-Objekte auf hoher Ebene sowohl für Threads (ThreadPoolExecutor) als auch für Prozesse (ProcessPoolExecutor) bereit. Beide Pool-Typen verfügen dabei über dieselbe API. Wie im obigen Beispiel zu sehen, ist es möglich, Funktionen aufzusetzen, die für beide Ansätze funktionieren.



Um Instanzen der work-Funktion an die verschiedenen Pool-Typen zu senden, nutzen wir run_pool. Standardmäßig verwendet jede Pool-Instanz einen einzelnen Thread oder Prozess pro verfügbarem CPU-Kern. Da es einen gewissen Mehraufwand mit sich bringt, einen Pool zu erstellen, sollten Sie es damit nicht übertreiben. Falls Sie über einen längeren Zeitraum viele Aufträge bearbeiten müssen, erstellen Sie zuerst den Pool – und entsorgen diesen erst, wenn Sie fertig sind. Über Executor-Objekte können Sie auch einen Kontextmanager verwenden, um Pools zu erstellen und zu entsorgen (with/as).



Um die Arbeit zu unterteilen, nutzen wir pool.map(). Das sorgt dafür, dass eine Funktion mit einer Liste von Argumenten auf jede Instanz angewendet wird und sogenannte Chunks entstehen (deren Größe sich anpassen lässt). Jeder Chunk wird an einen Worker-Thread oder -Prozess weitergeleitet.



Wird map ausgeführt, blockiert es normalerweise den entsprechenden Thread. In diesem Fall können Sie also nicht mehr tun, als abzuwarten. Um map asynchron über eine Callback-Funktion zu nutzen, die ausgeführt wird, wenn sämtliche Tasks abgeschlossen sind, verwenden Sie map_async.



Dieses grundlegende Beispiel enthält ausschließlich Threads und Prozesse, die einen individuellen State aufweisen. Falls Sie es mit langfristig angelegten, CPU-gebundenen Prozessen zu tun haben, bei denen Threads oder Prozesse Informationen miteinander austauschen müssen, hilft Ihnen die Python-Dokumentation an dieser Stelle weiter.  



CPU- vs. E/A-gebunden



Da unser Beispiel nicht CPU-gebunden ist, funktioniert es mit Threads und Subprozessen gleichermaßen gut. Für CPU-gebundene Aufgaben wären Threads hingegen nicht effektiv, wie Sie dem folgenden Code-Beispiel entnehmen können.



from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
from time import perf_counter

def work(n):
n = 0
for x in range(10_000_000):
n+=x
return n

def run_pool(pool_type):
with pool_type() as pool:
start = perf_counter()
results = pool.map(work, numbers)
print ("Time:", perf_counter()-start)
print ([_ for _ in results])

if __name__ == '__main__':
numbers = [x for x in range(1,16)]

# Run the task using a thread pool
run_pool(ThreadPoolExecutor)

# Run the task using a process pool
run_pool(ProcessPoolExecutor)



Wie Sie sicher festgestellt haben, nimmt der Thread-Pool deutlich mehr Zeit in Anspruch als der Prozess-Pool. Dabei ist der Overhead, der dabei entsteht, wenn letzterer gestartet wird, allerdings noch gar nicht berücksichtigt.



Die Executor-Abstraktion zu verwenden, bringt dabei den Vorteil, bestimmte Tasks, die nicht gut zu Threads passen, einfach in einem Prozess-Pool ausführen zu können. Dazu ändern Sie lediglich den Pool-Typ.



Python-Threads nach Version 3.13



Im Laufe der Jahre haben diverse Projekte versucht, eine Version des CPython-Interpreters ohne GIL auf die Beine zu stellen. Das würde Threads vollumfänglich mit Parallel-Processing-Fähigkeiten ausstatten. Leider waren die bisherigen Versuche das zu realisieren stets mit erheblichen Kompromissen verbunden. Der jüngste Versuch, das GIL zu entfernen, ist nun als PEP 703 verankert und behebt (bislang noch in experimenteller Form) viele dieser alten Probleme.



Sobald Sie Python 3.13 unter Windows installieren, haben Sie die Option, eine separate Version des Interpreters zu installieren, der „free-threaded“ funktioniert. Indem Sie die obigen Thread- und Prozess-Pool-Beispiele auf diesem Build ausführen, können Sie nachvollziehen, dass Threads und Prozesse etwa gleich gut funktionieren.



Der Free-Threaded-Build ist trotzdem noch weit davon entfernt, in der Produktion eingesetzt zu werden. Zuvor gilt es, die Leistungseinbußen von Single-threaded Python-Programmen zu minimieren. Bis es soweit ist, macht es aber Sinn, sich mit diesem neuen Build zu beschäftigen, beziehungsweise damit zu experimentieren. So bekommen Sie eine Vorstellung davon, wie Free-Threading im Vergleich zu Multiprocessing performt.



Python-Subinterpreter vs. Threads



Ein weiteres Feature, das CPython bereichern soll, ist das Konzept des Subinterpreters – siehe PEP 734. Jeder CPython-Prozess kann theoretisch eine oder mehrere Instanzen des aktuellen Python-Interpreters nebeneinander ausführen – jede mit ihrem eigenen GIL. Das schaltet diverse Free-Threading-Vorteile frei, ganz ohne Leistungseinbußen für Single-Threaded-Code – und inklusive GIL, das zum Einsatz kommt, wenn es nützlich ist.



Sollte PEP 734 vollständig akzeptiert werden, werden Python-Entwickler damit künftig dazu befähigt, mit InterpreterPoolExecutor Workloads zwischen Subinterpretern zu verteilen und die Ergebnisse zu synchronisieren. Im Gegensatz zu Threads können Subinterpreter jedoch ihren State nicht teilen. Deshalb müssen sie auf eine Queue oder eine ähnliche Abstraktion zurückgreifen, um Daten hin und her zu schicken. (fm)



Sie wollen weitere interessante Beiträge zu diversen Themen aus der IT-Welt lesen? Unsere kostenlosen Newsletter liefern Ihnen alles, was IT-Profis wissen sollten – direkt in Ihre Inbox!