Multithread-Python: Durch einen E / A-Engpass rutschen?

Wie Sie durch die Nutzung der Parallelität in Python Ihre Software um Größenordnungen beschleunigen können.

Ich habe kürzlich ein Projekt namens Hydra entwickelt: einen in Python geschriebenen Multithread-Link-Checker. Im Gegensatz zu vielen Python-Site-Crawlern, die ich bei der Recherche gefunden habe, verwendet Hydra nur Standardbibliotheken ohne externe Abhängigkeiten wie BeautifulSoup. Es soll als Teil eines CI / CD-Prozesses ausgeführt werden. Ein Teil seines Erfolgs hing also davon ab, schnell zu sein.

Mehrere Threads in Python sind insofern ein heikles Thema (nicht zu entschuldigen), als der Python-Interpreter nicht zulässt, dass mehrere Threads gleichzeitig ausgeführt werden.

Pythons Global Interpreter Lock (GIL) verhindert, dass mehrere Threads gleichzeitig Python-Bytecodes ausführen. Jeder Thread, der ausgeführt werden soll, muss zuerst warten, bis die GIL vom aktuell ausgeführten Thread freigegeben wird. Das GIL ist so ziemlich das Mikrofon in einem Low-Budget-Konferenzpanel, außer wo niemand schreien kann.

Dies hat den Vorteil, dass Rennbedingungen verhindert werden. Es fehlen jedoch die Leistungsvorteile, die sich aus der gleichzeitigen Ausführung mehrerer Aufgaben ergeben. (Wenn Sie eine Auffrischung zu Parallelität, Parallelität und Multithreading wünschen, lesen Sie Parallelität, Parallelität und die vielen Themen von Santa Claus.)

Ich bevorzuge Go wegen seiner praktischen erstklassigen Grundelemente, die Parallelität unterstützen (siehe Goroutinen), aber die Empfänger dieses Projekts waren mit Python besser vertraut. Ich nutzte die Gelegenheit, um zu testen und zu erkunden!

Das gleichzeitige Ausführen mehrerer Aufgaben in Python ist nicht unmöglich. Es dauert nur ein wenig zusätzliche Arbeit. Für Hydra besteht der Hauptvorteil in der Überwindung des E / A-Engpasses (Input / Output).

Damit Webseiten überprüft werden können, muss Hydra ins Internet gehen und sie abrufen. Im Vergleich zu Aufgaben, die nur von der CPU ausgeführt werden, ist das Ausgehen über das Netzwerk vergleichsweise langsamer. Wie langsam?

Hier sind ungefähre Zeitangaben für Aufgaben, die auf einem typischen PC ausgeführt werden:

AufgabeZeit
ZentralprozessorFühren Sie eine typische Anweisung aus1 / 1,000,000,000 sec = 1 Nanosec
Zentralprozessoraus dem L1-Cache-Speicher abrufen0,5 nanosec
ZentralprozessorBranchenfehlvorhersage5 nanosec
Zentralprozessoraus dem L2-Cache-Speicher abrufen7 nanosec
RAMMutex sperren / entsperren25 nanosec
RAMaus dem Hauptspeicher holen100 nanosec
NetzwerkSenden Sie 2 KByte über ein 1-Gbit / s-Netzwerk20.000 Nanosec
RAMLesen Sie 1 MB nacheinander aus dem Speicher250.000 Nanosec
Scheibevom neuen Speicherort abrufen (suchen)8.000.000 Nanosec (8 ms)
ScheibeLesen Sie 1 MB nacheinander von der Festplatte20.000.000 Nanosec (20 ms)
NetzwerkPaket US nach Europa und zurück senden150.000.000 Nanosec (150 ms)

Peter Norvig hat diese Zahlen vor einigen Jahren erstmals in Teach Yourself Programming in Ten Years veröffentlicht. Da sich Computer und ihre Komponenten von Jahr zu Jahr ändern, sind die oben angegebenen genauen Zahlen nicht der Punkt. Was diese Zahlen veranschaulichen, ist der Unterschied in Größenordnungen zwischen Operationen.

Vergleichen Sie den Unterschied zwischen dem Abrufen aus dem Hauptspeicher und dem Senden eines einfachen Pakets über das Internet. Während diese beiden Vorgänge aus menschlicher Sicht (im wahrsten Sinne des Wortes) in weniger als einem Augenblick ausgeführt werden, können Sie feststellen, dass das Senden eines einfachen Pakets über das Internet über eine Million Mal langsamer ist als das Abrufen aus dem RAM. Es ist ein Unterschied, der sich in einem Single-Thread-Programm schnell ansammeln und zu störenden Engpässen führen kann.

