ThreadLocal: Korrekte Idiomatik, falsche Ära
Java EE Servlet-Container, circa 1999: ein Thread pro Request. Ein Thread bearbeitet genau eine Anfrage von Anfang bis Ende und terminiert dann. ThreadLocal speichert einen Wert, der dem aktuellen Thread zugeordnet ist. Bei einem Thread pro Request gehört ein in ThreadLocal gespeicherter Wert genau einer Anfrage. Die Idiomatik: korrekt.
Thread-Pools haben den Vertrag geändert. Ein Thread bearbeitet Request A, speichert Principal A in ThreadLocal, beendet Request A und kehrt in den Pool zurück. Thread-Pools setzen den Thread-Zustand nicht zurück. ThreadLocal.remove() räumt auf, aber der Aufruf erfordert explizite Disziplin. Wenn die Disziplin versagt, läuft Request B auf demselben Thread und liest Principal A aus ThreadLocal.
Das 5-Schritte-Leck:
1. Request A trifft ein. Server weist Thread-7 zu.
2. Thread-7 ruft ThreadLocal.set(principal_A) zu Beginn der Anfrage auf.
3. Anfrage A wird abgeschlossen. Thread-7 kehrt in den Pool zurück. ThreadLocal.remove() wird nicht aufgerufen.
4. Anfrage B trifft ein. Der Server weist Thread-7 zu (Pool-Wiederverwendung).
5. Thread-7 liest ThreadLocal.get(): gibt principal_A zurück. Anfrage B läuft unter der falschen Identität.
Warum Tests dies nicht erkennen
Unit-Tests laufen isoliert: kein Thread-Pool, keine Wiederverwendung. Integrationstests verwenden frische Threads oder setzen den Zustand zwischen Tests zurück. Lasttests starten mit korrekten Benutzern und geringer Parallelität. Der Fehler tritt nur bei Thread-Pool-Wiederverwendung mit überlappenden Anfragen auf – eine Bedingung, die in der Produktion unter normalem Verkehr auftritt, nicht jedoch in Testkonfigurationen, die darauf prüfen.
Die Sicherheitskonsequenz
Der Principal von Benutzer A gelangt in die Anfrage von Benutzer B. Kein Absturz. Keine Ausnahme. Eine stille Verletzung der Sicherheitsgrenze: Benutzer B führt Aktionen als Benutzer A aus, liest Benutzer A’s Daten oder umgeht die Berechtigungen von Benutzer B. Das System erzeugt keinen Fehler. Die Logs zeigen, dass Anfrage B autorisiert wurde. Alles sieht korrekt aus.
Die fünf Schritte
Die fünf Schritte eines ThreadLocal-Leaks sind entscheidend: der Defekt tritt nicht in dem Moment auf, in dem der fehlerhafte Code ausgeführt wird. Er tritt früher auf, durch das Fehlen eines Cleanup-Schritts.
An den Scope gebundene Werte
ThreadLocal bindet einen Wert an einen Thread. Ein Thread überlebt eine Anfrage. Mismatch.
Scope-attached Values binden einen Wert an eine Arbeitseinheit. Wenn die Arbeitseinheit endet, endet auch der Wert. Kein explizites Cleanup. Kein remove() zum Vergessen.
Java 21: ScopedValue
// ThreadLocal (DEFECT carrier)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // set at request start
// ... request handling ...
PRINCIPAL.remove(); // MUSS aufgerufen werden; wird oft vergessen
// ScopedValue (KORREKTER Träger)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... Anfrageverarbeitung ...
// Wert wird automatisch entfernt, wenn run() zurückkehrt
});
Go: context.Context
// context.Context trägt Werte explizit; Geltungsbereich = Funktionsaufrufkette
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx wird explizit übergeben; verschwindet, wenn die Funktion zurückkehrt
Python asyncio: contextvars.ContextVar
# ContextVar ist an jede asynchrone Task gebunden
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # nur für diese Task setzen
# ... Task-Verarbeitung ...
PRINCIPAL.reset(token) # oder: Scope endet mit der Task
Die gemeinsame Eigenschaft: Die Lebensdauer entspricht der Arbeitseinheit. Wenn die Anfrage endet (run() kehrt zurück, die Funktion kehrt zurück, die Task ist abgeschlossen), endet auch der Wert. Nichts zu vergessen, kein Pool, der beschädigt werden kann.
Identifizieren & Ersetzen
Eine Java-EE-Anwendung speichert die Tenant-ID in einem ThreadLocal beim Start einer Anfrage. Unter hoher Last erscheint die Tenant-ID von A in Anfragen von Tenant B. Die Abfragen von Tenant B liefern Daten von Tenant A. Es wird keine Exception geworfen. Der Fehler tritt nur bei Produktionslasttests auf.