Ursachen für Verlangsamungen in Async-Code und ihre Behebung (technische Zusammenfassung)
Dieses Video behandelt häufige Ursachen dafür, dass Python-asyncio-Code langsamer wird als synchroner Code, sowie technische Methoden zu ihrer Behebung.
1. Zentrale Konzepte von Asyncio
- Event Loop: Das Herz jeder asynchronen Anwendung. Er wird mit
asyncio.run()gestartet, verwaltet die Ausführung von Tasks in einem einzelnen Thread und übernimmt deren Scheduling. - Coroutines: Asynchrone Funktionen, die mit
async defdeklariert werden. Treffen sie auf das Schlüsselwortawait, können sie ihre Ausführung pausieren und die Kontrolle an den Event Loop zurückgeben. - Tasks: Sie kapseln Coroutines und planen sie so ein, dass sie im Event Loop gleichzeitig ausgeführt werden. Sie werden mit
asyncio.create_task()erzeugt. - Futures: Low-Level-Objekte, die das endgültige Ergebnis einer asynchronen Operation repräsentieren.
2. Beispiel für die Umwandlung von synchronem in asynchronen Code
Dabei wird das bestehende synchrone time.sleep() durch das asynchrone await asyncio.sleep() ersetzt, die Funktion mit async def deklariert und die Haupt-Coroutine mit asyncio.run() ausgeführt.
Häufige Fehler, die Leistungsprobleme verursachen, und ihre Lösungen
Fehler 1: Sequenzielle Ausführung
Werden unabhängige Tasks nicht parallel ausgeführt, sondern nacheinander mit await abgearbeitet, entspricht die gesamte Laufzeit der Summe aller einzelnen Task-Laufzeiten.
-
Falsches Beispiel (sequenziell):
# Jedes await wartet, bis die vorherige Arbeit abgeschlossen ist await get_user_notifications() await get_recent_activity() await get_unread_messages() -
Lösung (parallel): Mit
asyncio.gatheroderasyncio.TaskGroupwerden unabhängige Tasks gleichzeitig ausgeführt. Die Gesamtlaufzeit sinkt dadurch auf die Dauer des langsamsten Tasks.# Drei Aufgaben starten gleichzeitig await asyncio.gather( get_user_notifications(), get_recent_activity(), get_unread_messages() )
Vergleich von Werkzeugen für parallele Ausführung
asyncio.gather:- Führt mehrere Coroutines gleichzeitig aus.
- Nachteil: Die Fehlerbehandlung ist unzureichend. Tritt in einem Task eine Ausnahme auf, werden andere laufende Tasks abgebrochen.
asyncio.create_task:- Ermöglicht Kontrolle und Fehlerbehandlung pro Task.
- Nützlich für Hintergrundausführung, aber umständlich, weil mehrere Tasks einzeln mit
awaitbehandelt werden müssen.
asyncio.TaskGroup(Python 3.11+):- Die moderne Alternative für „structured concurrency“.
- Verwaltet Task-Gruppen mit der
async with-Syntax und stellt sicher, dass beim Verlassen des Kontexts alle Tasks abgeschlossen oder Ausnahmen behandelt sind.
async with asyncio.TaskGroup() as tg: tg.create_task(some_coro_1()) tg.create_task(some_coro_2()) # Nach dem Ende des 'async with'-Blocks wurden alle Tasks awaited
Fehler 2: Verwendung synchroner Bibliotheken
Werden innerhalb von asyncio-Code synchrone (blocking) Bibliotheken wie requests oder pathlib verwendet, blockiert das den gesamten Event Loop. Selbst innerhalb von asyncio.gather laufen sie dann faktisch sequenziell.
- Lösung: Es sollten spezialisierte Bibliotheken mit Unterstützung für asynchrones (non-blocking) Arbeiten genutzt werden, etwa
aiohttp(als Ersatz für requests) oderaiofiles(als Ersatz für files/pathlib).
Fehler 3: Blockierung des Event Loops durch CPU-bound-Aufgaben
Da asyncio in einem einzelnen Thread läuft, halten rechenintensive Aufgaben (CPU-bound) den Event Loop an und verzögern andere I/O-Operationen.
- Lösung: Mit
loop.run_in_executor()werden CPU-bound-Aufgaben in einen separaten Thread-Pool (Standard) oder Process-Pool ausgelagert.loop = asyncio.get_running_loop() # Führt eine CPU-intensive Funktion in einem separaten Thread aus await loop.run_in_executor( None, # Standard-Thread-Pool verwenden cpu_bound_function, arg1 )
Fehler 4: Blockierung durch unwichtige Aufgaben
Wird bei nicht kernrelevanten Aufgaben wie Logging auf await gewartet, obwohl sie nichts mit der Benutzerantwort zu tun haben, erhöht das unnötig die Antwortzeit.
- Lösung: Mit
asyncio.create_task()werden solche Arbeiten als Hintergrund-Task ausgelagert und nicht mitawaitblockiert.user_profile = await get_user_profile() # Logging läuft im Hintergrund, ohne await asyncio.create_task(send_logs_to_external_service()) return user_profile
Fehler 5: Zu viele Tasks erzeugen
Werden sehr kleine Arbeiten in großer Zahl jeweils als eigener Task erstellt, kann Context-Switching-Overhead die Leistung verschlechtern.
- Lösung 1: Kleine Arbeiten bündeln (Batching) und in einige größere Tasks zusammenfassen.
- Lösung 2: Mit
asyncio.Semaphoredie maximale Zahl gleichzeitig laufender Tasks begrenzen.# Maximal 10 gleichzeitige Aufgaben zulassen semaphore = asyncio.Semaphore(10) async with semaphore: await fetch_data()
Weitere Fehler
- „Never Awaited“-Coroutines: Eine Coroutine wird aufgerufen, aber nicht mit
awaitbehandelt, sodass die Aufgabe gar nicht ausgeführt wird und stillschweigend fehlschlägt. Mit Lintern wieflake8-asynclässt sich das erkennen. - Ungeeignete Ressourcenverwaltung: Werden Dateien, DB-Verbindungen usw. ohne
try...finallyverwendet, kann es zu Resource Leaks kommen. Die Lösung sind asynchrone Context Manager mitasync with.
Debugging und Wahl des Nebenläufigkeitsmodells
Debug-Modus von Asyncio
Wird der standardmäßig deaktivierte Debug-Modus aktiviert (asyncio.run(debug=True)), hilft das beim Erkennen folgender Probleme.
- Coroutines, die nicht
awaitet wurden (RuntimeWarning). - Asynchrone APIs, die aus dem falschen Thread aufgerufen wurden.
- Callbacks mit einer Laufzeit von mehr als 100 ms.
- Langsame I/O-Selector-Operationen.
Weitere Debugging-Werkzeuge
- Scalene: CPU- und Speicher-Profiler.
- aio-monitor: Monitoring und CLI für
asyncio-Anwendungen. - pdb: Der Standard-Debugger von Python.
- py-stack: Gibt Stack-Traces laufender Python-Prozesse aus, um Blockierungsstellen zu erkennen.
Leitfaden zur Wahl des Nebenläufigkeitsmodells
- Asyncio (einzelner Thread): Optimal für viele I/O-bound-Aufgaben mit hoher Latenz, etwa Netzwerkanfragen oder Datei-I/O.
- Threads (Multithreading): Für I/O-bound-Aufgaben mit gemeinsamem Datenzugriff geeignet. Aufgrund des GIL (Global Interpreter Lock) entsteht keine echte Parallelität, aber während I/O-Wartezeiten können andere Threads laufen.
- Processes (Multiprocessing): Für CPU-bound-Aufgaben wie Bildverarbeitung oder rechenintensive Berechnungen geeignet. Sie nutzen mehrere CPU-Kerne für echte Parallelität, verursachen aber hohen Speicher- und Kommunikations-Overhead.
12 Kommentare
Python ist zweifellos eine großartige Sprache, aber die asynchrone Schnittstelle scheint mir eine falsch konzipierte Funktion zu sein.
Bei Punkt 4 fehlt
eager_start=True. Dacreate_taskeinweakreferstellt, wäre das Code, bei dem der Task womöglich niemals ausgeführt wird....> https://rosettalens.com/s/ko/python-to-node
Diese Person meinte auch, sie sei wegen Python-
asyncauf Node.js umgestiegen.Fazit: Die asynchrone Schnittstelle von Python ist noch immer nicht intuitiv.
Tatsächlich ist man bei einem Projekt, das Python-Asynchronität so weit optimieren muss, in Sachen Performance und Stabilität deutlich besser damit beraten, es in einer anderen Sprache zu schreiben.
Wenn man nicht zu einer kompilierten Sprache wechselt, gibt es dann große Leistungsunterschiede? Bei Multithreading gäbe es wegen der Existenz des GIL natürlich deutliche Unterschiede, aber wenn es ohnehin eine asynchrone Struktur ist, in der eine Event Loop arbeitet, frage ich mich, welche sprachbedingten Unterschiede es dann gibt.
Ob JIT-Kompilierung vorhanden ist oder nicht, macht mehr aus, als man denkt. V8 ist sehr gut optimiert.
Ich habe das Quellvideo zwar nicht überprüft, aber der Lösungscode zu Fehler 4 ist falsch.
Die von
create_task()zurückgegebene Task-Instanz muss mindestens einer Variable zugewiesen werden, und diese Variable muss bis zum Ende der Task weiterbestehen. Andernfalls besteht das Risiko, dass die Task-Instanz vom Garbage Collector eingesammelt wird, während die Coroutine noch läuft.Wenn die Funktion, die wie oben eine Task erstellt, bald wieder endet, sollte man daher die Task-Instanz zurückgeben oder sie einer globalen Variable bzw. einer Instanzvariable zuweisen.
P.S.)
Auch wenn man den Rückgabewert nicht unbedingt braucht und sicher ist, dass die Coroutine in kurzer Zeit beendet sein wird, ist es besser, den Code so zu schreiben, dass man die Task-Instanz irgendwann doch noch
awaitet. Wenn man das nicht möchte, sollte man stattdessen für jede als Task laufende Coroutine eine konsequente Exception-Behandlung einbauen und eine Struktur schaffen, die Log-Meldungen lückenlos ausgibt. Andernfalls kann es passieren, dass Exceptions nicht behandelt werden und der Task stillschweigend fehlschlägt.In einem Projekt, das ich beruflich entwickle und betreue, hatte ich einmal ein Muster entworfen, bei dem Dutzende Module jeweils eine Task nach dem Schema
while self.ok(): cmd = await self.cmd_queue.get(); await self.process(cmd);erzeugen und dauerhaft laufen lassen. Bis wir ein sauberes Exception-Handling-Muster etabliert hatten, war es jedes Mal ein außergewöhnliches Erlebnis, dass bei jedem einzelnen Problem auch gleich meine mentale Verfassung mit explodiert ist, haha.Selbst aus der Sicht von jemandem, der bei einem Unternehmen arbeitet, das C# verwendet – also gewissermaßen dem Ursprung(?) des Async/Await-Musters – sieht man ziemlich oft fehlerhaften Code wie in Fehler Nr. 1, bei dem
awaiteinfach nur der Reihe nach stumpf aneinandergeschrieben wird.Wenn man solchen Code sieht, hat man oft den Eindruck, dass gemeinsam ist, dass man nur weiß, dass vor einem
async-Methodenaufruf das Schlüsselwortawaitstehen muss, sich aber über die darüber hinausgehende Reihenfolge der asynchronen Ausführung kaum Gedanken macht, und deshalb solcher Code entsteht.Wenn mehrere
awaitvorkommen, wird das Ergebnis mancher Aufrufe direkt darunter verwendet, sodass man davor denawait-Rückgabewert desTask<T>-Objekts entgegennimmt; anderes wird erst deutlich später verwendet, sodass man zunächst nur denTask<T>entgegennimmt und erst späterawaitdarauf ausführt – den Code also unter Berücksichtigung dieses asynchronen Ablaufs zu schreiben, ist eben entsprechend Denkarbeit.Zumindest ich schreibe in asynchron deklarierten Methoden den Code so, dass ich diesen Verarbeitungsablauf mit berücksichtige. Wenn ich aber manchmal im Rahmen der Wartung bestehenden Codes den Code eines ausgeschiedenen Mitarbeiters sehe, habe ich auch gelegentlich den Eindruck: „Ich würde eigentlich einfach nur ganz normalen synchronen Code schreiben wollen, aber weil die Methode, die ich zwischendurch aufrufen muss, nur in einer asynchronen Variante existiert, schreibe ich es eben einfach so.“
Wenn Nummer 1 immer unabhängig ist, ist es zwar gut, das so zu machen,
aber wenn man den Code ändert und er dadurch nicht mehr unabhängig ist, wirkt es auch umständlich, alle Stellen zu prüfen und anzupassen, die diese Funktion verwenden.
Wenn es keine Arbeit ist, die extrem lange dauert, ist es im Hinblick auf die Wartbarkeit des Codes vielleicht besser,
awaitseriell auszuführen.Ich denke, man sollte das mit dem Konzept angehen: „Da der Overhead von Multithreading belastend ist, löst man Parallelverarbeitung alternativ, indem man einen Single-Thread aufteilt.“ Deshalb scheint es mir richtig, dass man dabei je nach Situation grundsätzlich sogar noch mehr Sorgfalt braucht als bei Multithreading.
Das stimmt auch.
Es scheint, als sei wirklich gut geschriebener asynchroner Code seinem Wesen nach Code, dem man sehr viel Aufmerksamkeit widmen muss.