Blogs / Blockierte Event-Loop in FastAPI: Diagnose und Behebung

Blockierte Event-Loop in FastAPI: Diagnose und Behebung

Veröffentlicht
26. August 2026
Autor
Faizan Nadeem
Tags
FastAPI Python AsyncIO Performance
Eine Nahaufnahme einer Uhr mit freigelegtem Innenleben aus ZahnrÀdern und Mechanik
Foto von Peter Bryan auf Unsplash

„Mach es einfach zu async def“ ist der Rat, den jeder FastAPI-Entwickler zuerst hört, und genau deshalb beginnen so viele ProduktionsvorfĂ€lle damit, dass ein langsamer Endpoint still und leise jeden anderen Endpoint mit in den Abgrund zieht. Der Impuls ist nachvollziehbar — async def wirkt wie die „schnelle“ Option und def wie die veraltete — aber dieses Framing verdreht den tatsĂ€chlichen Fehlermodus. async def bedeutet nicht kostenlose ParallelitĂ€t. Es ist ein Versprechen an die Event-Loop, dass diese Funktion niemals einfach sitzen bleibt und blockiert, und FastAPI hat keine Möglichkeit zu prĂŒfen, ob du dieses Versprechen eingehalten hast, bis es bereits zu spĂ€t ist.

Die wahre Geschichte ist spezifischer als „verwende ĂŒberall async“, und sie zu verstehen ist der Unterschied zwischen einer API, die sich unter Last kontrolliert verschlechtert, und einer, bei der ein einzelner requests.get()-Aufruf innerhalb eines async def-Handlers jeden gleichzeitigen Nutzer auf dem Server einfriert — nicht nur den, der diese Anfrage gestellt hat. Dieser Beitrag zeigt, was tatsĂ€chlich passiert, wenn eine Coroutine blockiert, warum sich def-Routen völlig anders verhalten, wie du den blockierenden Aufruf in einer Bibliothek erkennst, die du nie geprĂŒft hast, und was du konkret tun solltest, sobald du ihn gefunden hast.

Du lernst:

  • Was FastAPIs Event-Loop tatsĂ€chlich ist und warum sie immer nur eine Sache gleichzeitig tun kann
  • Den echten Unterschied zwischen async def- und def-Routen und warum FastAPI sie unter der Haube völlig unterschiedlich behandelt
  • Wie der Threadpool, in dem def-Routen laufen, die Event-Loop schĂŒtzt und warum dieses Sicherheitsnetz fĂŒr async def nicht existiert
  • Welche blockierenden Aufrufe sich in async def-Handlern verstecken — synchrone DB-Treiber, requests, time.sleep, CPU-gebundene Arbeit
  • Wie man eine blockierte Event-Loop in einem laufenden Service tatsĂ€chlich diagnostiziert, statt nur zu raten
  • Die konkreten Lösungen: run_in_executor, async-native Bibliotheken und wann du einfach def verwenden solltest

Inhaltsverzeichnis

  1. Die Grundlagen
  2. async def vs def: Was FastAPI tatsÀchlich macht
  3. Das Threadpool-Sicherheitsnetz
  4. Blockierende Aufrufe, die sich in async def verstecken
  5. Diagnose einer blockierten Event-Loop
  6. Die Behebung
  7. Ein durchgearbeitetes Beispiel
  8. HĂ€ufige Fallstricke
  9. Best Practices fĂŒr die Produktion

Die Grundlagen

Eine Event-Loop, ein Thread, keine Ausnahmen

FastAPI basiert auf Starlette, das wiederum auf asyncio basiert. In der Standardkonfiguration fĂŒhrt ein einzelner Worker-Prozess eine einzelne Event-Loop auf einem einzelnen Thread aus, und jede async def-Coroutine in deiner App — jeder Request-Handler, jede Dependency, jede Middleware — ist abwechselnd auf genau diesem einen Thread an der Reihe. Dieses „abwechselnd“ funktioniert nur, wenn jede Coroutine die Kontrolle regelmĂ€ĂŸig an die Loop zurĂŒckgibt, was bei jedem await geschieht. Zwischen einem await und dem nĂ€chsten gehört dieser Coroutine der Thread vollstĂ€ndig. Nichts anderes im Prozess lĂ€uft — keine anderen Requests, keine Health Checks, nicht einmal die eigenen Interna des Frameworks — bis sie entweder fertig ist oder ein weiteres await erreicht.

