Die Wahl des richtigen Concurrency-Modells für Ihre Python-Aufgaben
Daniel Hayes
Full-Stack Engineer · Leapcell

Einleitung
In der Welt der Softwareentwicklung sind Reaktionsfähigkeit und Effizienz von größter Bedeutung. Ob Sie einen Webserver erstellen, große Datensätze verarbeiten oder Informationen aus dem Internet scrapen, die Fähigkeit Ihrer Anwendung, mehrere Operationen gleichzeitig auszuführen, kann ihre Leistung und Benutzererfahrung erheblich beeinflussen. Python bietet mit seinem reichen Ökosystem mehrere leistungsstarke Concurrency-Modelle: multiprocessing
, threading
und asyncio
. Das Verständnis der Nuancen jedes einzelnen Modells und vor allem zu wissen, wann man welches auswählen sollte, ist eine entscheidende Fähigkeit für jeden Python-Entwickler, der Hochleistungsanwendungen schreiben möchte. Dieser Artikel wird diese Concurrency-Modelle entmystifizieren, Sie durch ihre Prinzipien führen und Ihnen helfen, fundierte Entscheidungen für Ihre spezifischen Anwendungsfälle zu treffen.
Kernkonzepte der Concurrency
Bevor wir uns mit den Besonderheiten jedes Modells befassen, wollen wir ein klares Verständnis einiger grundlegender Konzepte erlangen, die der Concurrency in Python zugrunde liegen.
Concurrency vs. Parallelität: Concurrency bedeutet, sich mit vielen Dingen gleichzeitig zu befassen, während Parallelität viele Dinge gleichzeitig zu tun bedeutet. Eine Single-Core-CPU kann durch schnelles Wechseln zwischen Aufgaben (Kontextwechsel) konsequent sein und die Illusion gleichzeitiger Ausführung erwecken. Parallelität hingegen erfordert mehrere Verarbeitungseinheiten (CPU-Kerne), um Aufgaben wirklich gleichzeitig auszuführen.
CPU-gebundene vs. I/O-gebundene Aufgaben:
- CPU-gebundene Aufgaben sind Operationen, die den größten Teil ihrer Zeit mit Berechnungen verbringen und durch die Geschwindigkeit der CPU begrenzt sind. Beispiele hierfür sind aufwendige mathematische Berechnungen, Bildverarbeitung oder Datenkomprimierung.
- I/O-gebundene Aufgaben sind Operationen, die den größten Teil ihrer Zeit mit dem Warten auf die Antwort externer Ressourcen verbringen, wie z. B. Netzwerkanfragen, Festplatten-Lese-/Schreibvorgänge oder Datenbankabfragen. Während dieser Wartezeit ist die CPU weitgehend untätig.
Global Interpreter Lock (GIL): Der GIL ist ein Mutex, der den Zugriff auf Python-Objekte schützt und verhindert, dass mehrere native Threads gleichzeitig Python-Bytecodes ausführen. Das bedeutet, dass selbst auf Multi-Core-Prozessoren zu einem bestimmten Zeitpunkt nur ein Thread Python-Bytecode ausführen kann. Während der GIL die Entwicklung von C-Erweiterungen und die Speicherverwaltung vereinfacht, schränkt er die echte Parallelität für CPU-gebundene Aufgaben innerhalb eines einzigen Python-Prozesses ein.
Threading: Concurrency mit gemeinsamem Speicher
threading
ermöglicht es Ihnen, mehrere Teile Ihres Programms gleichzeitig innerhalb desselben Prozesses auszuführen. Threads teilen sich denselben Speicherbereich, was die gemeinsame Nutzung von Daten erleichtert, aber auch potenzielle Herausforderungen wie Race Conditions und Deadlocks mit sich bringt, wenn sie nicht sorgfältig verwaltet werden.
Funktionsweise
Wenn Sie einen neuen Thread erstellen, führt er eine separate Funktion gleichzeitig mit dem Hauptthread aus. Das Betriebssystem verwaltet die Zeitplanung dieser Threads.
Beispiel
Betrachten wir eine I/O-gebundene Aufgabe wie das Abrufen von Daten von mehreren URLs.
import threading import requests import time def fetch_url(url): print(f"Starting to fetch {url}") try: response = requests.get(url, timeout=5) print(f"Finished fetching {url}: Status {response.status_code}") except requests.exceptions.RequestException as e: print(f"Error fetching {url}: {e}") urls = [ "https://www.google.com", "https://www.bing.com", "https://www.yahoo.com", "https://www.amazon.com", "https://www.wikipedia.org" ] start_time = time.time() threads = [] for url in urls: thread = threading.Thread(target=fetch_url, args=(url,))[:] threads.append(thread) thread.start() for thread in threads: thread.join() # Wait for all threads to complete end_time = time.time() print(f"All URLs fetched in {end_time - start_time:.2f} seconds using threading.")
Wann Threading verwenden
threading
eignet sich am besten für I/O-gebundene Aufgaben. Während der GIL die echte Multi-Core-CPU-Parallelität verhindert, wird der GIL freigegeben, wenn ein Thread eine I/O-Operation durchführt (z. B. auf Netzwerkdaten wartet), was anderen Threads die Ausführung ermöglicht. Dies macht threading
effektiv für Aufgaben, die das Warten auf externe Ressourcen beinhalten.
Umgekehrt bietet threading
für CPU-gebundene Aufgaben aufgrund des GIL kaum oder gar keine Leistungsvorteile und kann sogar zusätzlichen Aufwand durch Kontextwechsel verursachen, was das Programm möglicherweise langsamer macht als einen Single-Threaded-Ansatz.
Multiprocessing: Echte Parallelität mit separaten Prozessen
multiprocessing
ermöglicht es Ihnen, neue Prozesse zu starten, jeder mit seinem eigenen Python-Interpreter und Speicherbereich. Das bedeutet, dass der GIL kein Problem darstellt und eine echte parallele Ausführung von CPU-gebundenen Aufgaben über mehrere CPU-Kerne hinweg ermöglicht.
Funktionsweise
Wenn Sie multiprocessing
verwenden, werden neue Betriebssystemprozesse erstellt. Diese Prozesse teilen sich keinen direkten Speicher, wodurch GIL-Beschränkungen vermieden werden. Die Kommunikation zwischen Prozessen erfolgt typischerweise über explizite Mechanismen wie Pipes oder Queues.
Beispiel
Betrachten wir eine CPU-gebundene Aufgabe wie die Berechnung von Primzahlen, um multiprocessing
zu demonstrieren.
import multiprocessing import time def is_prime(n): if n < 2: return False for i in range(2, int(n**0.5) + 1): if n % i == 0: return False return True def find_primes_in_range(start, end): primes = [n for n in range(start, end) if is_prime(n)] # print(f"Found {len(primes)} primes between {start} and {end}") return primes if __name__ == "__main__": nums_to_check = range(1000000, 10000000) # A larger range for better demonstration num_processes = multiprocessing.cpu_count() # Use as many processes as CPU cores chunk_size = len(nums_to_check) // num_processes chunks = [] for i in range(num_processes): start_idx = i * chunk_size end_idx = (i + 1) * chunk_size if i < num_processes - 1 else len(nums_to_check) chunks.append((nums_to_check[start_idx], nums_to_check[end_idx-1] + 1))[:] start_time = time.time() with multiprocessing.Pool(num_processes) as pool: all_primes = pool.starmap(find_primes_in_range, chunks) # Flatten the list of lists total_primes = [item for sublist in all_primes for item in sublist] end_time = time.time() print(f"Found {len(total_primes)} primes in {end_time - start_time:.2f} seconds using multiprocessing.") # For comparison, single-threaded execution (uncomment to run) # start_time_single = time.time() # single_primes = find_primes_in_range(nums_to_check[0], nums_to_check[-1] + 1) # end_time_single = time.time() # print(f"Found {len(single_primes)} primes in {end_time_single - start_time_single:.2f} seconds using single-thread.")
Wann Multiprocessing verwenden
multiprocessing
ist die beste Lösung für CPU-gebundene Aufgaben. Durch die Nutzung mehrerer CPU-Kerne umgeht es die Beschränkung des GIL und erreicht echte Parallelität, was zu erheblichen Geschwindigkeitssteigerungen bei rechenintensiven Operationen führt.
Es kann auch für I/O-gebundene Aufgaben verwendet werden, aber der Aufwand für die Erstellung und Verwaltung von Prozessen ist typischerweise höher als bei Threads, was threading
oder asyncio
für solche Szenarien oft effizienter macht.
Asyncio: Kooperatives Multitasking für hohe Concurrency
asyncio
ist Pythons Bibliothek zum Schreiben von Concurrent Code mit der async
/await
-Syntax. Es ermöglicht kooperatives Multitasking mithilfe eines einzigen Threads, bei dem Tasks die Kontrolle freiwillig an die Event-Schleife zurückgeben, sodass andere Tasks ausgeführt werden können. Dies ist besonders leistungsfähig für die effiziente Handhabung einer großen Anzahl gleichzeitiger I/O-Operationen.
Funktionsweise
asyncio
arbeitet mit einer Event-Schleife. Wenn ein await
-Ausdruck angetroffen wird (typischerweise eine I/O-Operation), wird die aktuelle Aufgabe angehalten und die Kontrolle kehrt zur Event-Schleife zurück. Die Event-Schleife prüft dann auf andere bereite Tasks oder externe Ereignisse (wie eine Netzwerkreaktion) und plant diese ein. Wenn die erwartete I/O-Operation abgeschlossen ist, wird die ursprüngliche Aufgabe fortgesetzt.
Beispiel
Schauen wir uns das URL-Fetching-Beispiel noch einmal an, diesmal mit asyncio
.
import asyncio import aiohttp # Asynchronous HTTP client import time async def fetch_url_async(url, session): print(f"Starting to fetch {url}") try: async with session.get(url, timeout=5) as response: status = response.status print(f"Finished fetching {url}: Status {status}") return status except aiohttp.ClientError as e: print(f"Error fetching {url}: {e}") return None async def main(): urls = [ "https://www.google.com", "https://www.bing.com", "https://www.yahoo.com", "https://www.amazon.com", "https://www.wikipedia.org", "https://www.example.com", # Add more for better demonstration "https://www.test.org" ] start_time = time.time() async with aiohttp.ClientSession() as session: tasks = [fetch_url_async(url, session) for url in urls] results = await asyncio.gather(*tasks) # Run tasks concurrently end_time = time.time() print(f"All URLs fetched in {end_time - start_time:.2f} seconds using asyncio.") # print(f"Results: {results}") if __name__ == "__main__": asyncio.run(main())
Wann Asyncio verwenden
asyncio
glänzt bei I/O-gebundenen Aufgaben, bei denen Sie eine sehr große Anzahl gleichzeitiger Verbindungen oder Operationen ohne den Overhead der Erstellung vieler Threads oder Prozesse verwalten müssen. Da es innerhalb eines einzigen Threads arbeitet, ist der Kontextwechsel viel leichter als bei Threads, und es vermeidet die Auswirkungen des GIL-Problems auf I/O. Denken Sie an Webserver, Datenbank-Proxys oder Long-Polling-Clients.
Es ist im Allgemeinen nicht für CPU-gebundene Aufgaben geeignet, da eine einzige rechenintensive Aufgabe die gesamte Event-Schleife blockieren würde, was verhindert, dass alle anderen kooperativen Aufgaben ausgeführt werden können, bis sie abgeschlossen ist. Für CPU-gebundene Operationen in einer asyncio
-Anwendung würden Sie diese typischerweise an einen multiprocessing.Pool
oder einen ThreadPoolExecutor
auslagern, um die Event-Schleife nicht zu blockieren.
Die Wahl des richtigen Modells
Hier ist eine kurze Zusammenfassung und ein Entscheidungsrahmen:
- CPU-gebundene Aufgaben: Verwenden Sie
multiprocessing
. Es umgeht den GIL und ermöglicht eine echte parallele Ausführung über mehrere Kerne für rechenintensive Operationen. - I/O-gebundene Aufgaben:
- Für eine moderate Anzahl gleichzeitiger Operationen oder wenn Sie mit blockierenden I/O-Bibliotheken arbeiten, die keine asynchronen Äquivalente haben, ist
threading
eine gute Wahl. Es ist für viele traditionelle I/O-Szenarien einfacher zu implementieren alsasyncio
. - Für eine sehr große Anzahl gleichzeitiger I/O-Operationen, insbesondere Netzwerkanrufe, und bei Verwendung asynchroner Bibliotheken (wie
aiohttp
,asyncpg
) istasyncio
aufgrund seines kooperativen Multitaskings und seines geringeren Overheads erheblich effizienter.
- Für eine moderate Anzahl gleichzeitiger Operationen oder wenn Sie mit blockierenden I/O-Bibliotheken arbeiten, die keine asynchronen Äquivalente haben, ist
- Gemischte Aufgaben (CPU-gebunden und I/O-gebunden): Oft ist ein hybrider Ansatz am besten. Verwenden Sie
asyncio
für die I/O-gebundenen Teile und lagern Sie CPU-gebundene Berechnungen an einenmultiprocessing.Pool
aus (mithilfe vonloop.run_in_executor
inasyncio
-Kontexten), um die Event-Schleife nicht zu blockieren.
Fazit
Python bietet leistungsstarke Werkzeuge zum Erstellen von Concurrent-Anwendungen, von denen jedes seine Stärken und idealen Anwendungsfälle hat. Threading
eignet sich gut für I/O-gebundene Aufgaben mit moderater Concurrency, multiprocessing
ist der Spitzenreiter für CPU-gebundene Aufgaben, die echte Parallelität erfordern, und asyncio
bietet eine elegante und effiziente Lösung für hochgradig konsequente I/O-gebundene Operationen. Durch das Verständnis dieser Unterschiede können Entwickler zuversichtlich das am besten geeignete Concurrency-Modell auswählen und sicherstellen, dass ihre Python-Anwendungen sowohl reaktionsschnell als auch performant sind. Der Schlüssel liegt darin, das Concurrency-Modell an die Art Ihrer Aufgabe anzupassen.