ThreadLocal: Korrekter Spruch, Falsche Ära
Java EE Servlet-Container, um 1999: Ein Thread pro Anforderung. Ein Thread bearbeitet genau eine Anforderung von Anfang bis Ende und beendet sich dann. ThreadLocal speichert einen Wert, der an den aktuellen Thread angelehnt ist. Mit einem-Thread-pro-Anforderung gehört ein im ThreadLocal gespeicherter Wert genau einer Anforderung. Der Spruch: korrekt.
Threadpools änderten den Vertrag. Ein Thread bearbeitet Anforderung A, speichert den Principal A in ThreadLocal, beendet Anforderung A und kehrt in den Pool zurück. Threadpools resetten den Thread-Zustand nicht. ThreadLocal.remove() bereinigt, aber es erfordert explizite Disziplin. Wenn die Disziplin versäumt wird, läuft Anforderung B auf demselben Thread und liest Principal A in ThreadLocal.
Der 5-Schritt-Geleak:
1. Anforderung A tritt auf. Der Server gibt Thread-7 zu.
2. Thread-7 setzt ThreadLocal.set(principal_A) am Anforderungsbeginn.
3. Anforderung A ist abgeschlossen. Thread-7 kehrt in den Pool zurück. ThreadLocal.remove() wird nicht aufgerufen.
4. Anforderung B tritt auf. Der Server gibt Thread-7 (Pool-Nutzung) zu.
5. Thread-7 liest ThreadLocal.get(): gibt principal_A zurück. Anforderung B läuft unter der falschen Identität.
Warum Tests es verpassen
Einheitstests laufen isoliert: Kein Threadpool, keine Wiederverwendung. Integrationstests verwenden frische Threads oder resetten den Zustand zwischen Tests. Lasttests wärmen mit korrekten Benutzern und niedriger Konkurrenz auf. Die Fehlererscheinung manifestiert sich nur unter Threadpool-Nutzung mit überlappenden Anforderungen, eine Bedingung, die sich in der Produktion unter normalen Verkehr zeigt, nicht in einer Testkonfiguration, die danach sucht.
Die Sicherheitsfolge
Principal A von Benutzer A gelangt in die Anforderung von Benutzer B. Kein Crash. Keine Ausnahme. Eine stille Sicherheits-Grenzverletzung: Benutzer B führt Aktionen unter der Identität von A aus, liest Daten von A oder umgeht die Berechtigungen von B. Das System zeigt keine Fehler. Die Protokolle zeigen, dass Anforderung B autorisiert war. Alles sieht korrekt aus.
Die Fünf Schritte
Die fünf Schritte eines ThreadLocal-Geleaks sind genau: Der Fehler tritt nicht erst in dem Moment auf, in dem die falsche Code ausgeführt wird. Er tritt früher auf, im Fehlen einer Bereinigungsschritt.
Werte, die an den Scope gebunden sind
ThreadLocal bindet eine Werte an einen Thread. Ein Thread überlebt eine Anfrage. Missmatch.
Scope-bundene Werte binden eine Werte an eine Einheit von Arbeit. Wenn die Einheit von Arbeit endet, endet auch der Wert. Keine explizite Bereinigung. Kein remove() um den Wert zu vergessen.
Java 21: ScopedValue
// ThreadLocal (Defekt-Träger)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // Wert wird bei Anfrage-Start gesetzt
// ... Anfrage-Handling ...
PRINCIPAL.remove(); // Muss aufgerufen werden; oft vergessen
// ScopedValue (RICHTIGER Träger)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... Anfrage-Handling ...
// Wert wird automatisch entfernt, wenn run() zurückkehrt
});
Go: context.Context
// context.Context trägt Werte explizit; Scope = Funktionsaufruf-Kette
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx wird explizit übergeben; verschwindet, wenn Funktion zurückkehrt
Python asyncio: contextvars.ContextVar
# ContextVar ist für jede async-Aufgabe skaliert
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # Wert wird nur für diese Aufgabe gesetzt
# ... Aufgabe bearbeiten ...
PRINCIPAL.reset(token) # oder: Bereich endet mit Aufgabe
Die Eigenschaft, die diese teilen: Das Leben entspricht der Einheit der Arbeit. Wenn die Anfrage endet (run() kehrt zurück, die Funktion kehrt zurück, die Aufgabe abgeschlossen ist), endet auch der Wert. Keine Reinigung vergessen. Kein Pool verderben.
Identifizieren & Ersetzen
Eine Java EE-Anwendung speichert die Mieter-ID in einer ThreadLocal am Anfang der Anfrage. Bei hohem Lastfall erscheint die ID des Mieters A in Anfragen des Mieters B. Die Abfragen von Mieter B liefern Daten von Mieter A. Keine Exception wird geworfen. Das Defekt tritt nur bei Produktionslasttests auf.