In Hydra ist das Analysieren von Antwortdaten und das Zusammenstellen von Ergebnissen zu einem Bericht relativ schnell, da alles auf der CPU geschieht. Der um mehr als sechs Größenordnungen langsamste Teil der Programmausführung ist die Netzwerklatenz. Hydra muss nicht nur Pakete abrufen, sondern ganze Webseiten!

Eine Möglichkeit zur Verbesserung der Leistung von Hydra besteht darin, eine Möglichkeit zu finden, wie die Aufgaben zum Abrufen von Seiten ausgeführt werden können, ohne den Hauptthread zu blockieren.

Python bietet mehrere Optionen für die parallele Ausführung von Aufgaben: mehrere Prozesse oder mehrere Threads. Mit diesen Methoden können Sie die GIL umgehen und die Ausführung auf verschiedene Arten beschleunigen.

Mehrere Prozesse

Um parallele Aufgaben mit mehreren Prozessen auszuführen, können Sie Pythons verwenden ProcessPoolExecutor. Eine konkrete Unterklasse Executoraus dem concurrent.futuresModul ProcessPoolExecutorverwendet einen Pool von Prozessen, die mit dem multiprocessingModul erzeugt wurden, um die GIL zu vermeiden.

Diese Option verwendet Worker-Unterprozesse, die maximal die Anzahl der Prozessoren auf dem Computer verwenden. Mit dem multiprocessingModul können Sie die Funktionsausführung prozessübergreifend maximal parallelisieren, wodurch rechengebundene (oder CPU-gebundene) Aufgaben erheblich beschleunigt werden können.

Da der Hauptengpass für Hydra die E / A ist und nicht die von der CPU auszuführende Verarbeitung, kann ich besser mehrere Threads verwenden.

Mehrere Threads

Passenderweise verwendet Python ThreadPoolExecutoreinen Pool von Threads, um asynchrone Aufgaben auszuführen. Ebenfalls eine Unterklasse von Executor, verwendet es eine definierte Anzahl von maximalen Worker-Threads (standardmäßig mindestens fünf gemäß der Formel min(32, os.cpu_count() + 4)) und verwendet inaktive Threads wieder, bevor neue gestartet werden, was es ziemlich effizient macht.

Hier ist ein Ausschnitt von Hydra mit Kommentaren, die zeigen, wie Hydra ThreadPoolExecutorparallele Multithread-Glückseligkeit erreicht:

# Create the Checker class class Checker: # Queue of links to be checked TO_PROCESS = Queue() # Maximum workers to run THREADS = 100 # Maximum seconds to wait for HTTP response TIMEOUT = 60 def __init__(self, url): ... # Create the thread pool self.pool = futures.ThreadPoolExecutor(max_workers=self.THREADS) def run(self): # Run until the TO_PROCESS queue is empty while True: try: target_url = self.TO_PROCESS.get(block=True, timeout=2) # If we haven't already checked this link if target_url["url"] not in self.visited: # Mark it as visited self.visited.add(target_url["url"]) # Submit the link to the pool job = self.pool.submit(self.load_url, target_url, self.TIMEOUT) job.add_done_callback(self.handle_future) except Empty: return except Exception as e: print(e) 

Sie können den vollständigen Code im GitHub-Repository von Hydra anzeigen.

Einzelner Thread zu Multithread

Wenn Sie den vollen Effekt sehen möchten, habe ich die Laufzeiten für die Überprüfung meiner Website zwischen einem Prototyp-Single-Thread-Programm und der mehrköpfigen - ich meine Multithread-- Hydra verglichen.

time python3 slow-link-check.py //victoria.dev real 17m34.084s user 11m40.761s sys 0m5.436s time python3 hydra.py //victoria.dev real 0m15.729s user 0m11.071s sys 0m2.526s 

Das Single-Thread-Programm, das E / A blockiert, lief in ungefähr siebzehn Minuten. Als ich die Multithread-Version zum ersten Mal ausführte, endete sie in 1: 13.358 Sekunden - nach einigen Profilen und Optimierungen dauerte es etwas weniger als 16 Sekunden.

Auch hier bedeuten die genauen Zeiten nicht viel; Sie variieren je nach Faktoren wie der Größe der zu crawlenden Site, Ihrer Netzwerkgeschwindigkeit und dem Gleichgewicht Ihres Programms zwischen dem Aufwand für die Thread-Verwaltung und den Vorteilen der Parallelität.

Das Wichtigste und das Ergebnis, das ich jeden Tag machen werde, ist ein Programm, das einige Größenordnungen schneller läuft.