Das ist das gesamte mentale Modell, das du brauchst: Eine async def-Funktion, die niemals irgendetwas awaited oder die etwas Synchrones und Langsames aufruft, ohne darauf zu awaiten, lĂ€uft nicht einfach nur langsam — sie hĂ€lt jeden anderen gerade laufenden Request so lange komplett an, wie sie dafĂŒr braucht.

Warum sich das als „zufĂ€llige“ Langsamkeit zeigt

Das Symptom sieht selten so aus wie „dieser eine Endpoint ist langsam“. Es sieht vielmehr so aus, dass ein völlig unabhĂ€ngiger, normalerweise schneller Endpoint gelegentlich drei Sekunden braucht — ohne sichtbaren Grund — weil er in diesen drei Sekunden hinter dem blockierenden Aufruf eines anderen auf demselben Thread in die Warteschlange geraten ist. Genau das macht den Bug so schwer auffindbar: Der langsame Request und der betroffene Request sind normalerweise unterschiedliche Endpoints, daher zeigt Profiling des betroffenen Endpoints in Isolation ĂŒberhaupt nichts AuffĂ€lliges.

async def vs def: Was FastAPI tatsÀchlich macht

FastAPI behandelt die beiden Signaturen völlig unterschiedlich, und genau dieser Unterschied ist die ganze Geschichte:

@app.get("/fast-if-truly-async")
async def get_data():
    # Runs directly on the event loop thread.
    # Every `await` here yields control back to the loop.
    result = await some_async_db_call()
    return result

@app.get("/runs-in-threadpool")
def get_data_sync():
    # FastAPI automatically dispatches this to a worker
    # thread from Starlette's threadpool — it never runs
    # on the event loop thread at all.
    result = some_blocking_db_call()
    return result

Eine async def-Route lĂ€uft direkt auf der Event-Loop. FastAPI vertraut darauf, dass du eine Funktion geschrieben hast, die den Thread niemals fĂŒr einen nennenswerten Zeitraum ohne ein dazwischenliegendes await blockiert. Eine def-Route wird dagegen automatisch ĂŒber run_in_threadpool an einen separaten Worker-Thread ausgelagert — du erhĂ€ltst korrektes, nicht blockierendes Verhalten, ohne eine einzige Zeile Async-Code zu schreiben, weil die blockierende Arbeit komplett woanders stattfindet.

Das ist exakt das Gegenteil von dem, was die meisten Menschen bei ihrem ersten Kontakt mit FastAPI vermuten: def ist nicht die „langsame, veraltete“ Option. FĂŒr ein wirklich synchrones StĂŒck Arbeit — einen Aufruf einer Bibliothek ohne Async-Äquivalent, etwas CPU-leichtes blockierendes I/O — ist schlichtes def oft die sicherere Wahl, weil es die Loop nicht versehentlich blockieren kann, selbst wenn du die Interna falsch implementierst.

Das Threadpool-Sicherheitsnetz

Der Threadpool von Starlette (gestĂŒtzt auf anyio) verwendet standardmĂ€ĂŸig einen begrenzten Worker-Pool — historisch 40 Threads — in dem def-Routen und def-Dependencies laufen. Jeder Request an eine def-Route belegt einen Thread, erledigt dort seine blockierende Arbeit und ĂŒbergibt das Ergebnis anschließend an die Event-Loop zurĂŒck. Die Event-Loop selbst sitzt dabei niemals wartend auf diesem Aufruf; sie kann weiterhin gleichzeitig andere Requests bedienen.

Das ist ein echtes Sicherheitsnetz, aber kein unbegrenztes. Wenn dein Service deutlich mehr als 40 gleichzeitige langsame synchrone Requests erhĂ€lt, landen spĂ€tere hinter frĂŒheren in der Warteschlange und warten auf einen freien Thread — ein anderer Fehlermodus als das Blockieren der Loop, aber trotzdem ein Engpass, den man kennen sollte. Das lĂ€sst sich ĂŒber den Thread-Limiter von Starlette/anyio anpassen, aber die nachhaltigere Lösung bei echter Skalierung ist normalerweise, den Anteil synchroner, threadhungriger Arbeit ĂŒberhaupt zu reduzieren, statt die Pool-GrĂ¶ĂŸe immer weiter hochzusetzen.

