Als interpretierte Programmiersprache gehört Python nicht zu den schnellsten Entwicklerwerkzeugen. Wir zeigen, wie Sie der Schlange mit Nebenläufigkeit Beine machen.
Python lässt sich leicht erlernen und bringt mit seiner großen Standardbibliothek sowie den zahlreichen Erweiterungspaketen alles mit, was das Herz eines Datenwissenschaftlers begehrt. Die Sprache vereinfacht zudem das Programmieren, sodass das Prototyping schneller von der Hand geht. Selbst Nicht-Programmierer kommen mit Python schnell zurecht.
Wo wissenschaftliche Anwendungen aufwendige Berechnungen mit sehr großen Datenmengen erfordern, kommt schnell der Wunsch nach Nebenläufigkeit auf. Die lässt sich aber nicht so einfach realisieren: CPython setzt eine Sperre für seinen intern geteilten globalen Status, wodurch eigentlich nicht mehr als ein Thread gleichzeitig laufen kann.
Der vorliegende, zweiteilige Artikel wirft daher einen Blick auf die Möglichkeiten, die Entwicklern zur Verfügung stehen, um Prozesse in Python zu parallelisieren. Dazu gilt es, zunächst einige Designentscheidungen in Python zu erläutern.
Global Interpreter Lock
Guido van Rossum, der Erfinder von Python, hat sich bei der Entwicklung der Sprache unter anderem an Donald E. Knuth orientiert, dem Begründer des Literate Programming. Von Knuth stammt der Ausspruch: “In etwa 97 Prozent aller Fälle rät es sich an, den Drang zu kleineren Effizienzsteigerungen zu ignorieren: Vorzeitige Optimierung ist die Wurzel allen Übels. Dennoch dürfen wir uns bei den kritischen 3 Prozent [des Codes] die Gelegenheit [zur Optimierung] nicht entgehen lassen.”
Das greift in unserem Fall insofern, als Nebenläufigkeit den Code komplexer und fehleranfälliger macht und es auch erschwert, ihn zu debuggen. Nebenläufiger Code ist sehr viel anfälliger gegen sogenannte Race Conditions. Will man nun Programme skalieren, stellt sich immer die Frage, auf welche Weise das geschehen soll. Alex Martelli, der Autor des “Python Cookbook” entwarf ein Modell der Skalierbarkeit, das gemeinsam mit den Grundzügen des Literate Programming lange Zeit die Designentscheidungen in der Python-Entwicklung bestimmte. Es unterscheidet drei Arten der Skalierung (Tabelle “Skalierung nach Martelli”).
|
CPU-Cores |
Beschreibung |
|---|---|
|
1 |
Programme mit einem Thread und einem Prozess. |
|
2 bis 8 |
Programme mit mehreren Threads und Prozessen. |
|
Mehr als 8 |
Verteiltes Computing. |
Programme mit einem einzelnen Thread profitieren von leistungsfähigeren CPUs. Führt man Single-Thread-Programme auf schnelleren CPUs aus, laufen sie auch deutlich schneller. Diese Form der Skalierung stößt jedoch schnell an Grenzen.
Auch die zweite Form – Programme mit mehreren Threads – skalieren in Python nicht unbegrenzt. Das liegt am Global Interpreter Lock von Python. Dabei handelt es sich um einen Mutex, also ein Verfahren, das durch gegenseitigen Ausschluss (mutual exclusion) verhindert, dass mehrere Threads gleichzeitig Python-Bytecodes ausführen. Die nicht Thread-sichere Speicherverwaltung von CPython erfordert ein solches Verfahren, um Threads zu sichern und Race Conditions zu verhindern. Dadurch gehen aber auch Skalierungsmöglichkeiten verloren. Zwar gibt es mit PyPy [1] eine Python-Implementierung, die ohne GIL auskommt, sie konnte sich bisher aber noch nicht gegen die Referenzimplementierung CPython durchsetzen. Das liegt auch daran, dass PyPy zwar mit reinem Python-Code klarkommt, vorhandene C-Erweiterungen wie Pillow [2] oder NumPy [3] jedoch emulieren muss, was wenig effizient ist.
Immerhin konnten die Entwickler inzwischen mit dem Threading-Modul von CPython erreichen, dass sich mehrere Threads gleichzeitig ausführen lassen, die sich dabei allerdings immer noch einen Status teilen und die auf einer CPU laufen müssen. Dieser Workaround eignet sich daher vor allem für I/O-lastige Aufgaben. Versuche, GIL ganz aus CPython zu entfernen, stießen dagegen auf erhebliche Schwierigkeiten. Das Gilectomy-Projekt [4] musste feststellen, dass sich die Implementierung kaum optimieren lässt, ohne inkompatible Änderungen herbeizuführen. Das erscheint jedoch bei einer so beliebten Sprache wie Python inakzeptabel. Die zweite Kategorie nach der Martelli-Klassifizierung, in der Programme mit mehreren Threads auf mehreren Kernen laufen, lässt sich damit nur bedingt nutzen.
Was bleibt, um bei bei aufwendigen Berechnungen schnellere Ergebnisse zu erzielen, wäre die dritte Kategorie, also das verteilte Rechnen auf vielen Maschinen zugleich. In jedem Fall steht der Wunsch nach Performance-Verbesserungen ganz oben auf der Wunschliste viele Python-Programmierer. Laut einer Umfrage der Python Software Foundation aus dem Jahr 2020 wünschen sich 20 Prozent der Befragten mehr Geschwindigkeit und 15 Prozent eine bessere Nebenläufigkeit und Parallelisierung. Mittlerweile sieht es so aus, als käme Bewegung in die Sache – und zwar durch keinen Geringeren als den Schöpfer von Python höchstpersönlich.
Faster CPython
Auf der PyCon US im Mai diesen Jahres stellte Guido van Rossum ein neues Projekt vor, das die Geschwindigkeit von Python verdoppeln soll [5]. Es basiert auf einem Plan von Mark Shannon [6] und den Erfahrungen mit dem Projekt HotPy [7]. Das von Microsoft finanzierte Projektteam von Faster CPython besteht aus drei Personen: Eric Snow, Mark Shannon und Guido van Rossum.
Um offen mit den übrigen Python-Core-Entwicklern zusammenarbeiten zu können, verfasste Mark Shannon das Python Enhancement Proposal PEP 659 “Specializing Adaptive Interpreter” [8]. Daneben stehen ein offenen Issue Tracker [9] sowie Werkzeuge zum Sammeln von Bytecode-Statistiken [10] zur Verfügung. Die Pläne sehen die Entwicklung eines adaptiven, spezialisierten Bytecode-Interpreters und spezieller Optimierungen vor. Dazu gehören eine Verbesserung des Frame-Stacks, schnellere Aufrufe, eine Optimierung der Zuordnung sowie ein Zero-Overhead Exception Handling.
Von Faster CPython profitieren voraussichtlich vor allem CPU-intensiver Python-Code und mit Python erstellte Websites. Auf die Performance von bereits in C geschriebenem Code, von I/O-lastigen Prozessen und von Multi-Threading-Code dürfte es keine oder nur geringe Auswirkungen haben. Hier könnten Lösungen wie Numba [11] und das bereits erwähnte PyPy zielführender sein. Der JIT-Compiler Numba übersetzt vor allem wissenschaftlichen Python- und NumPy-Code in schnellen Maschinencode. PyPy bringt zwar einen universelleren JIT-Compiler mit, ist jedoch bei der Emulation vorhandener C-Erweiterungen ineffizient.
Schneller zum Ziel
Die durch GIL verursachten Limitierungen von Python lassen sich durch Nebenläufigkeit bis zu einem gewissen Grad aufheben. Dabei spaltet man ein Programm mithilfe des Threading-Moduls in mehrere Teile auf, die gleichzeitig arbeiten. Das beschleunigt die Ausführung, da sich einzelne Arbeitsschritte so in mehreren Threads, auf mehreren Prozessoren oder sogar auf verschiedenen Servern erledigen lassen.
Hardwareseitig sind der Nebenläufigkeit kaum Grenzen gesetzt, softwareseitig gibt es jedoch mehrere Dinge zu beachten. Sobald Prozesse miteinander kommunizieren, wird die Situation komplex, da sie aufeinander warten müssen und sich gegenseitig blockieren können. In Python gibt es verschiedene Möglichkeiten, Prozesse nebenläufig zu organisieren: Multithreading, Multiprocessing sowie asynchrone Ein- und Ausgabevorgänge.
Zehn Threads zählen bis zehn
Multithreading hat den Vorteil, dass sich Threads einen gemeinsamen Status teilen, sodass sich auch serielle Aufgaben via Multithreading erledigen lassen. Ein weiterer Vorteil: Der Wechsel der Tasks erfolgt automatisch, was man als präemptives Multitasking bezeichnet.
Multithreading kann man einfach mit vorhandenem Code und Werkzeugen verwenden, so lange man Locks [12] für kritische Abschnitte hinzufügt. Die Tatsache, dass alles auf einer CPU verarbeitet werden muss, die zusätzlich auch noch Task-Switches und Synchronisation zu berechnen hat, setzt der Performance-Optimierung per Multithreading jedoch Grenzen.
Im Folgenden soll ein simples Beispiel verdeutlichen, wie man mit Multithreading seriell arbeiten kann: das Zählen bis 10. Wenn wir die Aufgabe auf zehn Threads verteilen, muss jeder davon wissen, bis zu welcher Zahl bereits hochgezählt wurde. Der Code dafür sieht zunächst sehr einfach (Listing 1).
Listing 1
Bis 10 zählen
import threading
counter = 0
def worker():
global counter
counter += 1
print('%d' % counter)
print('Starting up')
for i in range(10):
threading.Thread(target=worker).start()
print('Finishing up')
Testen auf Race Conditions
Führt man den Code aus Listing 1 aus, wird schnell klar, dass das Inkrementieren beim Hochzählen anfällig für Race Conditions ist. Dasselbe gilt übrigens auch für die Print-Funktion im Beispielcode. Das Ergebnis jeder einzelnen Zähloperation hängt vom zeitlichen Verhalten der anderen zählenden Einzeloperationen ab. Es gibt mit Fuzzing eine Technik, die Race Conditions provoziert, was zunächst übersehene Fehler sichtbar macht (Listing 2). Damit lassen sich Race Conditions zwar nicht ausschließen, aber sie treten immerhin zutage.
Listing 2
Fuzz Test
import threading, time, random
FUZZ = True
def fuzz():
if FUZZ:
time.sleep(random.random())
counter = 0
def worker():
global counter
fuzz()
oldcnt = counter
fuzz()
counter = oldcnt + 1
fuzz()
print('%d' % counter)
fuzz()
print('Starting up')
fuzz()
for i in range(10):
threading.Thread(target=worker).start()
fuzz()
print('Finishing up')
fuzz()
Race Conditions vermeiden
Beim Multithreading mit Queues werden gemeinsam genutzte Ressourcen in einem Thread ausgeführt, was in diesem Kontext Race Conditions ausschließt. Über atomare Message-Queues kommunizieren weitere Threads mit einem zentralen Thread. Via »join()« lassen sich die Ergebnisse aller nebenläufigen Threads einsammeln. Führt man »join()« auf der Queue selbst aus, wartet das Programm auch nicht auf unendlich laufende Daemon-Threads, ehe es alle angeforderten Aufgaben als erledigt markiert. Dabei gilt es zu beachten, dass in Multithreading-Code globale Variablen ihren Zustand ändern können. Abhilfe bietet hier »threading.local()«, das innerhalb eines Threads global ist. Damit sieht der Code so aus wie in Listing 3.
Listing 3
Zählen mit Queues
import threading, time, random, queue
FUZZ = True
def fuzz():
if FUZZ:
time.sleep(random.random())
counter = 0
counter_queue = queue.Queue()
def counter_manager():
global counter
while True:
increment = counter_queue.get()
fuzz()
oldcnt = counter
fuzz()
counter = oldcnt + increment
fuzz()
print_queue.put([
'The count is %d' % counter,
])
fuzz()
counter_queue.task_done()
t = threading.Thread(target=counter_manager)
t.daemon = True
t.start()
del t
print_queue = queue.Queue()
def print_manager():
while True:
job = print_queue.get()
fuzz()
for line in job:
print(line)
fuzz()
print()
fuzz()
print_queue.task_done()
fuzz()
t = threading.Thread(target=print_manager)
t.daemon = True
t.start()
del t
def worker():
counter_queue.put(1)
fuzz()
print_queue.put(['Starting up'])
fuzz()
worker_threads = []
for i in range(10):
t = threading.Thread(target=worker)
worker_threads.append(t)
t.start()
fuzz()
for t in worker_threads:
fuzz()
t.join()
counter_queue.join()
fuzz()
print_queue.put(['Finishing up'])
fuzz()
print_queue.join()
fuzz()
Multithreading mit Locks
Einfacher als mit Queues klappt das Multithreading mit Locks (Listing 4), zumindest mit dem einfachen Code aus unserem Beispiel. Bei komplexerem Code kann Locking seinerseits sehr unübersichtlich werden. In einem solchen Fall bieten sich atomare Queues als bessere Wahl an.
Listing 4
Zählen mit Locks
import threading, time, random
FUZZ = True
def fuzz():
if FUZZ:
time.sleep(random.random())
counter_lock = threading.Lock()
printer_lock = threading.Lock()
counter = 0
def worker():
global counter
with counter_lock:
oldcnt = counter
fuzz()
counter = oldcnt + 1
fuzz()
with printer_lock:
print('%d' % counter)
fuzz()
with printer_lock:
print('Starting up')
fuzz()
worker_threads = []
for i in range(10):
t = threading.Thread(target=worker)
worker_threads.append(t)
t.start()
fuzz()
for t in worker_threads:
t.join()
fuzz()
with printer_lock:
print('Finishing up')
fuzz()
Ausblick
Im zweiten Teil dieses Artikels in der nächsten Ausgabe sehen wir uns anhand konkreter Codebeispiele an, wie sich Nebenläufigkeit in Python auch mit Multiprocessing und unter Verwendung von Asyncio bewerkstelligen lässt. Dabei kommen dann auch die Vor- und Nachteile der einzelnen Techniken zur Sprache. (jcb/jlu)
Infos
- PyPy: https://www.pypy.org
- Pillow: https://pillow.readthedocs.io/en/stable
- NumPy: https://numpy.org
- Gilectomy-Project: https://pythoncapi.readthedocs.io/gilectomy.html
- Faster CPython: https://github.com/faster-cpython
- Implementation Plan: https://github.com/markshannon/faster-cpython/blob/master/plan.md
- HotPy: http://code.google.com/p/hotpy
- PEP 659: https://www.python.org/dev/peps/pep-0659/
- Issue Tracker: https://github.com/faster-cpython/ideas/issues
- Statistik-Tools: https://github.com/faster-cpython/tools
- Numba: http://numba.pydata.org
- Locks: https://docs.python.org/3/library/threading.html#threading.Lock





