Python-Kurs / Threads, Prozesse und asyncio mit Fehlerbehandlung vergleichen

Threads, Prozesse und asyncio ermöglichen unterschiedliche Formen gleichzeitigen Fortschritts. Wählen Sie nach Rechenarbeit, externen Wartezeiten und Bibliotheksunterstützung und messen Sie Ihre tatsächliche Last, statt automatisch eine Beschleunigung durch mehr Aufgaben anzunehmen.

Nebenläufigkeit und parallele Ausführung unterscheiden

Modell Arbeitsweise Zu berücksichtigen
Threads Gemeinsamer Speicher innerhalb eines Prozesses Synchronisierung und Verhalten verwendeter Bibliotheken
Prozesse Getrennte Prozesse mit eigenen Speicherbereichen Start, Serialisierung und Übertragung von Daten
asyncio Kooperative Ausführung mit geeigneten await-Stellen Asynchrone Bibliotheken und blockierende Operationen im Ereignisloop

Nebenläufigkeit bedeutet, dass mehrere Aufgaben im Ablauf vorankommen können; Parallelität bedeutet tatsächliche Ausführung zur selben Zeit. Bei einem gewöhnlichen CPython-Build mit GIL, dem Global Interpreter Lock, werden reine Python-Bytecode-Berechnungen nicht schon durch zusätzliche Threads beliebig parallel. Erweiterungen, die den GIL freigeben, und Free-Threaded-Builds können andere Bedingungen haben. Prüfen Sie daher Interpreter und Bibliotheken gemeinsam.

Als Ausgangspunkt können Threads für blockierendes Ein-/Ausgabe-Warten, Prozesse für geeignete reine Python-Rechenarbeit und asyncio für asynchron unterstützte Ein-/Ausgabe dienen. Das ist eine Auswahlhilfe, kein versprochenes Leistungsergebnis. Die Threading-Dokumentation zum GIL erläutert die Ausführungsbedingungen.

Drei Schnittstellen mit einem vollständigen Programm vergleichen

Speichern Sie das folgende vollständige Programm als concurrency.py mit UTF-8 in einem eigenen Ordner. Starten Sie es als Datei mit python concurrency.py in Ihrer Projektumgebung. Die Standardbibliothek genügt. Außerhalb einer virtuellen Umgebung kann unter macOS oder Ubuntu der Befehl python3 heißen. Verlassen Sie sich bei Prozessstart nicht auf identisches Verhalten in einer interaktiven Sitzung oder einem Notebook.

import asyncio
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def square(value):
    return value * value

async def async_square(value):
    await asyncio.sleep(0.01)
    return square(value)

async def gather_values():
    return await asyncio.gather(*(async_square(x) for x in [1, 2, 3]))

def main():
    with ThreadPoolExecutor(max_workers=2) as pool:
        print(list(pool.map(square, [1, 2, 3])))
    with ProcessPoolExecutor(max_workers=2) as pool:
        print(list(pool.map(square, [1, 2, 3])))
    print(asyncio.run(gather_values()))

if __name__ == "__main__":
    main()

Erwartete Ausgabe:

[1, 4, 9]
[1, 4, 9]
[1, 4, 9]

Alle drei Varianten berechnen dieselben Quadrate. pool.map und hier auch asyncio.gather geben Ergebnisse in der Reihenfolge der Eingaben zurück. Das bedeutet nicht, dass die Aufgaben in genau dieser Reihenfolge fertig wurden. Die kurze asyncio.sleep(0.01)-Pause ist ein kontrolliertes kooperatives Warten und keine echte HTTP-Anfrage.

Der Schutz if __name__ == "__main__": verhindert, dass ein neu gestarteter Prozess beim Import erneut den gesamten Startablauf ausführt. Die Arbeitsfunktion square steht auf Modulebene, damit sie für den Prozesspool zugänglich ist. Die Referenz zu concurrent.futures beschreibt Aufgabenübergabe, Ergebnisabholung und Lebensdauer der Pools.

Abbruch und Fehlerweitergabe ausdrücklich beobachten

Eine mit async def definierte Funktion blockiert den Ereignisloop weiterhin, wenn sie lange CPU-Arbeit oder synchrone Netzwerkaufrufe ohne geeigneten Kontrollübergang ausführt. Asynchrone Schreibweise ersetzt keine nichtblockierende Bibliothek. Ein einfacher Coroutine-Aufruf allein führt deren Körper zudem noch nicht automatisch aus; er muss ausgeführt oder geplant werden.