Der Threadpool greift nur bei def. Nichts fĂ€ngt einen blockierenden Aufruf innerhalb von async def ab — genau deshalb existiert diese gesamte Bug-Klasse.

Den Threadpool-Limiter anpassen

Wenn du tatsĂ€chlich mehr als das standardmĂ€ĂŸige Limit an Worker-Threads brauchst — etwa fĂŒr einen Burst synchroner Datei-Uploads — stellt anyio einen Capacity Limiter bereit, den du beim Start erhöhen kannst:

import anyio
from anyio import to_thread

@app.on_event("startup")
async def raise_threadpool_limit():
    limiter = to_thread.current_default_thread_limiter()
    limiter.total_tokens = 100

Betrachte das als Zwischenlösung, nicht als Fix. Jeder zusĂ€tzliche Thread erhöht den Speicherbedarf und den Overhead fĂŒr Kontextwechsel, und es löst ĂŒberhaupt nichts bei einer wirklich blockierten Event-Loop — es hilft nur dann, wenn der Engpass die VerfĂŒgbarkeit von Threads fĂŒr def-Routen ist, was ein anderes Problem ist als das, um das es in diesem Beitrag hauptsĂ€chlich geht.

Warum mehr Uvicorn-Worker das nicht beheben

Ein hĂ€ufiger, aber falscher Reflex ist, sich aus einem Vorfall mit blockierter Loop herauszuskalieren, indem man mehr Uvicorn-/Gunicorn-Worker-Prozesse hinzufĂŒgt. Jeder Worker-Prozess bekommt tatsĂ€chlich seine eigene Event-Loop, also erhöhen mehr Worker auch die GesamtkapazitĂ€t — aber innerhalb eines einzelnen Workers tritt exakt dasselbe blockierende Verhalten auf: Ein einzelner langsamer async def-Aufruf blockiert weiterhin jeden Request, der aktuell dieser Worker-Loop zugewiesen ist. Mehr Worker reduzieren den Blast Radius pro Vorfall (jeweils nur 1/N deines Traffics trifft gleichzeitig den blockierten Worker), aber sie beheben die eigentliche Ursache nicht und vervielfachen die Infrastrukturkosten, nur um einen Bug zu kaschieren, den ein fĂŒnfzeiliger Code-Fix kostenlos beseitigen wĂŒrde. Betrachte zusĂ€tzliche Worker als Resilienz-Puffer, niemals als die eigentliche Lösung fĂŒr einen blockierenden Aufruf.

Blockierende Aufrufe, die sich in async def verstecken

Das gefĂ€hrliche Muster hat immer dieselbe Form: ein async def-Handler, der etwas Synchrones ohne Wrapping aufruft, sodass die „async“-Funktion die Loop genauso hart blockiert wie eine def-Funktion — nur ohne den Threadpool, der sie gerettet hĂ€tte.

import time
import requests  # synchronous HTTP client

@app.get("/danger")
async def danger():
    time.sleep(2)                 # blocks the entire event loop for 2s
    resp = requests.get(EXTERNAL) # blocks for however long the network takes
    return resp.json()

Jeder irgendwo im Prozess laufende Request steht fĂŒr die kombinierte Dauer beider Aufrufe still. Die ĂŒblichen Schuldigen:

  • Synchrone HTTP-Clients — requests oder httpx.Client (nicht httpx.AsyncClient) — innerhalb von async def.
  • Synchrone Datenbanktreiber — psycopg2, der Sync-Modus von SQLAlchemy — die direkt statt ĂŒber einen Async-Treiber aufgerufen werden. Genau diese Falle wird ausfĂŒhrlich behandelt in Async SQLAlchemy Sessions in FastAPI, Done Right: Das Mischen einer Sync-Engine in eine Async-Codebasis fĂŒhrt genau dieses Blockierverhalten wieder ein.
  • time.sleep() statt asyncio.sleep() — ein einfacher Tippfehler, der keinerlei Wirkung zeigt, bis gleichzeitige Last ins Spiel kommt.
  • CPU-gebundene Arbeit — Bildskalierung, PDF-Erzeugung, schwere pandas-Transformationen, kryptografisches Hashing. Diese blockieren unabhĂ€ngig davon, welche Client-Bibliothek du verwendest, denn die Kosten entstehen durch Berechnung, nicht durch I/O — es gibt keine Async-Version von „die CPU ist beschĂ€ftigt“, und kein noch so großes await behebt das.
  • Datei-I/O auf lokaler Festplatte — open(), .read(), .write() sind alles synchrone Systemaufrufe, sofern du sie nicht ĂŒber aiofiles oder einen Threadpool leitest.

