Python Concurrency Tutorial

Dies ist eine Zusammenfassung meines PyCon 2020 Tutorials. Das Originalvideo und den Quellcode finden Sie hier: https://github.com/santiagobasulto/pycon-concurrency-tutorial-2020

https://www.youtube.com/watch?v=18B1pznaU1o

Dies ist eine kurze Anleitung/Tutorial, wie man effektiv nebenläufige Programme mit Python schreibt. Gleichzeitigkeit in Python kann verwirrend sein. Es gibt mehrere Module (threading, _thread, multiprocessing, subprocess). Es gibt auch die viel gehasste GIL, aber nur für CPython (PyPy und Jython haben keine GIL). Collections sind nicht thread-sicher, abgesehen von einigen Implementierungsdetails in CPython.

Das Ziel dieses Tutorials ist es, bodenständige Empfehlungen zu geben, wie man an jeden Anwendungsfall von nebenläufigem (oder parallelem) Code herangeht.

Sie werden feststellen, dass ich im Gegensatz zu einigen anderen traditionellen „Multithreading only“ Python-Tutorials mit einer Zusammenfassung sowohl der Computerarchitektur als auch der Konzepte von Betriebssystemen beginne. Das Verständnis dieser grundlegenden Konzepte ist für die korrekte Erstellung nebenläufiger Programme unerlässlich. Bitte überspringen Sie diese Abschnitte, wenn Sie mit diesen Themen bereits vertraut sind.

Dieses Tutorial ist in folgende Abschnitte unterteilt:

  • Gleichzeitigkeit vs. Parallelität
  • Computerarchitektur
  • Die Rolle des Betriebssystems
  • Threads mit Python
  • Thread-Synchronisation
  • Multiprocessing
  • High Level Bibliotheken: concurrent.futures und parallel

Gleichzeitigkeit vs. Parallelität

Parallelität ist, wenn mehrere Aufgaben gleichzeitig ausgeführt werden. Das ist das eigentliche Ziel von nebenläufigen Programmen. Gleichzeitigkeit ist weniger als Parallelität, es bedeutet, dass wir mehrere Aufgaben starten und sie in der gleichen Zeitspanne jonglieren. Zu einem bestimmten Zeitpunkt führen wir jedoch immer nur eine Aufgabe aus.

Angewandt auf das Kochen wären dies Beispiele für Gleichzeitigkeit und Parallelität.

Bei der Gleichzeitigkeit ist nur eine Person in der Küche:

  • Beginnen Sie mit dem Schneiden der Zwiebeln
  • Beginnen Sie mit dem Erhitzen der Pfanne
  • Beenden Sie das Schneiden der Zwiebeln

In diesem Fall ist es klar, dass wir nicht mehrere Dinge GLEICHZEITIG tun können. Wir können sie alle beginnen, aber wir müssen hin- und herspringen, um sie zu kontrollieren.

Beim Parallelismus sind mehrere Personen in der Küche:

  • Person 1 schneidet Zwiebeln
  • Person 2 schneidet rote Paprika
  • Person 3 wartet, dass die Pfanne heiß wird

In diesem Fall werden mehrere Aufgaben gleichzeitig erledigt.

Computerarchitektur

Alle modernen Computer lassen sich durch die von-Neumann-Architektur vereinfachen, die aus 3 Hauptkomponenten besteht: Rechnen (CPU), Speicher (RAM), E/A (Festplatten, Netzwerke, Videoausgabe).

Die von Neumann-Architektur

Ihr Code wird immer Aufgaben ausführen, die mit jeder dieser Komponenten zusammenhängen. Einige Anweisungen sind „rechnerisch“ (x + 1), einige sind speicherbezogen (x = 1) und einige sind E/A (fp = open('data.csv')).

Um die Details der Gleichzeitigkeit in Python richtig zu verstehen, ist es wichtig, die unterschiedlichen „Zugriffszeiten“ dieser Komponenten im Auge zu behalten. Der Zugriff auf die CPU ist viel schneller als der Zugriff auf den RAM, ganz zu schweigen von der E/A. Es gibt eine Studie, in der die Latenzzeiten von Computern mit den relativen Zeiten von Menschen verglichen werden: Wenn ein CPU-Zyklus 1 Sekunde menschlicher Zeit entspricht, dauert eine Netzwerkanfrage von San Francisco nach Hongkong 11 Jahre.

Latenz auf menschlicher Ebene (Quelle)

Der radikale Zeitunterschied bei diesen „Anweisungen“ wird für die Diskussion von Threads und der Python-GIL von grundlegender Bedeutung sein.

Die Rolle des Betriebssystems

Wenn ich einen Universitätskurs über Nebenläufigkeit aufbauen würde, würde ich eine ganze Vorlesung nur über die Geschichte der Betriebssysteme halten. Nur so kann man verstehen, wie sich die Gleichzeitigkeitsmechanismen entwickelt haben.

