Einführung in Multithreading, Multiprocessing und async

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 war, dass im Laufe der Zeit die zweite Kategorie immer unbedeutender wird, da einzelne Kerne werden immer leistungsfähiger und große Datensätze immer größer werden.

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.

Ü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 erzeugt 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.

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.