Nichts davon wirft einen Fehler. Es sorgt nur still und leise dafĂŒr, dass jeder andere Request im Prozess warten muss, bis er an der Reihe ist.

Diagnose einer blockierten Event-Loop

Zu raten, welcher Endpoint der Schuldige ist, funktioniert selten — du brauchst echte Signale aus dem laufenden Prozess.

1. asyncio-Debug-Modus. Das AusfĂŒhren mit PYTHONASYNCIODEBUG=1 (oder asyncio.run(main(), debug=True)) veranlasst asyncio, eine Warnung zu protokollieren, wann immer ein Callback lĂ€nger als 100 ms zur AusfĂŒhrung braucht — genau das Symptom einer blockierten Loop.

PYTHONASYNCIODEBUG=1 uvicorn app.main:app

2. Server.log_slow_callbacks / manuelle Loop-Instrumentierung. Du kannst eine periodische Heartbeat-Coroutine anhĂ€ngen, die ihre eigene Scheduling-Verzögerung misst — wenn ein Heartbeat, der alle 100 ms feuern sollte, regelmĂ€ĂŸig zu spĂ€t feuert, monopolisiert etwas die Loop:

import asyncio, time

async def loop_monitor():
    while True:
        start = time.monotonic()
        await asyncio.sleep(0.1)
        drift = time.monotonic() - start - 0.1
        if drift > 0.05:
            print(f"Event loop blocked for ~{drift:.2f}s")

3. Lasttests mit einem gemischten Endpoint-Set. Beschieße einen bekanntermaßen schnellen Endpoint und einen mutmaßlich langsamen gleichzeitig mit einem Tool wie locust oder hey. Wenn sich die Latenz des schnellen Endpoints im Gleichschritt mit der Last auf dem langsamen verschlechtert, siehst du Loop-Contention und keine endpoint-spezifische Langsamkeit.

4. APM-Traces mit Thread-/Task-Kontext. Tools wie Datadog APM oder OpenTelemetry können dir zeigen, ob ein Span auf dem Event-Loop-Thread oder auf einem Threadpool-Worker lĂ€uft — ein langsamer async def-Span ohne Child-Spans ist ein starkes Indiz dafĂŒr, dass er synchron blockierende Arbeit ausfĂŒhrt.

5. py-spy dump gegen den Live-Prozess. py-spy hĂ€ngt sich an einen laufenden Python-Prozess an, ohne ihn neu zu starten, und gibt den aktuellen Stack jedes Threads aus. Auf einem festhĂ€ngenden Server fĂŒhre py-spy dump --pid <uvicorn-worker-pid> aus und suche nach einem einzelnen Thread, der in time.sleep, einem synchronen Socket-Aufruf oder einer C-Erweiterung eines Datenbanktreibers festhĂ€ngt — das ist dein blockierender Aufruf, auf frischer Tat ertappt, in Produktion, ohne dass CodeĂ€nderungen nötig sind, um ihn zu reproduzieren.

pip install py-spy
py-spy dump --pid $(pgrep -f "uvicorn app.main:app" | head -1)

Die Behebung

Sobald du den blockierenden Aufruf identifiziert hast, gibt es drei echte Optionen, in dieser Reihenfolge der PrÀferenz:

1. Verwende die async-native Version der Bibliothek. requests → httpx.AsyncClient; psycopg2 → asyncpg oder SQLAlchemys Async-Engine; time.sleep → asyncio.sleep. Das ist fast immer der richtige Fix — er entfernt den blockierenden Aufruf, statt ihn nur zu umschiffen.

import httpx

@app.get("/fixed")
async def fixed():
    async with httpx.AsyncClient() as client:
        resp = await client.get(EXTERNAL)
    return resp.json()

