# Asyncio in Python: async/await, TaskGroup, Threads, Multiprocessing und GIL

URL: https://skillaria.top/de/guides/python/asyncio-python

Asyncio ist die eingebaute Bibliothek von Python für asynchronen Code: Funktionen mit async def (Koroutinen) pausieren bei await, und während eine auf eine Netzwerkantwort wartet, führt die Ereignisschleife andere Aufgaben aus, sodass Hunderte Anfragen gleichzeitig in einem Thread laufen. Für das Warten auf Netz und Festplatte eignen sich asyncio oder Threads (threading), für schwere Berechnungen Prozesse (multiprocessing), weil der GIL in der gewöhnlichen CPython-Version parallele Berechnungen in Threads verhindert.

Asyncio ist die eingebaute Bibliothek von Python für asynchronen Code. Funktionen, die mit `async def` definiert sind, können bei `await` pausieren. Während eine davon auf die Antwort eines Netzwerks wartet, führt die Ereignisschleife andere Aufgaben aus. So laufen Hunderte Anfragen gleichzeitig in einem einzigen Thread. Asyncio ist aber nur eines von drei Werkzeugen: Daneben gibt es Threads (`threading`) und Prozesse (`multiprocessing`). Wir sehen uns an, wie asyncio in Python funktioniert, was man für welche Aufgabe wählt und welche Rolle der GIL spielt.

## Warten und Rechnen sind verschiedene Aufgaben

Ohne diese Unterscheidung lässt sich der Rest nicht verstehen. Ein Programm wird aus einem von zwei Gründen langsam:

- **I/O-bound** — es wartet auf Ein- und Ausgabe: auf die Antwort einer API, einer Datenbank oder das Lesen einer Festplatte. Der Prozessor liegt dabei brach.
- **CPU-bound** — es rechnet: verarbeitet Bilder oder durchläuft Schleifen über Millionen Datensätze. Der Prozessor ist ausgelastet.

Zum Warten eignen sich asyncio oder Threads: Während eine Operation wartet, läuft die andere. Für Berechnungen braucht man echte parallele Verarbeitung auf mehreren Kernen, also Prozesse. Der Hauptfehler besteht darin, beides zu verwechseln: Threads beschleunigen eine Berechnung in reinem Python nicht, und Prozesse für drei HTTP-Anfragen sind mit Kanonen auf Spatzen geschossen.

## Was asyncio ist: Koroutinen und die Ereignisschleife

Asynchroner Code beruht auf zwei Schlüsselwörtern. `async def` definiert eine Koroutine, eine Funktion, die pausieren kann. Ihr Aufruf führt den Körper nicht aus, sondern erzeugt ein Koroutinenobjekt. `await` innerhalb bedeutet: „Hier warte ich, und du kannst inzwischen andere Aufgaben erledigen.“ Alles steuert die Ereignisschleife (event loop), die man mit `asyncio.run()` startet.

```
import asyncio

async def fetch_rate(currency):
    await asyncio.sleep(1)          # Simulation einer API-Anfrage
    return {"USD": 92.5, "EUR": 100.1, "CNY": 12.7}[currency]

async def main():
    rate = await fetch_rate("USD")
    print(rate)

asyncio.run(main())  # 92.5
```

`asyncio.sleep()` ist eine asynchrone Pause: Sie gibt die Steuerung an die Ereignisschleife zurück. Das gewöhnliche `time.sleep()` innerhalb einer Koroutine würde dagegen die ganze Schleife blockieren, und alle anderen Aufgaben stünden still.

### Nacheinander oder gleichzeitig

Schreibt man `await` in einer Schleife, laufen die Anfragen nacheinander: Drei Währungen dauern drei Sekunden. Damit sie gleichzeitig laufen, müssen Sie sie als Aufgaben starten.

```
import asyncio, time

async def main():
    start = time.perf_counter()
    for cur in ("USD", "EUR", "CNY"):
        await fetch_rate(cur)                       # nacheinander
    print(f"{time.perf_counter() - start:.1f} s")   # 3.0 s

asyncio.run(main())
```

## TaskGroup: gemeinsam starten und auf alle warten

Der moderne Weg (Python 3.11+) ist die Aufgabengruppe. Innerhalb des Blocks `async with` startet jede Aufgabe sofort über `tg.create_task()`, und der Block endet, wenn alle fertig sind.

