ThreadLocal: Idioma Correcto, Mal Época
Contenedores de Servlets Java EE, alrededor de 1999: un hilo por solicitud. Un hilo maneja exactamente una solicitud de principio a fin, luego se detiene. ThreadLocal almacena un valor claveado al hilo actual. Con un-hilo-por-solicitud, un valor almacenado en ThreadLocal pertenece a exactamente una solicitud. El idioma: correcto.
Los pools de hilos cambiaron el contrato. Un hilo maneja solicitud A, almacena principal A en ThreadLocal, finaliza solicitud A y regresa a la piscina. Los pools de hilos no reinician el estado del hilo. ThreadLocal.remove() limpia, pero llamarlo requiere disciplina explícita. Cuando la disciplina falla, solicitud B corre en el mismo hilo y lee principal A en ThreadLocal.
El 5-paso filtraje:
1. Llega solicitud A. El servidor asigna Thread-7.
2. Thread-7 establece ThreadLocal.set(principal_A) al inicio de la solicitud.
3. Solicitud A se completa. Thread-7 regresa a la piscina. No se llama ThreadLocal.remove().
4. Llega solicitud B. El servidor asigna Thread-7 (reutilización de la piscina).
5. Thread-7 lee ThreadLocal.get(): devuelve principal_A. Solicitud B corre bajo la identidad incorrecta.
¿Por qué los Pruebas Lo Pasan Por Alto?
Los tests unitarios corren aislados: sin pool de hilos, sin reutilización. Los tests de integración usan hilos frescos o reinician el estado entre tests. Los tests de carga se calientan con usuarios correctos y baja concurrencia. La deficiencia solo se manifiesta bajo la reutilización de pool de hilos con solicitudes superpuestas, una condición que aparece en producción bajo tráfico normal, no en ninguna configuración de prueba que lo cheque.
La Consecuencia de Seguridad
El principal de usuario A se filtra en la solicitud de usuario B. No es una caída. No es una excepción. Una violación silenciosa de la frontera de seguridad: usuario B realiza acciones como usuario A, lee los datos de usuario A o supera las permisos de usuario B. El sistema no produce ningún error. Los registros muestran que solicitud B fue autorizada. Todo parece correcto.
Los Cinco Pasos
Los cinco pasos de un filtraje ThreadLocal importan precisamente: el defecto no ocurre en el momento en que se ejecuta el código incorrecto. Ocurrió antes, en la ausencia de un paso de limpieza.
Valores adjuntos al alcance
ThreadLocal adjunta un valor a un hilo. Un hilo sobrevive a una solicitud. Colisión.
Los valores adjuntos al alcance adjunan un valor a una unidad de trabajo. Cuando la unidad de trabajo termina, el valor también termina. No se necesita limpieza explícita. No se necesita remove() para olvidar.
Java 21: ScopedValue
// ThreadLocal (portador de defectos)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // establecer en el inicio de la solicitud
// ... manejo de la solicitud ...
PRINCIPAL.remove(); // DEBE llamarse; a menudo se olvida
// ScopedValue (portador correcto)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... manejo de la solicitud ...
// el valor se va automáticamente cuando run() devuelve
});
Go: context.Context
// context.Context lleva valores explícitamente; alcance = cadena de llamada de funciones
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx se pasa explícitamente; se va cuando la función devuelve
Python asyncio: contextvars.ContextVar
# ContextVar a nivel de cada tarea async
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # establecer para solo esta tarea
# ... manejo de tareas ...
PRINCIPAL.reset(token) # o: el alcance finaliza con la tarea
La propiedad que comparten: el tiempo de vida coincide con la unidad de trabajo. Cuando finaliza la solicitud (retorna run(), retorna la función, se completa la tarea), el valor también finaliza. No hay limpieza que olvidar. No hay piscina que se corrompa.
Identificar y Reemplazar
Una aplicación Java EE almacena el ID de inquilino en un ThreadLocal al inicio de la solicitud. Bajo carga alta, el ID de inquilino A aparece en solicitudes de inquilino B. Las consultas de inquilino B devuelven datos de inquilino A. No se lanza ninguna excepción. El defecto solo aparece en el rendimiento de carga en producción.