English· Español· Deutsch· Nederlands· Français· 日本語· ქართული· 繁體中文· 简体中文· Português· Русский· العربية· हिन्दी· Italiano· 한국어· Polski· Svenska· Türkçe· Українська· Tiếng Việt· Bahasa Indonesia

un

gość
1 / ?
powrót do lekcji

ThreadLocal: poprawny idiom, niewłaściwa epoka

Kontenery servletów Java EE, około 1999: jeden wątek na żądanie. Wątek obsługuje dokładnie jedno żądanie od początku do końca, a następnie kończy działanie. ThreadLocal przechowuje wartość powiązaną z bieżącym wątkiem. Przy modelu jeden-wątek-na-żądanie wartość zapisana w ThreadLocal należy dokładnie do jednego żądania. Idiom: poprawny.

Pule wątków zmieniły kontrakt. Wątek obsługuje żądanie A, zapisuje principal A w ThreadLocal, kończy żądanie A i wraca do puli. Pule wątków nie resetują stanu wątku. ThreadLocal.remove() czyści wartość, ale wymaga to jawnej dyscypliny. Gdy dyscyplina zawodzi, żądanie B uruchamia się na tym samym wątku i odczytuje principal A z ThreadLocal.

5-etapowy wyciek:

1. Nadchodzi żądanie A. Serwer przydziela Thread-7.

2. Wątek-7 wywołuje ThreadLocal.set(principal_A) na początku żądania.

3. Żądanie A kończy się. Wątek-7 wraca do puli. ThreadLocal.remove() nie zostało wywołane.

4. Nadchodzi żądanie B. Serwer przydziela Wątek-7 (ponowne użycie z puli).

5. Wątek-7 odczytuje ThreadLocal.get(): zwraca principal_A. Żądanie B działa pod niewłaściwą tożsamością.

Dlaczego testy tego nie wychwytują

Testy jednostkowe działają w izolacji: brak puli wątków, brak ponownego użycia. Testy integracyjne używają świeżych wątków lub resetują stan między testami. Testy obciążeniowe rozgrzewają się z poprawnymi użytkownikami i niską współbieżnością. Błąd ujawnia się tylko przy ponownym użyciu wątków z puli przy nakładających się żądaniach – warunek ten występuje w produkcji przy normalnym ruchu, a nie w żadnej konfiguracji testowej, która by go sprawdzała.

Konsekwencje bezpieczeństwa

Principal użytkownika A przenika do żądania użytkownika B. Nie jest to crash ani wyjątek. Ciche naruszenie granicy bezpieczeństwa: użytkownik B wykonuje akcje jako użytkownik A, odczytuje dane użytkownika A lub omija uprawnienia użytkownika B. System nie generuje błędu. Logi pokazują, że żądanie B zostało autoryzowane. Wszystko wygląda poprawnie.

Pięć kroków

Pięć kroków wycieku ThreadLocal ma znaczenie: defekt nie występuje w momencie uruchomienia błędnego kodu. Występuje wcześniej, w braku kroku czyszczenia.

Przejdź przez 5 kroków. W którym kroku występuje defekt i dlaczego zestaw testów mógłby go przeoczyć?

Wartości powiązane z zakresem

ThreadLocal przypina wartość do wątku. Wątek żyje dłużej niż żądanie. Niezgodność.

Wartości przypięte do zakresu (scope-attached) przypinają wartość do jednostki pracy. Gdy jednostka pracy się kończy, wartość kończy się razem z nią. Brak jawnego czyszczenia. Brak remove() do zapomnienia.

Java 21: ScopedValue

// ThreadLocal (nośnik DEFECT)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal);           // ustawienie na początku żądania
// ... obsługa żądania ...
PRINCIPAL.remove();                 // MUSI zostać wywołane; często zapominane

// ScopedValue (POPRAWNY nośnik)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... obsługa żądania ...
// wartość automatycznie znika po powrocie z run()
});

Go: context.Context

// context.Context przenosi wartości jawnie; zakres = łańcuch wywołań funkcji
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx)  // ctx przekazywany jawnie; znika po zakończeniu funkcji

Python asyncio: contextvars.ContextVar

# ContextVar ograniczony do każdego zadania asynchronicznego
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal)    # ustaw tylko dla tego zadania
# ... obsługa zadania ...
PRINCIPAL.reset(token)              # lub: zakres kończy się wraz z zadaniem

Wspólna cecha tych rozwiązań: czas życia jest zgodny z jednostką pracy. Gdy żądanie się kończy (run() zwraca wynik, funkcja zwraca wynik, zadanie się kończy), wartość również przestaje istnieć. Nie trzeba pamiętać o czyszczeniu. Nie ma puli, którą można uszkodzić.

Identify & Replace

Aplikacja Java EE przechowuje ID najemcy w ThreadLocal na początku żądania. Przy dużym obciążeniu ID najemcy A pojawia się w żądaniach najemcy B. Zapytania najemcy B zwracają dane najemcy A. Nie jest zgłaszany żaden wyjątek. Defekt ujawnia się tylko podczas testów obciążeniowych w środowisku produkcyjnym.

Jakie MOAD opisuje ten przypadek? Jaki nośnik umożliwił powstanie defektu? Co go zastępuje i jaka właściwość zamiennika zapobiega wyciekowi?