„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- unddef-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ĂĽrasync defnicht 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 einfachdefverwenden solltest
Inhaltsverzeichnis
- Die Grundlagen
- async def vs def: Was FastAPI tatsächlich macht
- Das Threadpool-Sicherheitsnetz
- Blockierende Aufrufe, die sich in async def verstecken
- Diagnose einer blockierten Event-Loop
- Die Behebung
- Ein durchgearbeitetes Beispiel
- Häufige Fallstricke
- 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 —
requestsoderhttpx.Client(nichthttpx.AsyncClient) — innerhalb vonasync 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()stattasyncio.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ßesawaitbehebt das. - Datei-I/O auf lokaler Festplatte —
open(),.read(),.write()sind alles synchrone Systemaufrufe, sofern du sie nicht ĂĽberaiofilesoder 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 aufasync def. Lass FastAPIs Threadpool seine Arbeit machen, statt denselben Schutz manuell nachzubauen. - PrĂĽfe jede
async defauf einen Aufruf, der nicht awaited wird. Ein schnelles Grep nachrequests.,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?