Die ersten verbraucherorientierten Betriebssysteme (man denke an MS-DOS) unterstützten kein Multitasking. Es konnte höchstens ein Programm zu einer bestimmten Zeit laufen. Es folgten die „kooperativen Multitasking“-Systeme (Windows 3.0). Diese Art von Multitasking ähnelte eher einer „Ereignisschleife“ (man denke an Pythons asyncio) als einem modernen Multithreading-Programm. In einem kooperativen Multitasking-Betriebssystem ist die Anwendung dafür verantwortlich, die CPU „freizugeben“, wenn sie nicht mehr benötigt wird, und andere Anwendungen laufen zu lassen. Dies hat offensichtlich nicht funktioniert, da man nicht darauf vertrauen kann, dass jede Anwendung (oder sogar die Entwickler) scharfsinnig genug sind, um die richtige Entscheidung zu treffen, wann die CPU freigegeben werden soll. Moderne Betriebssysteme (Windows 95, Linux usw.) haben ihr Multitasking-Design auf „präemptives Multitasking“ umgestellt, bei dem das Betriebssystem entscheidet, welche Anwendungen wie oft und wie lange CPU-Leistung erhalten.

Das Betriebssystem entscheidet also, welche Prozesse wie lange laufen dürfen. Das ist es, was der Anwendung das Gefühl gibt, dass sie „Multitasking“ betreibt, auch wenn sie nur eine CPU hat. Der Scheduler des Betriebssystems schaltet die Prozesse sehr schnell hin und her.

Im folgenden Diagramm laufen drei Prozesse (P1, P2, P3) gleichzeitig und teilen sich dieselbe CPU (die blaue Linie ist die CPU-Zeit). P1 läuft eine ganze Weile, bevor er schnell auf P2 umgeschaltet wird, dann auf P3 und so weiter.

3 Prozesse laufen „gleichzeitig“

Der Prozess

Ein Prozess ist die Abstraktion, die das Betriebssystem verwendet, um Ihren Code auszuführen. Es wickelt Ihren Code in diesen Prozess ein und weist Speicher und andere gemeinsame Ressourcen zu. Die Prozessabstraktion ermöglicht es dem Betriebssystem, zwischen laufenden Programmen zu „unterscheiden“, so dass sie Konflikte miteinander erzeugen. So kann beispielsweise Prozess 1 nicht auf den von Prozess 2 reservierten Speicher zugreifen. Sie ist auch für die Sicherheit der Benutzer wichtig. Wenn Benutzer 1 einen Prozess startet, kann dieser Prozess die Dateien lesen, auf die dieser Benutzer Zugriff hat.

Das folgende Diagramm enthält eine visuelle Darstellung eines Prozesses. Der Prozess enthält den auszuführenden Code, zugewiesenen Arbeitsspeicher und alle vom Programm erzeugten Daten (Variablen, geöffnete Dateien usw.).

Eine visuelle Darstellung eines OS-Prozesses

Jetzt können wir sehen, wie dasselbe „Programm“ (dasselbe Stück Code) in mehreren Prozessen gleichzeitig läuft.

Wir können nun zur zweiten Phase übergehen: Wie wird Gleichzeitigkeit (oder sogar Parallelität) tatsächlich erreicht? Aus Ihrer Sicht als Entwickler gibt es zwei Mechanismen, die Sie einsetzen können, um „Gleichzeitigkeit“ zu erreichen: Multithreading oder Multiprocessing.

Multithreading

Threads sind gleichzeitige Ausführungsabläufe innerhalb desselben Prozesses. Der Prozess startet mehrere Ausführungsstränge, die sich unabhängig voneinander entwickeln, je nachdem, was sie zu tun haben.

Angenommen, wir haben ein Programm, das Daten von drei Websites herunterladen und dann für einen Abschlussbericht kombinieren muss. Jede Website braucht etwa zwei Sekunden, um mit den Daten zu antworten. Ein Programm mit einem einzigen Thread würde etwa so aussehen:

d1 = get_website_1() # 2 secs
d2 = get_website_2() # 2 secs
d3 = get_website_3() # 2 secscombine(d1, d2, d3) # very fast

Die Gesamtausführungszeit beträgt >6 Sekunden:

Ein Programm mit nur einem Thread

Ein Programm mit mehreren Threads beginnt damit, die Daten von den drei Websites gleichzeitig herunterzuladen, und wartet, bis sie alle fertig sind, um den endgültigen Bericht zu erstellen.

t1 = Thread(get_website_1)
t2 = Thread(get_website_2)
t3 = Thread(get_website_3)wait_for_threads()d1 = t1.result()
d2 = t2.result()
d3 = t3.result()combine(d1, d2, d3)

Die Ausführung wird nun etwa ~2 Sekunden dauern, da die drei Websites etwa zur gleichen Zeit antworten werden:

Ein multithreaded Programm

In Python werden Threads mit der Klasse Thread, aus dem Modul threading, erstellt, und man kann mehrere Beispiele im ersten Notebook aus dem Repo meines Tutorials sehen: 1. Thread Basics.ipynb.

