Retour au cours

backend / python

contextvars : propager du contexte en concurrence

Leçon 341 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi une variable globale classique est dangereuse dans du code concurrent
  • Utiliser ContextVar pour isoler une valeur par tâche, même si elles s'exécutent de façon entrelacée
  • Distinguer ContextVar (isolation par contexte logique) de threading.local (isolation par thread)
  • Appliquer le pattern set()/reset() dans un finally pour 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écanismeIsole parAdapté à
Variable globaleRien (partagée par tout)Jamais en environnement concurrent
threading.local()Thread physiqueMulti-threading classique
ContextVarContexte d'exécution logiqueasyncio 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".

python
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é

  • ContextVar isole une valeur par contexte d'exécution logique : propagée à travers await, isolée entre Task.
  • Contrairement à threading.local, ContextVar fonctionne correctement avec asyncio (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 avec copy_context().

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →