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

un

Gast
1 / ?

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.

Gehen Sie die 5 Schritte durch. In welchem Schritt tritt der Defekt auf, und warum würde eine Testsuite ihn übersehen?

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.

Welches MOAD beschreibt dies? Welcher Carrier hat den Fehler ermöglicht? Was ersetzt ihn, und welche Eigenschaft der Ersatzlösung verhindert das Leak?