23 Punkte von darjeeling 2025-11-16 | 12 Kommentare | Auf WhatsApp teilen

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 def deklariert werden. Treffen sie auf das Schlüsselwort await, 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.gather oder asyncio.TaskGroup werden 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 await behandelt 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) oder aiofiles (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 mit await blockiert.
    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.Semaphore die 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 await behandelt, sodass die Aufgabe gar nicht ausgeführt wird und stillschweigend fehlschlägt. Mit Lintern wie flake8-async lässt sich das erkennen.
  • Ungeeignete Ressourcenverwaltung: Werden Dateien, DB-Verbindungen usw. ohne try...finally verwendet, kann es zu Resource Leaks kommen. Die Lösung sind asynchrone Context Manager mit async 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.

https://youtu.be/wGDOwNW6lVk

12 Kommentare

 
savvykang 2025-11-18

Python ist zweifellos eine großartige Sprache, aber die asynchrone Schnittstelle scheint mir eine falsch konzipierte Funktion zu sein.

 
ceruns 2025-11-17

Bei Punkt 4 fehlt eager_start=True. Da create_task ein weakref erstellt, wäre das Code, bei dem der Task womöglich niemals ausgeführt wird....

 
tested 2025-11-17

> https://rosettalens.com/s/ko/python-to-node

Diese Person meinte auch, sie sei wegen Python-async auf Node.js umgestiegen.

 
kandk 2025-11-17

Fazit: Die asynchrone Schnittstelle von Python ist noch immer nicht intuitiv.

 
bungker 2025-11-17

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.

 
euphcat 2025-11-17

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.

 
vwjdalsgkv 2025-11-17

Ob JIT-Kompilierung vorhanden ist oder nicht, macht mehr aus, als man denkt. V8 ist sehr gut optimiert.

 
euphcat 2025-11-16

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.

 
kunggom 2025-11-16

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 await einfach 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üsselwort await stehen muss, sich aber über die darüber hinausgehende Reihenfolge der asynchronen Ausführung kaum Gedanken macht, und deshalb solcher Code entsteht.
Wenn mehrere await vorkommen, wird das Ergebnis mancher Aufrufe direkt darunter verwendet, sodass man davor den await-Rückgabewert des Task<T>-Objekts entgegennimmt; anderes wird erst deutlich später verwendet, sodass man zunächst nur den Task<T> entgegennimmt und erst später await darauf 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.“

 
skageektp 2025-11-17

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, await seriell auszuführen.

 
euphcat 2025-11-17

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.

 
kunggom 2025-11-17

Das stimmt auch.
Es scheint, als sei wirklich gut geschriebener asynchroner Code seinem Wesen nach Code, dem man sehr viel Aufmerksamkeit widmen muss.