```
async def main():
    start = time.perf_counter()
    async with asyncio.TaskGroup() as tg:
        tasks = {cur: tg.create_task(fetch_rate(cur)) for cur in ("USD", "EUR", "CNY")}
    rates = {cur: t.result() for cur, t in tasks.items()}
    print(rates, f"{time.perf_counter() - start:.1f} s")   # ... 1.0 s

asyncio.run(main())
```

Eine TaskGroup arbeitet streng: Schlägt eine Aufgabe fehl, werden die übrigen abgebrochen, und alle Fehler werden in einer `ExceptionGroup` gesammelt. Abgefangen werden sie mit `except*`:

```
async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(fetch_rate("USD"))
            tg.create_task(fetch_rate("XXX"))   # KeyError
    except* KeyError as eg:
        print("unbekannte Währung:", eg.exceptions)
```

### asyncio.gather und create_task

Vor der TaskGroup war `asyncio.gather()` der Standard. Es startet Koroutinen gleichzeitig und liefert die Ergebnisse in derselben Reihenfolge zurück. Es ist im vorhandenen Code noch sehr verbreitet.

```
async def main():
    usd, eur, cny = await asyncio.gather(
        fetch_rate("USD"), fetch_rate("EUR"), fetch_rate("CNY")
    )
    # mit return_exceptions=True werden Fehler in der Liste zurückgegeben und brechen nicht alles ab
```

`asyncio.create_task()` startet eine Koroutine im Hintergrund ohne Gruppe. Die Referenz auf die Aufgabe müssen Sie speichern, sonst kann der Garbage Collector sie entfernen, und Fehler bleiben unbemerkt. Deshalb ist die TaskGroup für neue Projekte die sicherere Wahl.

### Aufgaben abbrechen und Zeitlimits

```
async def main():
    task = asyncio.create_task(fetch_rate("USD"))
    task.cancel()                     # in der Aufgabe wird CancelledError ausgelöst
    try:
        await task
    except asyncio.CancelledError:
        print("abgebrochen")

    try:
        async with asyncio.timeout(0.5):   # Python 3.11+
            await fetch_rate("EUR")
    except TimeoutError:
        print("zu lange")
```

In älteren Versionen nutzt man statt `asyncio.timeout()` die Funktion `asyncio.wait_for(coro, timeout=0.5)`. Außerdem gibt es `asyncio.wait()`, das Mengen von abgeschlossenen und offenen Aufgaben liefert und auf die erste fertige warten kann (`return_when=asyncio.FIRST_COMPLETED`).

In Python 3.15 hat die TaskGroup, wie im Kurs gezeigt, eine Methode `tg.cancel()` erhalten: Sie bricht alle Aufgaben der Gruppe ohne Ausnahme ab. Damit lässt sich der Fall „Wer ist zuerst?“ lösen: Man fragt drei Spiegel-Dienste ab, nimmt die erste Antwort und bricht die anderen ab. Früher musste man dafür eine eigene Signal-Ausnahme werfen oder die Aufgaben von Hand abbrechen.

## Synchronisation: Semaphore, Lock, Event, Queue

Asyncio hat eigene Werkzeuge, die mit `await` arbeiten. Das nützlichste ist `Semaphore`: Es begrenzt die Anzahl gleichzeitiger Vorgänge, damit man eine fremde API nicht mit tausend Anfragen überlastet.

```
sem = asyncio.Semaphore(10)

async def limited(url):
    async with sem:              # höchstens 10 gleichzeitig
        await asyncio.sleep(0.1) # hier stünde die Anfrage
        return url
```

- `asyncio.Lock` — nur eine Koroutine im kritischen Abschnitt.
- `asyncio.Event` — eine Koroutine wartet mit `await event.wait()`, eine andere signalisiert mit `event.set()`.
- `asyncio.Queue` — Warteschlange nach dem Muster Erzeuger und Verbraucher: `await q.put()`, `await q.get()`, `q.task_done()`.

## Fallstrick: await vergessen

Der häufigste Fehler besteht darin, eine Koroutine ohne `await` aufzurufen:

```
async def main():
    rate = fetch_rate("USD")    # await vergessen
    print(rate * 2)             # TypeError
# RuntimeWarning: coroutine 'fetch_rate' was never awaited
```

