Przed Pythonem 3.7, programiści polegali wyłącznie na threading.local() do przechowywania danych specyficznych dla żądań, takich jak sesje użytkowników czy połączenia z bazą danych. Jednakże, rozkwit asyncio ujawnił zasadniczą wadę: pamięć lokalna wątku jest współdzielona przez wszystkie korutyny działające na tym samym wątku pętli zdarzeń. Gdy jedno zadanie asynchroniczne oddaje kontrolę, inne może przypadkowo uzyskać dostęp do lub zmienić stan pierwszego zadania, co prowadzi do luk bezpieczeństwa i zniekształcenia danych. PEP 567 wprowadził contextvars, aby zapewnić izolację logicznych kontekstów wykonania niezależnych od wątków systemowych, modelując koncepcję na podstawie podobnych mechanizmów w C# i Erlang.
W synchronicznym Pythonie, każde żądanie HTTP zazwyczaj działa na własnym wątku, co sprawia, że threading.local() jest wystarczające do przechowywania kontekstu żądania. W architekturach asynchronicznych tysiące równoległych żądań mogą być multiplikowane na jednym wątku zarządzanym przez pętlę zdarzeń. Jeśli dwa zadania asynchroniczne przeplatają wykonanie — jedno wstrzymując się na await, podczas gdy drugie wznawia działanie — dzielą ten sam słownik lokalny wątku. Bez mechanizmu do migawek i przywracania kontekstu podczas przełączeń zadań, globalny stan przecieka pomiędzy logicznie oddzielnymi operacjami. To tworzy warunki wyścigu, w których token uwierzytelnienia zadania A staje się widoczny dla zadania B, lub granice transakcji bazy danych zaciemniają się między niezwiązanymi żądaniami.
Python implementuje ContextVar jako klucz do niezmiennej mapy przechowywanej w stanie wątku. Każde zadanie asynchroniczne utrzymuje odniesienie do swojego własnego obiektu Context — trwałej struktury danych, w której modyfikacje tworzą nowe wersje zamiast modyfikować współdzielony stan. Gdy asyncio wstrzymuje zadanie na await, przechwytuje bieżący kontekst; przy wznowieniu, przywraca ten kontekst, zapewniając, że ContextVar.get() zwraca wartość związaną z tym specyficznym zadaniem, mimo że wątki OS mogły się przesunąć. Taka semantyka kopiowania przy zapisie zapewnia izolację bez narzutu blokady.
import contextvars import asyncio request_id = contextvars.ContextVar('request_id', default='unknown') async def process_task(task_name): # Ustaw wartość dla tego konkretnego kontekstu zadania token = request_id.set(task_name) try: await asyncio.sleep(0.01) # Oddaj kontrolę, inne zadania mogą działać current = request_id.get() print(f"Zadanie {task_name} odczytuje: {current}") finally: request_id.reset(token) # Przywróć poprzedni kontekst async def main(): # Uruchom dwa zadania równolegle w tym samym wątku await asyncio.gather(process_task('Alpha'), process_task('Beta')) asyncio.run(main())
Zespół budujący bramkę API o dużej przepustowości przeniósł się z aplikacji Flask działającej w wątkach do asynchronicznej usługi FastAPI. Odkryli, że ich oprogramowanie pośredniczące do uwierzytelniania, które przechowywało bieżącego użytkownika w threading.local(), losowo przypisywało tożsamość Użytkownika A do żądań Użytkownika B pod obciążeniem. Początkowe debugowanie sugerowało warunki wyścigu, ale logi wskazały, że przypisania miały miejsce nawet w wdrożeniach z jednym pracownikiem. Przyczyną był współdzielony multitasking asyncio, gdzie jeden handler żądania oddaje kontrolę podczas wywołania bazy danych, umożliwiając innemu handlerowi działanie na tym samym wątku i dziedziczenie pamięci lokalnej wątku.
Zespół początkowo próbował kluczyć globalny słownik za pomocą threading.get_ident(), zakładając, że to odseparuje żądania. Podejście to oferowało prostą migrację z starej bazy kodu bez wprowadzania zewnętrznych zależności. Jednak w ramach uvicorn z asyncio, ten sam wątek obsługuje wiele żądań sekwencyjnie, co oznacza, że słownik zachowywał przestarzałe dane z wcześniejszych żądań i powodował błędy eskalacji uprawnień, w których uwierzytelnione sesje utrzymywały się niepoprawnie między niezwiązanymi żądaniami.
Zrefaktorowali każdą sygnaturę funkcji, aby akceptowała parametr słownika context, przemycając go przez całe drzewo wywołań od oprogramowania pośredniczącego do warstwy bazy danych. Ten jawny przepływ danych wyeliminował ukryty stan i działał w całym zakresie synchronicznym i asynchronicznym. Niestety, wymagało to masywnego refaktoryzowania, które dotknęło tysiące funkcji i przerwało integracje bibliotek stron trzecich oczekujących globalnych obiektów konfiguracyjnych, podczas gdy wynikowa złożoność kodu znacznie zwiększyła obciążenie konserwacyjne i ryzyko błędów programistycznych.
Zespół przyjął contextvars.ContextVar, aby przechowywać obiekt uwierzytelnionego użytkownika, pozwalając oprogramowaniu pośredniczącemu ustawić zmienną przy wejściu żądania, podczas gdy funkcje dalsze uzyskiwały do niej dostęp za pomocą .get() bez zanieczyszczania sygnatur funkcji. To podejście nie wymagało przekształcania architektury i zapewniało automatyczną izolację między równoległymi zadaniami, chociaż wymagało starannego zarządzania tokenami reset(), aby zapobiec wyciekom pamięci w długoterminowych procesach. Dodatkowo, debugowanie stało się bardziej wyzwaniem, ponieważ stan jest ukryty w kontekście wykonania, a nie widoczny w stosach wywołań.
Ostatecznie wybrali contextvars, ponieważ prototypowanie wykazało, że wymagało to zmian tylko w warstwie oprogramowania pośredniczącego, unikając masywnego refaktoryzowania związanego z eksplicytwnym przekazywaniem kontekstu. Poprzez owijanie handlerów żądań w bloki try/finally, aby zapewnić resetowanie tokenów, zapobiegli wyciekom pamięci, jednocześnie utrzymując czyste sygnatury funkcji. Bramką teraz przetwarza 50 000 równoległych połączeń na robota bez wycieków danych między żądaniami, a zespół zmniejszył liczbę wątków OS z 100 na instancję do 4, zmniejszając zużycie pamięci o 80% i poprawiając ogólną przepustowość o 300%.
Dlaczego threading.local() zawodzi w kodzie asynchronicznym, ale działa w kodzie wielowątkowym?
W wielowątkowym Pythonie, system operacyjny preemptively planuje wątki, a każdy z nich utrzymuje swoją własną stos C i strukturę PyThreadState. threading.local() mapuje zmienne na tożsamość wątku na poziomie systemu operacyjnego, zapewniając izolację. W asyncio, pętla zdarzeń współdzieli plany zadań na jednym wątku używając kolejki; gdy zadanie oddaje kontrolę, pętla natychmiast uruchamia inne zadanie na tym samym wątku bez przełączania PyThreadState. W rezultacie threading.local() widzi ten sam klucz dla obu zadań, co powoduje wyciek stanu. Contextvars rozwiązuje to, utrzymując stos mapowania kontekstów wewnątrz PyThreadState, które pętla zdarzeń wymienia podczas przełączania zadań, tworząc logiczną izolację niezależną od wątków systemowych.
Co się stanie, jeśli zapomnisz zresetować token ContextVar?
ContextVar.set() zwraca obiekt Token, reprezentujący poprzedni stan, który musi zostać przekazany do reset(), aby przywrócić poprzednią wartość. Jeśli zaniedbasz to — na przykład, pomijając blok try/finally — zmienna zachowa swoją wartość poza zamierzonym zakresem. W długoterminowych serwerach asynchronicznych prowadzi to do wycieku pamięci, w którym stare konteksty żądań gromadzą się w łańcuchu kontekstu, a kolejne zadania na tym wątku mogą dziedziczyć przestarzałe wartości, jeśli kontekst nie zostanie prawidłowo przywrócony. W przeciwieństwie do tradycyjnych zmiennych stosowych, które znikają po zwróceniu funkcji, zmienne kontekstowe utrzymują się w kontekście wykonania, dopóki nie zostaną jawnie zresetowane lub dopóki zadanie nie zostanie zakończone, co czyni sprzątanie obowiązkowym.
Jak propagują się zmienne kontekstowe do zadania podrzędnego i wątków?
Podczas używania asyncio.create_task(), zadanie podrzędne automatycznie otrzymuje kopię bieżącego kontekstu rodzica, zapewniając, że zmienne kontekstowe płynnie przechodzą w dół po grafie wywołań asynchronicznych. Jednak gdy używasz concurrent.futures.ThreadPoolExecutor lub loop.run_in_executor(), wywoływana funkcja wykonuje się w innym wątku systemu operacyjnego, który domyślnie zaczyna się z pustym kontekstem. Kandydaci często zakładają, że kontekst propaguje się przez granice wątku, tak jak pamięć lokalna wątku, ale contextvars są specyficzne dla logicznego kontekstu asynchronicznego. Aby propagować wartości do wątków, musisz jawnie przechwycić kontekst przy użyciu contextvars.copy_context() i uruchomić funkcję w nim za pomocą context.run(), lub ręcznie przekazać zmienne jako argumenty.