ThreadLocal: Idioma Corretto, Epoca Sbagliata
Container Servlet Java EE, circa 1999: un thread per richiesta. Un thread gestisce esattamente una richiesta dall’inizio alla fine, poi termina. ThreadLocal memorizza un valore associato al thread corrente. Con un thread per richiesta, un valore memorizzato in ThreadLocal appartiene esattamente a una sola richiesta. L’idioma: corretto.
I thread pool hanno cambiato il contratto. Un thread gestisce la richiesta A, memorizza il principal A in ThreadLocal, termina la richiesta A e torna al pool. I thread pool non azzerano lo stato del thread. ThreadLocal.remove() pulisce, ma chiamarlo richiede disciplina esplicita. Quando la disciplina viene a mancare, la richiesta B viene eseguita sullo stesso thread e legge il principal A da ThreadLocal.
La perdita in 5 passi:
1. La richiesta A arriva. Il server assegna Thread-7.
2. Thread-7 chiama ThreadLocal.set(principal_A) all'inizio della richiesta.
3. La richiesta A termina. Thread-7 torna al pool. ThreadLocal.remove() non viene chiamato.
4. La richiesta B arriva. Il server assegna Thread-7 (riutilizzo del pool).
5. Thread-7 legge ThreadLocal.get(): restituisce principal_A. La richiesta B viene eseguita con l'identità errata.
Perché i test non lo rilevano
I test unitari vengono eseguiti in isolamento: nessun pool di thread, nessun riutilizzo. I test di integrazione usano thread freschi o reimpostano lo stato tra un test e l'altro. I test di carico si scaldano con utenti corretti e bassa concorrenza. Il difetto si manifesta solo con il riutilizzo del pool di thread e richieste sovrapposte, una condizione che si verifica in produzione sotto traffico normale, non in alcuna configurazione di test che lo verifichi.
La conseguenza sulla sicurezza
Il principal dell'utente A si propaga nella richiesta dell'utente B. Non è un crash. Non è un'eccezione. Una violazione silenziosa del confine di sicurezza: l'utente B esegue azioni come utente A, legge i dati dell'utente A o aggira i permessi dell'utente B. Il sistema non genera errori. I log mostrano che la richiesta B è stata autorizzata. Tutto sembra corretto.
The Five Steps
I cinque passaggi di una perdita ThreadLocal contano in modo preciso: il difetto non si verifica nel momento in cui viene eseguito il codice errato. Si verifica prima, in assenza di una fase di pulizia.
Valori associati allo scope
ThreadLocal associa un valore a un thread. Un thread sopravvive a una richiesta. Mismatch.
I valori associati allo scope collegano un valore a un’unità di lavoro. Quando l’unità di lavoro termina, il valore termina con essa. Nessuna pulizia esplicita. Nessun remove() da dimenticare.
Java 21: ScopedValue
// ThreadLocal (DEFECT carrier)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // impostato all’avvio della richiesta
// ... gestione della richiesta ...
PRINCIPAL.remove(); // DEVE essere chiamato; spesso dimenticato
// ScopedValue (CORRETTO carrier)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... gestione della richiesta ...
// il valore viene rimosso automaticamente quando run() termina
});
Go: context.Context
// context.Context trasporta valori in modo esplicito; scope = catena di chiamate di funzione
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx passato esplicitamente; scompare al termine della funzione
Python asyncio: contextvars.ContextVar
# ContextVar associata a ogni task async
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # impostata solo per questo task
# ... gestione del task ...
PRINCIPAL.reset(token) # oppure: lo scope termina con il task
La proprietà che condividono: il lifetime coincide con l’unità di lavoro. Quando la richiesta termina (run() ritorna, la funzione ritorna, il task si completa), il valore termina. Nessuna pulizia da dimenticare. Nessun pool da corrompere.
Identifica e sostituisci
Un'applicazione Java EE memorizza l'ID del tenant in un ThreadLocal all'inizio della richiesta. Sotto carico elevato, l'ID del tenant A appare nelle richieste del tenant B. Le query del tenant B restituiscono i dati del tenant A. Non viene lanciata alcuna eccezione. Il difetto si manifesta solo nei test di carico in produzione.