Python hat die Funktion nicht ausgeführt, sondern nur ein Koroutinenobjekt erzeugt. Sehen Sie in den Logs „was never awaited“, suchen Sie nach dem fehlenden `await`. Der zweite typische Fehler ist ein blockierender Aufruf (`time.sleep`, `requests.get`) innerhalb einer Koroutine. Für HTTP nehmen Sie asynchrone Bibliotheken (httpx, aiohttp). Unvermeidlichen synchronen Code lagern Sie mit `await asyncio.to_thread(func, arg)` in einen Thread aus.

## Multithreading in Python: das Modul threading

Ein Thread ist ein eigenständiger Ausführungsfaden innerhalb eines Prozesses, Threads teilen sich den Speicher. Beim Warten auf das Netz funktionieren sie hervorragend und erfordern keine Umschreibung des Codes in async.

```
import threading, time

def download(name):
    time.sleep(1)               # Warten auf das Netz
    print(name, "fertig")

threads = [threading.Thread(target=download, args=(n,)) for n in ("a", "b", "c")]
for t in threads:
    t.start()
for t in threads:
    t.join()                    # alles zusammen in etwa 1 Sekunde
```

Der gemeinsame Speicher ist Vorteil und Fehlerquelle zugleich: Ändern zwei Threads dieselbe Variable, braucht man einen `threading.Lock`. Meist ist es einfacher, Threads nicht von Hand zu verwalten, sondern einen Pool zu verwenden.

### ThreadPoolExecutor

`concurrent.futures.ThreadPoolExecutor` legt einen Pool von Threads an und verteilt Aufgaben auf sie. `map` liefert die Ergebnisse in der ursprünglichen Reihenfolge.

```
from concurrent.futures import ThreadPoolExecutor
import time

def fetch(url):
    time.sleep(1)
    return f"{url}: 200"

urls = [f"https://example.com/{i}" for i in range(5)]
with ThreadPoolExecutor(max_workers=5) as pool:
    for result in pool.map(fetch, urls):
        print(result)           # alles zusammen in etwa 1 Sekunde
```

## Was der GIL ist und warum Threads Berechnungen nicht beschleunigen

Der GIL (Global Interpreter Lock) ist die globale Sperre des Interpreters CPython. Zu jedem Zeitpunkt führt nur ein Thread Python-Bytecode aus. Solange ein Thread auf Ein- und Ausgabe wartet, gibt er den GIL ab. Deshalb sind Threads für Netzwerkaufgaben nützlich. Bei reinen Berechnungen arbeiten vier Threads aber nicht schneller als einer, denn sie reichen sich die Sperre nacheinander weiter.

```
from concurrent.futures import ThreadPoolExecutor

def heavy(n):
    return sum(i * i for i in range(n))

with ThreadPoolExecutor(4) as pool:
    list(pool.map(heavy, [5_000_000] * 4))   # etwa so schnell wie nacheinander
```

Bibliotheken in C (NumPy und ähnliche) geben den GIL während schwerer Operationen oft frei. Für sie bringen Threads deshalb einen Geschwindigkeitsgewinn.

## multiprocessing: echte Parallelität

Jeder Prozess hat einen eigenen Interpreter und einen eigenen GIL, deshalb rechnen Prozesse auf verschiedenen Kernen gleichzeitig. Der Preis ist der getrennte Speicher: Daten werden zwischen Prozessen per Serialisierung (pickle) übertragen, und das Starten eines Prozesses ist teurer als das eines Threads.

```
from concurrent.futures import ProcessPoolExecutor

def heavy(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":      # unbedingt unter Windows und macOS
    with ProcessPoolExecutor() as pool:
        print(list(pool.map(heavy, [5_000_000] * 4)))
```

Ohne die Prüfung `if __name__ == "__main__"` würden die Kindprozesse beim Import des Moduls selbst neue Prozesse starten. Es gibt auch das niedrigere Modul `multiprocessing` mit `Process`, `Pool` und `Queue`. `ProcessPoolExecutor` ist aber einfacher und hat dieselbe Schnittstelle wie der Thread-Pool.

## Was wählen: asyncio, threading oder multiprocessing

| Aufgabe | Werkzeug | Warum |
| --- | --- | --- |