2. Lagere explizit in einen Thread aus mit run_in_threadpool oder run_in_executor. Wenn es kein Async-Äquivalent gibt — ein Legacy-SDK, eine von C-Erweiterungen gestĂŒtzte Bibliothek — schiebe den blockierenden Aufruf manuell in einen Worker-Thread, statt ihn auf der Loop auszufĂŒhren:

from starlette.concurrency import run_in_threadpool

@app.get("/legacy-sdk")
async def legacy_sdk():
    result = await run_in_threadpool(legacy_blocking_call, arg1, arg2)
    return result

3. Mach einfach eine def-Route daraus. Wenn ein Handler grundsĂ€tzlich synchron ist — er ruft ein blockierendes SDK auf und tut sonst nichts gleichzeitig — gibt es keinen Vorteil darin, ihn in async def zu zwingen. Lass FastAPIs Threadpool damit umgehen; genau dafĂŒr ist er da.

Bei wirklich CPU-gebundener Arbeit (Bildverarbeitung, aufwendige Berechnungen) helfen weder Threads noch async def tatsĂ€chlich, weil Pythons GIL bedeutet, dass Threads dir fĂŒr CPU-gebundenen Code keine ParallelitĂ€t geben — du brauchst ProcessPoolExecutor oder du musst die Arbeit ganz aus dem Request-Pfad heraus in einen Background-Worker verschieben. Diese Entscheidung wird in FastAPI BackgroundTasks vs Celery: Picking the Right One behandelt.

Ein durchgearbeitetes Beispiel

Um den Effekt direkt zu sehen, vergleiche zwei Versionen desselben Endpoints unter gleichzeitiger Last. Beide simulieren eine 1-sekĂŒndige blockierende Dependency; beide werden von einem Testskript mit httpx 20-mal gleichzeitig aufgerufen.

# blocking.py
@app.get("/blocking")
async def blocking():
    time.sleep(1)  # synchronous sleep inside async def
    return {"ok": True}

# fixed.py
@app.get("/fixed")
async def fixed():
    await asyncio.sleep(1)  # yields control back to the loop
    return {"ok": True}

Zwanzig gleichzeitige Requests an /blocking werden seriell abgeschlossen — insgesamt ungefĂ€hr 20 Sekunden, weil jedes time.sleep(1) den einzigen arbeitenden Thread vollstĂ€ndig belegt, bevor der nĂ€chste Request ĂŒberhaupt verarbeitet werden kann. Dieselben 20 gleichzeitigen Requests an /fixed sind insgesamt in ungefĂ€hr 1 Sekunde abgeschlossen, weil asyncio.sleep die Kontrolle sofort zurĂŒckgibt und alle zwanzig Coroutines von der Loop gemeinsam angehalten und wieder aufgenommen werden. An der nach außen beworbenen Latenz des Endpoints selbst hat sich nichts geĂ€ndert — nur daran, ob er tatsĂ€chlich mit allem anderen kooperiert, das im Prozess lĂ€uft.

Die LĂŒcke wird mit steigender ParallelitĂ€t grĂ¶ĂŸer, nicht kleiner. Bei zehn gleichzeitigen Requests könnte /blocking in einem kurzen manuellen Test noch akzeptabel wirken — ungefĂ€hr zehn Sekunden sind nicht alarmierend, wenn niemand genau auf die Uhr schaut. Bei den Traffic-Mengen, die ein echter Produktionsservice sieht, verwandelt sich dasselbe linear-serialisierte Verhalten in ein p95-Latenzdiagramm, das mit dem Request-Volumen in gerader Linie ansteigt, statt sich so abzuflachen, wie es bei einem gesunden Async-Service der Fall sein sollte. Genau diese Form — Latenz, die linear mit gleichzeitiger Last skaliert, statt bis zu echten Ressourcenlimits ungefĂ€hr konstant zu bleiben — ist eines der klarsten FingerabdrĂŒcke einer blockierten Event-Loop in einem Dashboard und normalerweise sichtbar, lange bevor jemand die eigentliche Ursache manuell diagnostiziert.

HĂ€ufige Fallstricke

Fehler: anzunehmen, async def sei immer schneller. FĂŒr eine Route mit einem einzigen synchronen, in der Praxis nicht blockierenden Aufruf sind def und der Threadpool einfacher und genauso sicher. Lösung: Verwende standardmĂ€ĂŸig def fĂŒr wirklich synchrone Arbeit und reserviere async def fĂŒr Codepfade, die tatsĂ€chlich auf etwas awaiten.

