Einführung in die Nebenläufigkeit

Bei der Entwicklung von Code kann es häufig zu Kompromissen zwischen verschiedenen Implementierungen kommen. Zu Beginn der Entwicklung eines Algorithmus ist es jedoch meist kontraproduktiv, sich um die Effizienz des Codes zu kümmern.

»Wir sollten kleine Effizienzsteigerungen in sagen wir etwa 97 % der Zeit, vergessen: Vorzeitige Optimierung ist die Wurzel allen Übels. Dennoch sollten wir unsere Chancen in diesen kritischen 3 % nicht verpassen.«1

1

Donald Knuth, Begründer des Literate programming, in Computer Programming as an Art (1974)

Martelli’s Modell der Skalierbarkeit

Anzahl Kerne

Beschreibung

1

Einzelner Thread und einzelner Prozess

2–8

Mehrere Threads und mehrere Prozesse

>8

Verteilte Verarbeitung

Martelli’s Beobachtung: Im Laufe der Zeit wird die zweite Kategorie immer unbedeutender: Einzelne Kerne werden immer leistungsfähiger und große Datensätze immer größer.

Global Interpreter Lock (GIL)

CPython verfügt über eine Sperre für seinen intern geteilten globalen Status. Dies hat zur Folge, dass nicht mehr als ein Thread gleichzeitig laufen kann.

Für I/O-lastige Anwendungen ist das GIL kein großes Problem; bei CPU-lastigen Anwendungen führt die Verwendung von Threading jedoch zu einer Verlangsamung. Dementsprechend ist Multi-Processing für uns spannend um mehr CPU-Zyklen zu erhalten.

Literate Programming und Martelli’s Modell der Skalierbarkeit bestimmten die Design-Entscheidungen zur Performance von Python über lange Zeit. An dieser Einschätzung hat sich bis heute wenig geändert: Entgegen der intuitiven Erwartungen führen mehr CPUs und Threads in Python zunächst zu weniger effizienten Anwendungen. Dennoch wünschen sich laut der Umfrage von 2020 zu den gewünschten Python-Features 20% Performance-Verbesserungen und 15% bessere Nebenläufigkeit und Parallelisierung. Das Gilectomy-Projekt, das das GIL ersetzen sollte, stieß jedoch auch noch auf ein weiteres Problem: Die Python C-API legt sehr viele Implementierungsdetails offen. Damit würden Leistungsverbesserungen jedoch schnell zu inkompatiblen Änderungen führen, die dann vor allem bei einer so beliebten Sprache wie Python inakzeptabel erscheinen. Dennoch gibt jedoch bereits einige Lösungen:

  • Numba ist ein JIT-Compiler, der vor allem wissenschaftlichen Python- und NumPy-Code in schnellen Maschinencode übersetzt.

  • PyPy mit einem universelleren JIT-Compiler, der jedoch vorhandene C-Erweiterung wie NumPy emulieren muss, was wirklich ineffizient ist.

Faster Cpython

Auf der PyCon US im Mai 2021 stellte Guido van Rossum mit Faster CPython ein Projekt vor, das die Geschwindigkeit von Python 3.11 verdoppeln soll. Die Zusammenarbeit mit den anderen Python-Kernentwicklern ist in PEP 659 – Specializing Adaptive Interpreter geregelt. Zudem gibt es einen offenen Issue Tracker und diverse Werkzeuge zum Sammeln von Bytecode-Statistiken. Von den Änderungen profitieren dürfte vor allem CPU-intensiver Python-Code; bereits in C geschriebener Code, I/O-lastige Prozesse und Multithreading-Code dürften hingegen kaum profitieren.

Multithreading, Multiprocessing und asynchrone Kommunikation

Überblick

Kriterium

Multithreading

Multiprocessing

asyncio

Trennung

Threads teilen sich einen Status.

Das Teilen eines Status kann jedoch zu Race Conditions führen, d.h. die Ergebnisse einer Operation können vom zeitlichen Verhalten bestimmter Einzeloperationen abhängen.

Die Prozesse sind unabhängig voneinander.

Sollen sie dennoch miteinander kommunizieren, wird Interprocess communication (IPC), object pickling und anderer Overhead nötig.

Mit run_coroutine_threadsafe() können asyncio-Objekte auch von anderen Threads verwendet werden.

Fast alle asyncio-Objekte sind nicht threadsicher.

Wechsel

Threads wechseln präemptiv, d.h., es muss kein expliziter Code hinzugefügt werdenm um einen Wechsel der Tasks zu verursachen.

Ein solcher Wechsel ist jedoch jederzeit möglich; dementsprechend müssen kritische Bereiche mit lock geschützt werden.

Sobald ihr den Prozess erhaltet, sollten deutliche Fortschritte gemacht werden. Ihr solltet also nicht zu viele Roundtrips hin und her machen.

asyncio wechselt kooperativ, d.h., es muss explizit yield oder await angegeben werden um einen Wechsel herbeizuführen. Ihr könnt daher die Aufwände für diese Wechsel sehr gering halten.

Tooling

Threads erfordern sehr wenig Tooling: Lock und Queue.

Locks sind in nicht-trivialen Beispielen schwer zu verstehen. Bei komplexen Anwendungen sollten daher besser atomare Message Queues oder asyncio verwendet werden.

Einfaches Tooling u.a. mit map und imap_unordered um einzelne Prozesse in einem einzelnen Thread zu testen, bevor zu Multiprocessing gewechselt wird.

Wird IPC oder object pickling verwendet, wird das Tooling jedoch aufwändiger.

Zumindest bei komplexen Systemen führt asyncio einfacher zum Ziel als Multithreading Locks.

asyncio benötigt jedoch eine große Menge von Werkzeugen: futures, Event Loops und nicht blockierende Versionen von fast allem.

Performance

Multithreading führt bei IO-lastigen Aufgaben zu den besten Ergebnissen.

Die Leistungsgrenze für Threads ist eine CPU abzüglich der Kosten für Task-Switches und Aufwänden für die Synchronisation.

Die Prozesse können auf mehrere CPUs verteilt werden und sollten daher für CPU-lastige Aufgaben verwendet werden.

Für die Kommunikation und die Synchronisierung der Prozesse entstehen jedoch ggf. zusätzliche Aufwände.

Der Aufruf einer reinen Python-Funktion erheugt mehr Overhead als die erneute Anfrage eines generator oder awaitable – d.h., asyncio kann die CPU effizient auslasten.

Für CPU-intensive Aufgaben ist jedoch Multiprocessing besser geeignet.

Aufgabenplaner

ipyparallel, Dask und Ray können Aufgaben in einem Cluster verteilen. Dabei haben sie unterschiedliche Schwerpunkte:

Resümee

Es gibt nicht die eine ideale Implementierung von Nebenläufigkeit – jeder der im folgenden vorgestellten Ansätze hat spezifische Vor- und Nachteile. Bevor ihr euch also entscheidet, welchen Ansatz ihr verfolgen wollt, solltet ihr die Performance-Probleme genau analysieren und anschließend die jeweils passende Läsung wählen. In unseren Projekten verwenden wir dabei häufig mehrere Ansätze, je nachdem, für welchen Teil die Performance optimiert werden soll.