Speichern Sie das folgende zweite vollständige Programm als cancel.py und starten Sie python cancel.py:

import asyncio
async def worker():
    try:
        await asyncio.sleep(60)
    finally:
        print("worker cleanup")
async def main():
    task = asyncio.create_task(worker())
    await asyncio.sleep(0)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("cancel confirmed")
if __name__ == "__main__":
    asyncio.run(main())

Erwartete Ausgabe:

worker cleanup
cancel confirmed

await asyncio.sleep(0) gibt dem Worker hier zunächst Gelegenheit zum Start. Danach wird die Aufgabe zur Stornierung aufgefordert. An einer geeigneten Stelle erhält sie CancelledError, führt ihren finally-Block aus und reicht die Stornierung weiter. Der aufrufende Code wartet die Aufgabe ab und bestätigt den Abbruch. Obwohl im Worker 60 Sekunden Wartezeit stehen, wartet dieses Beispiel diese nicht vollständig ab.

Ein Abbruch ist eine kooperative Anfrage, keine allgemeine Rücknahme externer Nebenwirkungen. Verschlucken Sie die Stornierung nicht unbemerkt, nur um eine Erfolgsausgabe zu erhalten. Die Dokumentation zur Task-Stornierung erklärt die Weitergabe.

Ein Future aus einem Threadpool transportiert ebenfalls einen Fehler, wenn Sie sein Ergebnis abholen. Speichern Sie die dritte vollständige Datei als future_failure.py und starten Sie python future_failure.py:

from concurrent.futures import ThreadPoolExecutor
def calculate(value):
    if value < 0:
        raise ValueError("negative input")
    return value * value
with ThreadPoolExecutor(max_workers=2) as pool:
    futures = [pool.submit(calculate, value) for value in [2, -1]]
    for future in futures:
        try:
            print(future.result(timeout=5))
        except ValueError as error:
            print("failed:", error)

Erwartete Ausgabe:

4
failed: negative input

Die erste Aufgabe liefert vier, die zweite löst ValueError aus. future.result(timeout=5) reicht diesen Fehler an den aufrufenden Code weiter. Das Zeitlimit begrenzt das Warten auf ein Ergebnis und beendet einen bereits laufenden Thread nicht gewaltsam. Auch der Austritt aus dem Pool-Kontext kann auf noch laufende Arbeit warten.

Last, gemeinsame Daten und Messgrenzen planen

max_workers=2 begrenzt die Anzahl der gleichzeitig eingesetzten Worker, nicht automatisch jede vorgelagerte Warteschlange. Teilen Sie große Eingaben in passende begrenzte Pakete auf. Bei asyncio können Sie für gleichzeitig laufende Ein-/Ausgabe beispielsweise eine Semaphore verwenden; unbegrenzt erzeugte Tasks werden nicht durch die bloße Wahl dieses Modells sinnvoll begrenzt.

Gemeinsame veränderliche Daten brauchen eine bewusst gewählte Synchronisierung. Getrennte Prozesse teilen eine normale Python-Liste nicht wie Threads. Trennen Sie außerdem eine technisch wiederholte Aufgabe von ihrer fachlichen Bedeutung: Eine erneut gesendete Schreibanfrage kann einen zweiten Vorgang erzeugen. Die API-Sammelübung zeigt, warum Netzwerk-, Identitäts- und Speicherregeln vor einer Parallelisierung klar sein sollten.

  1. Führen Sie alle drei Beispieldateien einzeln aus und prüfen Sie Ergebnisreihenfolge, Bereinigungsmeldung und die weitergereichte Ausnahme.
  2. Beurteilen Sie den kleinen Quadratvergleich als Schnittstellenübung. Er liefert keinen belastbaren Geschwindigkeitsvergleich; Prozessstart kann hier aufwendiger als die eigentliche Arbeit sein.
  3. Messen Sie bei einer realen Erweiterung repräsentative Daten und halten Sie Umgebung, Aufgabenanzahl, Workerzahl und Speicherbedarf fest. Vergleichen Sie nicht nur eine einzelne Laufzeit ohne gleichen Arbeitsumfang.

asyncio.run erzeugt und verwaltet den äußeren Ereignisloop und kann nicht innerhalb eines bereits laufenden Loops desselben Threads verschachtelt werden. In einer Umgebung mit eigenem Loop müssen Sie deren vorgesehenen Aufrufweg verwenden. Prüfen Sie Fehlerweitergabe, Abbruch und kontrolliertes Ende neben der reinen Ergebnissumme.