Threads und gemeinsam genutzte Daten

Threads werden explizit von deinem Programm erstellt, so dass sie innerhalb desselben Prozesses leben. Das heißt, sie teilen sich alle Daten des Prozesses. Sie können lokale Variablen lesen und ändern und auf denselben Speicher oder dieselben Ressourcen (Dateien, Netzwerk-Sockets usw.) zugreifen.

Threads teilen sich Prozessdaten

Das kann zu einigen Problemen führen, insbesondere zu unerwarteten Mutationen von gemeinsam genutzten Daten und Race Conditions. Ein Beispiel für eine Race Condition finden Sie im Notizbuch 2. Thread Data & Race Conditions.ipynb. Im gleichen Notizbuch werden einige Mechanismen zur „Synchronisierung“ von Threads vorgestellt, um Race Conditions zu vermeiden, aber dieselben Mechanismen können potenziell das Problem eines Deadlocks verursachen. Es gibt ein Beispiel für ein Deadlock im Notizbuch: 3. Deadlocks.ipynb.

Producers, Consumers and Queues

Synchronisationsprobleme können durch einen einfachen Paradigmenwechsel gelöst werden.

Der beste Weg, Synchronisationsprobleme zu vermeiden, ist, die Synchronisation ganz zu vermeiden.

Dies wird durch die Verwendung des Producer-Consumer-Musters erreicht. Producer-Threads erstellen „ausstehende Aufgaben“, die auszuführen sind, und stellen sie in eine gemeinsame Warteschlange. Consumer-Threads lesen die Aufgaben aus der Warteschlange und erledigen die eigentliche Arbeit.

In Python ist die Klasse Queue aus dem Modul queue eine thread-sichere Warteschlange, die für diese Zwecke verwendet wird.

Ein Beispiel für ein Producer-Consumer-Modell findet sich im Notebook
4. Producer-Consumer model.ipynb.

The Python Global Interpreter Lock

Das GIL ist eines der meistgehassten Features von Python. Eigentlich ist sie gar nicht so schlimm, aber sie hat einen schrecklichen Ruf.

Die GIL ist eine „globale“ Sperre, die vom Python-Interpreter gesetzt wird und garantiert, dass nur ein Thread zu einem bestimmten Zeitpunkt auf die CPU zugreifen kann. Das hört sich dumm an: Warum sollten Sie sich um Multithreading-Code kümmern, wenn ohnehin nur ein Thread zu einem bestimmten Zeitpunkt auf die CPU zugreifen kann?

Hier ist es wichtig zu verstehen, dass es bei Ihrem Code nicht nur um die Nutzung der CPU geht. Ihr Code wird andere Aufgaben erfüllen, wie das Lesen von Dateien, das Abrufen von Informationen aus dem Netz usw. Erinnern Sie sich an unsere Latenzzeiten? Hier sind sie wieder:

Latenzzeiten auf menschlicher Ebene (Quelle)

Dies ist der Schlüssel zur Bekämpfung der Beschränkung der GIL. In dem Moment, in dem ein Thread etwas aus einer Datei lesen muss, kann er die CPU freigeben und andere Threads laufen lassen. Es wird sehr lange dauern, bis die Festplatte die Daten zurückgibt, da die meisten Programme I/O-gebunden sind. Das heißt, sie nutzen viele E/A-Funktionen (Netzwerk, Dateisystem usw.).

Einen „Beweis“ für eine E/A-gebundene Aufgabe sehen Sie im Notebook: 5. Die Python GIL.ipynb.

Multiprocessing

Einige andere Programme sind zwangsläufig CPU-gebunden, wie z.B. Programme, die eine Menge Berechnungen durchführen müssen. Manchmal möchte man die Dinge beschleunigen. Wenn Sie Multithreading für CPU-gebundene Aufgaben verwenden, werden Sie feststellen, dass Ihre Programme durch die GIL nicht schneller werden (sie können sogar noch langsamer werden).

Multiprocessing ist der zweite Mechanismus, mit dem wir nebenläufige Programme schreiben können. Anstatt mehrere Threads zu haben, werden wir mehrere Kindprozesse erzeugen, die sich um die gleichzeitige Ausführung verschiedener Aufgaben kümmern.

Ein Beispiel für ein auf Multiprocessing basierendes Programm finden Sie im Notizbuch 6. Multiprocessing.ipynb

concurrent.futures

Zum Abschluss meines Tutorials möchte ich darauf hinweisen, dass es ein übergeordnetes Modul gibt, das Teil der Python-Standardbibliothek ist und wenn möglich verwendet werden sollte: concurrent.futures.

Es bietet Abstraktionen auf hoher Ebene, um Thread- und Prozess-Pools zu erstellen, und es ist ausgereift genug, um, wann immer möglich, als Standardalternative für das Schreiben von nebenläufigem Code betrachtet zu werden.

Da haben Sie es! Lassen Sie mich wissen, wenn Sie noch Fragen haben. Ich werde sie gerne beantworten.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.