backend / python
contextvars : propager du contexte en concurrence
Explication
Ce que vous allez apprendre
- Comprendre pourquoi une variable globale classique est dangereuse dans du code concurrent
- Utiliser
ContextVarpour isoler une valeur par tâche, même si elles s'exécutent de façon entrelacée - Distinguer
ContextVar(isolation par contexte logique) dethreading.local(isolation par thread) - Appliquer le pattern
set()/reset()dans unfinallypour toujours nettoyer le contexte - Utiliser
copy_context()pour exécuter du code dans une copie isolée du contexte courant
Dans quel contexte ?
Un développeur ajoute un identifiant de requête à chaque log émis par une API FastAPI, pour pouvoir retracer toutes les étapes d'une requête précise dans les logs de production. Utiliser une variable globale classique id_requete_courante = None semble fonctionner en test local, mais mélange les identifiants de requêtes différentes dès que le serveur traite plusieurs requêtes en concurrence — exactement le problème que ContextVar a été conçu pour résoudre proprement.
Le problème : une variable globale devient dangereuse en concurrence
Cette leçon prolonge directement la précédente sur le logging, où ContextVar a été utilisé sans être expliqué en détail. Imaginez que vous vouliez savoir "quel utilisateur est en train d'être traité" à n'importe quel endroit du code, sans passer ce paramètre à chaque fonction. Une variable globale classique semble pratique, mais devient un piège dès que plusieurs traitements s'exécutent de façon entrelacée (en asyncio) ou en parallèle (en threads) : ils se marchent dessus, chacun écrasant la valeur des autres.
| Mécanisme | Isole par | Adapté à |
|---|---|---|
| Variable globale | Rien (partagée par tout) | Jamais en environnement concurrent |
threading.local() | Thread physique | Multi-threading classique |
ContextVar | Contexte d'exécution logique | asyncio et threading |
Piège classique
Oublier reset() dans un finally laisse fuir la valeur d'un contexte vers le traitement suivant. Sur un pool de tâches réutilisées, ça peut faire apparaître l'identifiant d'un ancien utilisateur dans les logs d'une requête totalement différente.
ContextVar : une valeur isolée par "contexte logique"
ContextVar résout ce problème en donnant à chaque tâche d'exécution sa propre valeur isolée, même si elles tournent "en même temps". Quand une coroutine asyncio en crée une autre (via create_task ou gather), la nouvelle tâche reçoit une copie du contexte au moment de sa création, indépendante des modifications faites ailleurs.
Pourquoi pas threading.local ?
threading.local() isole par thread physique — logique pour du code multi-thread classique, mais inadapté à asyncio, où tout tourne sur un seul thread avec de nombreuses coroutines entrelacées : threading.local ne verrait aucune différence entre elles. ContextVar isole par contexte d'exécution logique, ce qui fonctionne correctement dans les deux modèles.
Le pattern set/reset
set() retourne un jeton qui permet de restaurer la valeur précédente avec reset() — toujours dans un finally, pour garantir le nettoyage même si une exception survient pendant le traitement. C'est le même principe de "toujours nettoyer, même en cas d'erreur" que vous avez déjà vu avec les context managers (with).
Commandes & code
contextvars : propager du contexte en concurrence
L'alternative moderne aux variables globales pour un état "par tâche".
import asyncio
import threading
from contextvars import ContextVar, copy_context
# --- Le probleme que contextvars resout ---
# Une variable globale classique est PARTAGEE entre toutes les taches concurrentes :
# dangereuse en asyncio (une seule execution mais entrelacee) et en threading (course critique).
utilisateur_courant_global = None # anti-pattern en environnement concurrent
# --- ContextVar : une variable dont la valeur est isolee PAR CONTEXTE D'EXECUTION ---
utilisateur_courant: ContextVar[str] = ContextVar("utilisateur_courant", default="anonyme")
async def traiter_requete(nom_utilisateur: str):
jeton = utilisateur_courant.set(nom_utilisateur) # set() retourne un token pour restaurer plus tard
try:
await appeler_service_metier()
finally:
utilisateur_courant.reset(jeton) # toujours nettoyer explicitement
async def appeler_service_metier():
# lit la valeur DEFINIE PAR LA TACHE APPELANTE, meme sans parametre explicite
print(f"Traitement pour : {utilisateur_courant.get()}")
await asyncio.sleep(0.1)
async def demo_isolation():
# chaque Task cree par create_task obtient une COPIE du contexte au moment de sa creation
await asyncio.gather(
traiter_requete("alice"),
traiter_requete("bob"),
)
# Alice et Bob restent isoles l'un de l'autre, meme executes de facon entrelacee
asyncio.run(demo_isolation())
# --- Difference cruciale avec threading.local ---
# threading.local() isole par THREAD -- inadapte a asyncio (un seul thread, plusieurs coroutines).
# ContextVar isole par CONTEXTE LOGIQUE, propage correctement a travers await et create_task.
local_thread = threading.local()
def demo_thread_local():
def worker(nom):
local_thread.nom = nom
import time
time.sleep(0.05)
print(f"Thread local : {local_thread.nom}") # jamais melange entre threads, MAIS
# ne fonctionnerait PAS pour isoler des coroutines dans le meme thread asyncio
threads = [threading.Thread(target=worker, args=(n,)) for n in ["t1", "t2"]]
for t in threads:
t.start()
for t in threads:
t.join()
demo_thread_local()
# --- copy_context() : executer une fonction dans une copie isolee du contexte courant ---
compteur_appels: ContextVar[int] = ContextVar("compteur_appels", default=0)
def incrementer_et_afficher():
compteur_appels.set(compteur_appels.get() + 1)
print(compteur_appels.get())
ctx = copy_context()
ctx.run(incrementer_et_afficher) # s'execute dans la copie : n'affecte PAS le contexte appelant
ctx.run(incrementer_et_afficher) # la copie garde son propre etat entre plusieurs run()
print(compteur_appels.get()) # 0 dans le contexte principal : totalement isole
# --- Cas d'usage reel : contexte de requete HTTP dans un framework ASGI ---
requete_id: ContextVar[str] = ContextVar("requete_id")
utilisateur_id: ContextVar[int | None] = ContextVar("utilisateur_id", default=None)
class MiddlewareContexte:
"""Injecte le contexte de requete AVANT d'appeler l'application, le nettoie apres."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
import uuid
jeton_requete = requete_id.set(str(uuid.uuid4()))
try:
await self.app(scope, receive, send)
finally:
requete_id.reset(jeton_requete)
async def handler_metier():
# accessible depuis N'IMPORTE QUELLE fonction appelee pendant cette requete,
# sans passer requete_id en parametre a travers toute la chaine d'appels
print(f"Traitement de la requete {requete_id.get()}")
# --- Piege : ThreadPoolExecutor NE PROPAGE PAS automatiquement le contexte ---
async def demo_piege_threadpool():
utilisateur_courant.set("alice")
def tache_bloquante():
# DANS un thread pool, le contexte n'est PAS automatiquement copie par defaut
return utilisateur_courant.get() # retourne "anonyme" (la valeur par defaut), pas "alice" !
resultat = await asyncio.to_thread(tache_bloquante)
print(resultat) # "anonyme" -- piege classique
# Solution : capturer le contexte explicitement et l'utiliser dans le thread
ctx_capture = copy_context()
resultat_correct = await asyncio.get_event_loop().run_in_executor(
None, lambda: ctx_capture.run(tache_bloquante)
)
print(resultat_correct) # "alice" -- contexte propage manuellement
asyncio.run(demo_piege_threadpool())Résumé
ContextVarisole une valeur par contexte d'exécution logique : propagée à traversawait, isolée entreTask.- Contrairement à
threading.local,ContextVarfonctionne correctement avecasyncio(un seul thread, plusieurs coroutines). set()retourne un token à passer àreset()pour restaurer proprement l'état précédent, y compris en cas d'exception.- Le contexte n'est PAS propagé automatiquement dans un
ThreadPoolExecutor: il faut le capturer aveccopy_context().
Exercices pratiques
Mission : dénouer un mélange d'identifiants utilisateur en production
Objectif : Remplacer une variable globale dangereuse par une ContextVar, et corriger le piège de non-propagation du contexte dans un ThreadPoolExecutor.
Contexte
Une API FastAPI utilise utilisateur_courant_global = None, une simple variable globale, pour savoir quel utilisateur est traité à n'importe quel endroit du code, sans le passer en paramètre partout. En test local avec une seule requête à la fois, tout fonctionne. En production, sous charge, les logs affichent parfois l'identifiant d'un autre utilisateur que celui réellement traité par la requête en cours.
Tu dois expliquer précisément pourquoi la variable globale casse sous charge, la remplacer par une ContextVar, puis résoudre un piège classique lié à ThreadPoolExecutor.