Module 6 — Requêtes asynchrones et tâches de fond
FastAPI accepte def et async def pour définir une route. Le débutant en
déduit qu'il faut toujours écrire async def — c'est neuf, ça sonne moderne
— et se retrouve avec un service dix fois plus lent que la version
synchrone. Ce module explique quand async aide réellement, pourquoi il
peut nuire, et comment lancer un travail long sans bloquer le service.
Le modèle d'exécution : une boucle unique, plusieurs travailleurs
Uvicorn tourne sur asyncio, une boucle d'événement qui alterne entre les
requêtes en cours. Tant que le code d'une route ne fait qu'attendre —
lecture réseau, écriture disque asynchrone, appel HTTP — la boucle rend la
main aux autres requêtes et le service traite mille clients avec un seul
processus.
Le mot-clé qui fait rendre la main est await. Un await est le seul
point de la fonction où d'autres tâches peuvent s'insérer.
import httpx
@app.get("/exemple")
async def exemple():
async with httpx.AsyncClient() as client:
r = await client.get("https://exemple.fr/api/config")
return r.json()
Ici, la fonction exemple traite pendant la latence réseau les requêtes
d'autres appelants. C'est l'archétype d'un usage juste de async.
Où async ne sert à rien : le calcul CPU
Un modèle scikit-learn tourne sur le processeur ; il n'attend rien, il
calcule. Aucun await ne s'y trouve, donc aucune insertion possible dans
la boucle. Écrire :
@app.post("/predict")
async def predict(dossier: DossierAbonne):
# aucun await : la boucle est bloquée pendant la durée de predict_proba
return modele.predict_proba([dossier_vers_vecteur(dossier)])[0, 1]
est plus mauvais qu'écrire la même chose en def. Uvicorn, voyant
async def, exécute la fonction directement dans la boucle d'événement, et
la boucle reste bloquée pendant les 20 ms de predict_proba. Aucune
autre requête n'avance. Sur 100 requêtes simultanées, chacune attend la
fin des 99 autres : le débit s'effondre.
La bonne règle : def par défaut, async seulement s'il y a await
Écrire def (sans async) sur une route de scoring déclenche un
comportement radicalement différent. FastAPI voit la fonction synchrone et
l'exécute dans un fil d'exécution distinct de la boucle d'événement,
tiré d'un pool géré par Starlette. Uvicorn continue de traiter d'autres
requêtes pendant que le fil calcule.
@app.post("/predict") # def, pas async def
def predict(dossier: DossierAbonne):
return modele.predict_proba([dossier_vers_vecteur(dossier)])[0, 1]
Sur le fil rouge, la version def traite 300 requêtes par seconde derrière
trois travailleurs Uvicorn ; la version async def avec le même modèle et
sans await chute à 30. C'est le facteur 10 annoncé plus haut.
La règle inverse s'applique aux appels I/O : si la route lit une base de
données via un pilote asynchrone (asyncpg, aiohttp), on écrit
async def et on await. Le meilleur signal pour choisir est la présence
d'au moins un await dans le corps.
Forcer un calcul bloquant dans un fil : run_in_threadpool
On rencontre parfois une route qui est presque entièrement asynchrone
(elle attend une base) mais qui contient un petit bloc CPU. On préfère
alors garder async def et sous-traiter le bloc CPU au pool de fils.
from fastapi.concurrency import run_in_threadpool
@app.post("/predict")
async def predict(dossier: DossierAbonne):
x = dossier_vers_vecteur(dossier).reshape(1, -1)
# sort du contexte async, ne bloque pas la boucle :
proba = await run_in_threadpool(lambda: float(modele.predict_proba(x)[0, 1]))
return {"probabilite_resiliation": proba}
run_in_threadpool exécute la fonction passée dans le même pool que
celui utilisé pour les routes synchrones. Le service reste réactif pendant
le calcul.
Tâches de fond : rendre la main sans bloquer l'appelant
Un besoin fréquent : l'appelant envoie un fichier CSV de 20 000 lignes, on
lui répond « bien reçu, on te ping quand c'est prêt » et le calcul continue
sans lui. FastAPI expose BackgroundTasks pour les cas simples.
from fastapi import BackgroundTasks, UploadFile
import pandas as pd
from pathlib import Path
def scorer_fichier(chemin_entree: Path, chemin_sortie: Path, version_modele: str) -> None:
df = pd.read_csv(chemin_entree)
X = df[VARIABLES].to_numpy(dtype=np.float64)
df["probabilite_resiliation"] = modele.predict_proba(X)[:, 1]
df["version_modele"] = version_modele
df.to_csv(chemin_sortie, index=False)
@app.post("/predict/fichier", status_code=202)
async def predict_fichier(
fichier: UploadFile,
background: BackgroundTasks,
):
lot_id = str(uuid.uuid4())
chemin_in = Path(f"/tmp/{lot_id}.in.csv")
chemin_out = Path(f"/tmp/{lot_id}.out.csv")
chemin_in.write_bytes(await fichier.read())
background.add_task(scorer_fichier, chemin_in, chemin_out, "churn-2026-08-30")
return {"lot_id": lot_id, "statut": "en_cours", "sortie": str(chemin_out)}
Le code de statut 202 Accepted signale à l'appelant que la requête est
prise en compte mais que le résultat n'est pas encore là. L'appelant
consulte ensuite /predict/fichier/{lot_id} (à écrire séparément) pour
récupérer le statut ou télécharger la sortie.
Les limites de BackgroundTasks
BackgroundTasks s'exécute dans le même processus que le service. Trois
conséquences.
Un redémarrage tue la tâche. Le service qui reçoit un SIGTERM de
l'orchestrateur laisse tomber les tâches en cours ; le fichier de 20 000
lignes est perdu. Il faut soit un temps de grâce (Kubernetes
terminationGracePeriodSeconds), soit une reprise idempotente (on retente
le lot au démarrage).
Le calcul concurrence les requêtes en direct. Une tâche de fond qui occupe un cœur ralentit les requêtes qui arrivent en même temps. Sur un service avec trois travailleurs, trois fichiers en fond consomment tout ; plus rien ne répond en direct.
Il n'y a pas de file entre plusieurs conteneurs. Deux réplicas n'échangent rien : une tâche envoyée à l'un ne peut pas être reprise par l'autre.
Pour ces trois raisons, BackgroundTasks convient pour un envoi de mail,
la génération d'un PDF léger, une notification. Pour un scoring de
plusieurs minutes, on passe à une file de travail externe.
Vers une file de travail : Celery, RQ, ARQ, Dramatiq
Le patron classique découple deux services : l'API FastAPI qui reçoit la
requête et pousse un message dans une file (Redis, RabbitMQ), et un
travailleur séparé qui consomme la file et exécute le calcul.
# côté API
from redis import Redis
from rq import Queue
file = Queue(connection=Redis(host="redis"))
@app.post("/predict/fichier", status_code=202)
async def predict_fichier(fichier: UploadFile):
lot_id = str(uuid.uuid4())
chemin_in = Path(f"/lots/{lot_id}.in.csv")
chemin_in.write_bytes(await fichier.read())
file.enqueue(scorer_fichier, str(chemin_in), lot_id)
return {"lot_id": lot_id, "statut": "en_file_attente"}
L'API redevient très rapide, les travailleurs se dimensionnent indépendamment, et la file persiste les tâches : un redémarrage n'en perd aucune. C'est la voie recommandée dès que la tâche dépasse une minute ou que le volume dépasse quelques par heure.
Ne pas ouvrir de connexion async dans une route def
Une erreur symétrique du async def bloquant : appeler une bibliothèque
asynchrone (comme httpx.AsyncClient) depuis une route def. Ça ne
compile pas, mais on peut être tenté d'utiliser asyncio.run(...) à
l'intérieur — ce qui ouvre une deuxième boucle d'événement, en
concurrence avec celle de Uvicorn, et provoque des blocages difficiles à
diagnostiquer. Choisir un régime pour la route et s'y tenir.
Le choix def vs async def a un effet mesurable sur le débit ; le mesurer
avec Locust (module 10) sur les deux versions du service évite les
convictions théoriques. Sur une route qui appelle un modèle scikit-learn,
la version def est presque toujours la bonne réponse.
En résumé
async defne sert qu'aux routes qui attendent vraiment (I/O réseau, base de données via pilote async, appels HTTP).- Pour une route de scoring bloquée par le CPU, écrire
def— FastAPI la met dans un fil d'exécution et la boucle reste libre. BackgroundTasksconvient aux tâches courtes (mail, notification) ; pour un scoring de plusieurs minutes, passer à une file externe (Celery, RQ, Dramatiq).- Renvoyer
202 Acceptedavec unlot_idsur les traitements asynchrones ; laisser l'appelant venir chercher le résultat.
Le module 7 protège l'accès au service par un jeton et introduit la limitation de débit.