Avant Python 3.7, les développeurs s'appuyaient exclusivement sur threading.local() pour stocker des données spécifiques à la requête telles que les sessions utilisateur ou les connexions à la base de données. Cependant, la prolifération de asyncio a révélé un défaut fondamental : le stockage local au thread est partagé par toutes les coroutines s'exécutant sur le même thread de boucle d'événements. Lorsque l'une des tâches asynchrones cédait le contrôle, une autre pouvait accéder ou modifier par inadvertance l'état supposément isolé de la première tâche, entraînant des vulnérabilités de sécurité et des corruptions de données. PEP 567 a introduit contextvars pour fournir une isolation du contexte d'exécution logique indépendante des threads OS, en modélisant le concept sur des mécanismes similaires en C# et Erlang.
Dans Python synchrone, chaque requête HTTP s'exécute généralement sur son propre thread, rendant threading.local() suffisant pour stocker le contexte de la requête. Dans les architectures asynchrones, des milliers de requêtes concurrentes peuvent être multiplexées sur un seul thread géré par une boucle d'événements. Si deux tâches asynchrones s'entrelacent dans leur exécution — l'une faisant une pause à un await pendant que l'autre reprend — elles partagent le même dictionnaire local au thread. Sans un mécanisme pour prendre un instantané et restaurer le contexte lors des commutateurs de tâches, l'état global fuit entre des opérations logiquement séparées. Cela crée des conditions de concurrence où le jeton d'authentification de la Tâche A devient visible pour la Tâche B, ou les frontières de transactions de base de données s'estompent entre des requêtes non liées.
Python implémente ContextVar comme une clé dans une carte immuable stockée dans l'état du thread. Chaque tâche asynchrone maintient une référence à son propre objet Context — une structure de données persistante où les modifications créent de nouvelles versions plutôt que de muter l'état partagé. Lorsque asyncio suspend une tâche à un await, il capture le contexte actuel ; lors de la reprise, il restaure ce contexte, garantissant que ContextVar.get() renvoie la valeur liée à cette tâche spécifique même si les threads OS ont pu changer. Cette sémantique de copie à l'écriture garantit l'isolement sans surcharge de verrouillage.
import contextvars import asyncio request_id = contextvars.ContextVar('request_id', default='unknown') async def process_task(task_name): # Définir la valeur pour ce contexte de tâche spécifique token = request_id.set(task_name) try: await asyncio.sleep(0.01) # Céder le contrôle, d'autres tâches peuvent s'exécuter current = request_id.get() print(f"Tâche {task_name} lit : {current}") finally: request_id.reset(token) # Restaurer le contexte précédent async def main(): # Exécuter deux tâches en parallèle sur le même thread await asyncio.gather(process_task('Alpha'), process_task('Beta')) asyncio.run(main())
Une équipe construisant une passerelle API à fort débit a migré d'une application Flask basée sur des threads à un service FastAPI asynchrone. Ils ont découvert que leur middleware d'authentification, qui stockait l'utilisateur actuel dans threading.local(), attribuait aléatoirement l'identité de l'utilisateur A aux requêtes de l'utilisateur B sous charge. Le débogage initial a suggéré des conditions de concurrence, mais les journaux ont montré que les attributions se produisaient même sur des déploiements à travailleur unique. La cause profonde était le multitâche coopératif de asyncio, où un gestionnaire de requête cède pendant un appel à la base de données, permettant à un autre gestionnaire de s'exécuter sur le même thread et d'hériter du stockage local au thread.
L'équipe a d'abord essayé de clé un dictionnaire global par threading.get_ident(), supposant que cela isolerait les requêtes. Cette approche offrait une migration simple depuis l'ancienne base de code sans introduire de dépendances externes. Cependant, sous uvicorn avec asyncio, le même thread gère plusieurs requêtes séquentiellement, ce qui signifie que le dictionnaire conservait des données obsolètes des requêtes précédentes et causait des bugs d'escalade de privilèges où des sessions authentifiées persistaient incorrectement entre des requêtes non liées.
Ils ont refactorisé chaque signature de fonction pour accepter un paramètre de dictionnaire context, le passant à travers toute la pile d'appels depuis le middleware jusqu'à la couche de base de données. Ce flux de données explicite a éliminé l'état caché et fonctionnait à travers les frontières synchrones et asynchrones. Malheureusement, cela nécessitait un refactoring massif qui touchait des milliers de fonctions et brisait les intégrations des bibliothèques tierces s'attendant à des objets de configuration globaux, tandis que la verbosité du code résultant augmentait considérablement la charge de maintenance et le risque d'erreurs des développeurs.
L'équipe a adopté contextvars.ContextVar pour stocker l'objet utilisateur authentifié, permettant au middleware de définir la variable à l'entrée de la requête tandis que les fonctions en aval y accédaient via .get() sans polluer les signatures de fonction. Cette approche ne nécessitait aucune refonte architecturale et offrait une isolation automatique entre les tâches concurrentes, bien qu'elle nécessitât une gestion soignée des jetons reset() pour éviter les fuites mémoire dans les processus de longue durée. De plus, le débogage est devenu plus difficile car l'état est implicite dans le contexte d'exécution plutôt que visible dans les traces de pile.
Ils ont finalement sélectionné contextvars parce que le prototypage a démontré qu'il ne nécessitait des changements que dans la couche middleware, évitant le refactoring massif associé au passage explicite de contexte. En enveloppant les gestionnaires de requêtes dans des blocs try/finally pour s'assurer que les jetons étaient réinitialisés, ils ont évité les fuites mémoire tout en maintenant des signatures de fonction propres. La passerelle traite désormais 50 000 connexions concurrentes par travailleur sans fuite de données entre les requêtes, et l'équipe a réduit son nombre de threads OS de 100 par instance à 4, réduisant l'utilisation de la mémoire de 80 % et améliorant le débit global de 300 %.
Pourquoi threading.local() échoue-t-il dans le code async mais fonctionne dans le code threadé ?
Dans Python threadé, le système d'exploitation planifie préventivement les threads, et chacun maintient sa propre pile C et sa structure PyThreadState. threading.local() mappe les variables à cette identité de thread de niveau OS, garantissant l'isolement. Dans asyncio, la boucle d'événements planifie coopérativement les tâches sur un seul thread en utilisant une file d'attente ; lorsqu'une tâche cède, la boucle exécute immédiatement une autre tâche sur le même thread sans changer PyThreadState. Par conséquent, threading.local() voit la même clé pour les deux tâches, causant une fuite d'état. Contextvars résout ce problème en maintenant une pile de mappages de contexte dentro de PyThreadState que la boucle d'événements échange pendant les commutateurs de tâches, créant une isolation logique indépendante des threads OS.
Que se passe-t-il si vous oubliez de réinitialiser un jeton ContextVar ?
ContextVar.set() renvoie un objet Token représentant l'état précédent, qui doit être passé à reset() pour restaurer la valeur antérieure. Si vous négligez cela — par exemple, en omettant un bloc try/finally — la variable conserve sa valeur au-delà de la portée intended. Dans des serveurs asynchrones de longue durée, cela crée une fuite de mémoire où d'anciens contextes de requête s'accumulent dans la chaîne de contexte, et les tâches ultérieures sur ce thread peuvent hériter de valeurs obsolètes si le contexte n'est pas correctement restauré. Contrairement aux variables de pile traditionnelles qui disparaissent lorsque les fonctions retournent, les variables de contexte persistent dans le contexte d'exécution jusqu'à ce qu'elles soient explicitement réinitialisées ou jusqu'à ce que la tâche se termine, rendant le nettoyage obligatoire.
Comment les variables de contexte se propagent-elles aux tâches et aux threads enfants ?
Lors de l'utilisation de asyncio.create_task(), la tâche enfant reçoit automatiquement une copie du contexte actuel du parent, garantissant que les variables de contexte se propagent naturellement dans le graphique d'appels async. Cependant, lors de l'utilisation de concurrent.futures.ThreadPoolExecutor ou loop.run_in_executor(), le callable s'exécute dans un thread OS différent qui commence avec un contexte vide par défaut. Les candidats supposent souvent que le contexte se propage à travers les frontières de threads comme le fait le stockage local au thread, mais contextvars sont spécifiques au contexte async logique. Pour propager des valeurs aux threads, vous devez explicitement capturer le contexte en utilisant contextvars.copy_context() et exécuter la fonction à l'intérieur en via context.run(), ou passer manuellement les variables en tant qu'arguments.