Fehler: eine Sync-ORM-Session in eine Async-Route zu mischen. Der Aufruf einer synchronen SQLAlchemy-Session innerhalb von async def blockiert die Loop genauso wie jeder andere Sync-Aufruf. Lösung: Verwende durchgÀngig eine Async-Engine und eine Async-Session, wie im Beitrag zu SQLAlchemy-Sessions beschrieben.

Fehler: nicht zu erkennen, dass CPU-gebundene Arbeit der eigentliche Engpass ist. Einen CPU-schweren Handler auf async def umzustellen oder ihn in run_in_threadpool zu wrappen hilft nicht — der GIL serialisiert CPU-Arbeit weiterhin ĂŒber Threads hinweg. Lösung: Verwende ProcessPoolExecutor fĂŒr CPU-gebundene Arbeit oder verschiebe sie vollstĂ€ndig aus dem Request-/Response-Zyklus heraus.

Fehler: in Isolation zu debuggen. Den mutmaßlich langsamen Endpoint allein und ohne gleichzeitige Last zu testen reproduziert das Symptom nie — der gesamte Bug hĂ€ngt von Contention ab. Lösung: Reproduziere immer mit gleichzeitigen Requests, die mehrere Endpoints gleichzeitig treffen.

Fehler: als erste Reaktion die GrĂ¶ĂŸe des Threadpools zu erhöhen. Das kaschiert Thread-Hunger bei def-Routen, tut aber nichts gegen eine blockierte Event-Loop, die von async def verursacht wird. Lösung: Diagnostiziere zuerst, welchen Fehlermodus du tatsĂ€chlich hast, bevor du zu einer KonfigurationsĂ€nderung greifst.

Best Practices fĂŒr die Produktion

  • Standardisiere neue synchrone Integrationen auf def, nicht auf async def. Lass FastAPIs Threadpool seine Arbeit machen, statt denselben Schutz manuell nachzubauen.
  • PrĂŒfe jede async def auf einen Aufruf, der nicht awaited wird. Ein schnelles Grep nach requests., time.sleep( oder Importen synchroner Treiber innerhalb von Async-Handlern verhindert die meisten echten VorfĂ€lle, bevor sie ausgeliefert werden.
  • FĂŒge Monitoring fĂŒr Loop-Lag in Produktion hinzu, nicht nur lokal. Eine leichtgewichtige Heartbeat-Coroutine, die Scheduling-Drift protokolliert, kostet fast nichts und erkennt Regressionen sofort.
  • FĂŒhre Lasttests mit ParallelitĂ€t durch, nicht mit einzelnen Requests. Loop-Contention ist unter sequentiellem Testen per Definition unsichtbar.
  • Bringe zuerst das grundlegende Layout der App in Ordnung. Eine Struktur, die Route-Handler schlank hĂ€lt — wie in FastAPI Project Structure That Survives Growth beschrieben — macht es viel einfacher, einen verirrten blockierenden Aufruf zu entdecken, weil die Business-Logik an einem offensichtlichen Ort liegt, statt ĂŒber Handler verstreut zu sein.

Zum Schluss

Eine blockierte Event-Loop ist keine mysteriöse Performance-Obergrenze — sie ist eine konkrete, nachvollziehbare Folge davon, dass eine Coroutine den einzigen Thread festhĂ€lt, von dem der gesamte Prozess abhĂ€ngt. Bei async def vs def ging es nie darum, welche Variante „modern“ ist; es geht darum, ob du FastAPI Code gibst, der tatsĂ€chlich mit der Loop kooperiert, oder Code, der nur so aussieht. PrĂŒfe auf blockierende Aufrufe, bevor Lasttests sie fĂŒr dich ans Licht bringen, und ĂŒberlass es im Zweifel dem Threadpool — def ist nicht die RĂŒckfalloption, sondern sehr hĂ€ufig die richtige.

Hast du deine async def-Routen tatsĂ€chlich unter gleichzeitiger Last getestet, oder nur angenommen, dass das SchlĂŒsselwort die Arbeit schon fĂŒr dich erledigt?

Weitere Artikel