| 200 Seiten herunterladen, viele API-Anfragen | asyncio (httpx/aiohttp) oder ThreadPoolExecutor | Warten auf das Netz, der Prozessor ist frei |

| Mehrere Anfragen in einem bereits asynchronen Programm | TaskGroup | der Code ist schon async |

| Synchrone Bibliothek ohne async-Version | Threads, `asyncio.to_thread` | der Code muss nicht umgeschrieben werden |

| Schwere Berechnung in reinem Python | ProcessPoolExecutor | umgeht den GIL, nutzt alle Kerne |

## Python ohne GIL und Subinterpreter: was neu ist

Seit Python 3.13 gibt es eine Version ohne GIL (Free-Threading, PEP 703). Sie ist am Buchstaben t im Namen zu erkennen, etwa `python3.15t`. In 3.13 war sie experimentell, seit 3.14 wird sie offiziell unterstützt (PEP 779). Dort rechnen Threads tatsächlich parallel auf verschiedenen Kernen. Standardmäßig bekommen Sie aber weiterhin das normale Python mit GIL. Einthreadiger Code läuft in der Free-Threading-Version etwas langsamer, und nicht alle Bibliotheken sind darauf vorbereitet.

```
import sys
print(sys._is_gil_enabled())   # False in der Free-Threading-Version
```

Einen zweiten Weg zu parallelen Berechnungen brachte Python 3.14 (PEP 734): Subinterpreter im Modul `concurrent.interpreters`. Dabei laufen mehrere isolierte Interpreter in einem Prozess, jeder mit eigenem GIL. Am bequemsten nutzt man sie über `InterpreterPoolExecutor`, der genauso aussieht wie ein Thread-Pool. In der Messung des Kurses brauchten vier schwere Aufgaben 0.39 s gegenüber 1.03 s in einem Thread.

## Fazit

Asyncio in Python ist ein Werkzeug für Wartezeiten: Koroutinen mit `async def`, Pausen mit `await`, gleichzeitiger Start über TaskGroup oder `gather`. Threads lösen dieselbe Aufgabe ohne Umschreiben des Codes in async. Für schwere Berechnungen braucht man Prozesse, weil der GIL in der gewöhnlichen Version von CPython parallele Berechnungen in Threads verhindert. Free-Threading und Subinterpreter verändern dieses Bild nach und nach, aber die Grundregel gilt weiterhin: Warten Sie, nehmen Sie asyncio oder Threads. Rechnen Sie, nehmen Sie Prozesse. Im Kurs wird das praktisch behandelt: Der Ausgabentracker holt Wechselkurse parallel und braucht dafür eine Sekunde statt drei.

## FAQ

**Kann ich eine async-Funktion aus gewöhnlichem Code aufrufen?**

Ja, mit asyncio.run(coro()). Die Funktion erstellt eine Ereignisschleife, führt die Koroutine aus und schließt die Schleife. Innerhalb einer bereits laufenden Schleife darf man asyncio.run nicht aufrufen, dort verwendet man await.

**Läuft asyncio in mehreren Threads?**

Nein, die Ereignisschleife führt alle Koroutinen in einem Thread aus und wechselt nur an den Stellen await zwischen ihnen. Dadurch gibt es weniger Datenrennen als bei Threads, aber eine einzige blockierende Operation hält alles an.

**Muss ich asyncio über pip installieren?**

Nein, asyncio gehört zur Standardbibliothek von Python 3. Es genügt import asyncio. Das Paket asyncio auf PyPI ist veraltet und muss nicht installiert werden.

**Wie starte ich eine schwere Funktion aus asyncio, ohne die Schleife zu blockieren?**

Verwenden Sie loop.run_in_executor mit einem ProcessPoolExecutor für Berechnungen oder asyncio.to_thread für blockierende Ein- und Ausgabe. Die Ereignisschleife bearbeitet dann weiterhin die übrigen Aufgaben.

**Worin unterscheidet sich eine Koroutine von einer Aufgabe (Task)?**

Eine Koroutine ist ein Objekt, das nichts tut, solange niemand mit await darauf wartet. Eine Aufgabe umhüllt die Koroutine und reiht sie sofort in die Ereignisschleife ein, sodass sie parallel zum übrigen Code läuft.


---
Source: Skillaria.Top (https://skillaria.top)
