Voor Python 3.7 vertrouwden ontwikkelaars uitsluitend op threading.local() om verzoekspecifieke gegevens zoals gebruikerssessies of databaseverbindingen op te slaan. De proliferatie van asyncio onthulde echter een fundamentele tekortkoming: thread-lokale opslag wordt gedeeld door alle coroutines die op dezelfde event loop-thread draaien. Wanneer één asynchrone taak de controle opgeefde, kon een andere per ongeluk toegang krijgen tot of de vermeende geïsoleerde staat van de eerste taak muteren, wat leidde tot beveiligingskwetsbaarheden en datacorruptie. PEP 567 introduceerde contextvars om logische uitvoeringscontextisolatie te bieden, onafhankelijk van OS-threads, waarbij het concept is gemodelleerd naar soortgelijke mechanismen in C# en Erlang.
In synchrone Python draait elk HTTP-verzoek doorgaans op zijn eigen thread, waardoor threading.local() voldoende is voor het opslaan van de verzoekcontext. In asynchrone architecturen kunnen duizenden gelijktijdige verzoeken op één thread worden gemultiplexed, beheerd door een event loop. Als twee asynchrone taken elkaar doorsnijden — de ene pauzeert bij een await terwijl de andere hervat — delen ze dezelfde thread-lokale dictionary. Zonder een mechanisme om de context vast te leggen en te herstellen bij taakwisselingen, lekt globale staat tussen logisch afzonderlijke operaties. Dit creëert racecondities waarbij de authenticatietoken van Taak A zichtbaar wordt voor Taak B, of waar de grenzen van database-transacties vervagen tussen niet-verwante verzoeken.
Python implementeert ContextVar als een sleutel in een onveranderlijke kaart die wordt opgeslagen in de threadstatus. Elke asynchrone taak behoudt een referentie naar zijn eigen Context-object — een persistente datastructuur waarbij aanpassingen nieuwe versies creëren in plaats van de gedeelde staat te muteren. Wanneer asyncio een taak pauzeert bij een await, legt het de huidige context vast; wanneer het hervat, herstelt het die context, zodat ContextVar.get() de waarde retourneert die aan die specifieke taak is gebonden, zelfs als OS-threads zijn verschoven. Deze copy-on-write semantiek garandeert isolatie zonder vergrendelingsoverhead.
import contextvars import asyncio request_id = contextvars.ContextVar('request_id', default='unknown') async def process_task(task_name): # Stel waarde in voor deze specifieke taakcontext token = request_id.set(task_name) try: await asyncio.sleep(0.01) # Geef controle, andere taken kunnen draaien current = request_id.get() print(f"Taak {task_name} leest: {current}") finally: request_id.reset(token) # Herstel de vorige context async def main(): # Voer twee taken gelijktijdig uit op dezelfde thread await asyncio.gather(process_task('Alpha'), process_task('Beta')) asyncio.run(main())
Een team dat een high-throughput API-gateway bouwde, migreerde van een threaded Flask-toepassing naar een asynchrone FastAPI-service. Ze ontdekten dat hun authenticatiemiddleware, die de huidige gebruiker in threading.local() opsloeg, willekeurig de identiteit van Gebruiker A toekende aan de verzoeken van Gebruiker B onder belasting. De eerste foutopsporing suggereerde racecondities, maar de logs toonden aan dat de toewijzingen zelfs op single-worker implementaties plaatsvonden. De hoofdoorzaak was de coöperatieve multitasking van asyncio, waarbij één verzoekhandler yield tijdens een database-aanroep, waardoor een andere handler op dezelfde thread kon draaien en de thread-lokale opslag kon erven.
Het team probeerde aanvankelijk een globale dictionary te keyen op threading.get_ident(), in de veronderstelling dat dit verzoeken zou isoleren. Deze aanpak bood een eenvoudige migratie van de oude codebasis zonder externe afhankelijkheden in te voeren. Echter, onder uvicorn met asyncio wordt dezelfde thread sequentieel gebruikt voor meerdere verzoeken, wat betekent dat de dictionary niet-bruikbare gegevens van vorige verzoeken behield en privilege-escalatiefouten veroorzaakte waarbij geauthenticeerde sessies onjuist tussen niet-verwante verzoeken bleven bestaan.
Ze herstructureerden elke functiehandtekening om een context dictionaryparameter te accepteren, waarmee deze door de hele call stack van middleware naar de databasestructuur werd doorgegeven. Deze expliciete gegevensstroom elimineerde verborgen staat en werkte zowel over synchrone als asynchrone grenzen. Helaas vereiste dit een enorme herstructurering die duizenden functies raakte en de integraties van derde partijen brak die verwachtten dat er globale configuratieobjecten zouden zijn, terwijl de resulterende code-verbose de onderhoudslast en het risico op ontwikkelaarsfouten aanzienlijk verhoogde.
Het team nam contextvars.ContextVar aan om het geauthenticeerde gebruikersobject op te slaan, waardoor de middleware de variabele bij het binnenkomen van het verzoek kon instellen, terwijl downstreamfuncties deze via .get() konden openen zonder de functiehandtekeningen te vervuilen. Deze aanpak vereiste geen architectonische ingreep en bood automatische isolatie tussen gelijktijdige taken, hoewel het zorgvuldige beheersing van reset()-tokens vereiste om geheugenlekken in langlopende processen te voorkomen. Bovendien werd debuggen uitdagender omdat de staat impliciet is in de uitvoeringscontext in plaats van zichtbaar in stacktraces.
Uiteindelijk kozen ze contextvars omdat prototyping aantoonde dat het alleen wijzigingen vereiste in de middleware-laag, waardoor de enorme herstructurering die gepaard ging met expliciete contextoverdracht werd vermeden. Door verzoekhandlers in try/finally-blokken te wikkelen om ervoor te zorgen dat tokens werden gereset, voorkwamen ze geheugenlekken terwijl ze schone functiehandtekeningen behielden. De gateway verwerkt nu 50.000 gelijktijdige verbindingen per worker zonder cross-verzoek gegevenslekken, en het team verminderde hun aantal OS-threads van 100 per instantie naar 4, waardoor het geheugengebruik met 80% werd verlaagd en de algehele doorvoer met 300% werd verbeterd.
Waarom faalt threading.local() in asynchrone code maar werkt het in threaded code?
In threaded Python plant het besturingssysteem threads preventief, en elke maint zijn eigen C stack en PyThreadState-structuur. threading.local() mapte variabelen naar deze OS-niveau threadidentiteit, wat isolatie garandeert. In asyncio plant de event loop coöperatief taken op een enkele thread met behulp van een wachtrij; wanneer een taak yield, voert de loop onmiddellijk een andere taak op dezelfde thread uit zonder de PyThreadState te wisselen. Dienovereenkomstig ziet threading.local() dezelfde sleutel voor beide taken, wat veroorzaakt dat staat lekt. Contextvars lost dit op door een stapel context-mappingen binnen de PyThreadState te onderhouden die de event loop verwisselt tijdens taakwisselingen en zo logische isolatie creëert onafhankelijk van OS-threads.
Wat gebeurt er als je vergeet een ContextVar-token te resetten?
ContextVar.set() retourneert een Token-object dat de vorige staat vertegenwoordigt, wat moet worden doorgegeven aan reset() om de vorige waarde te herstellen. Als je dit vergeet — bijvoorbeeld door een try/finally-blok weg te laten — behoudt de variabele zijn waarde buiten de bedoelde reikwijdte. In langlopende asynchrone servers creëert dit een geheugenlek waarbij oude verzoekcontexten zich ophopen in de contextketen, en latere taken op die thread kunnen verouderde waarden erven als de context niet correct wordt hersteld. In tegenstelling tot traditionele stack-variabelen die verdwijnen wanneer functies terugkeren, blijven contextvariabelen bestaan in de uitvoeringscontext totdat ze expliciet worden gereset of totdat de taak eindigt, waardoor opruiming noodzakelijk is.
Hoe worden contextvariabelen doorgegeven aan kindtaken en threads?
Bij gebruik van asyncio.create_task() ontvangt de kindtaak automatisch een kopie van de huidige context van de ouder, waardoor contextvariabelen op natuurlijke wijze door de asynchrone aanroepgrafiek stromen. Bij gebruik van concurrent.futures.ThreadPoolExecutor of loop.run_in_executor(), wordt de callable echter uitgevoerd in een andere OS-thread die standaard begint met een lege context. Kandidaten veronderstellen vaak dat context over threadgrenzen heen wordt doorgegeven zoals thread-lokale opslag dat doet, maar contextvars zijn specifiek voor de logische asynchrone context. Om waarden naar threads door te geven, moet je de context expliciet vastleggen met contextvars.copy_context() en de functie binnenin uitvoeren via context.run(), of variabelen handmatig doorgeven als argumenten.