ThreadLocal: Idioma correcto, era equivocada
Contenedores Servlet de Java EE, alrededor de 1999: un hilo por solicitud. Un hilo maneja exactamente una solicitud desde el inicio hasta el final, luego termina. ThreadLocal almacena un valor asociado al hilo actual. Con un hilo por solicitud, un valor almacenado en ThreadLocal pertenece exactamente a una solicitud. El idioma: correcto.
Los pools de hilos cambiaron el contrato. Un hilo maneja la solicitud A, almacena el principal A en ThreadLocal, finaliza la solicitud A y vuelve al pool. Los pools de hilos no restablecen el estado del hilo. ThreadLocal.remove() limpia, pero llamarlo requiere disciplina explícita. Cuando falla la disciplina, la solicitud B se ejecuta en el mismo hilo y lee el principal A en ThreadLocal.
La fuga de 5 pasos:
1. Llega la solicitud A. El servidor asigna Thread-7.
2. Thread-7 ejecuta ThreadLocal.set(principal_A) al inicio de la solicitud.
3. La solicitud A finaliza. Thread-7 regresa al pool. No se llama a ThreadLocal.remove().
4. Llega la solicitud B. El servidor asigna Thread-7 (reutilización del pool).
5. Thread-7 lee ThreadLocal.get(): devuelve principal_A. La solicitud B se ejecuta con la identidad incorrecta.
Por qué las pruebas no lo detectan
Las pruebas unitarias se ejecutan de forma aislada: sin pool de hilos, sin reutilización. Las pruebas de integración usan hilos nuevos o restablecen el estado entre pruebas. Las pruebas de carga se calientan con usuarios correctos y baja concurrencia. El defecto solo se manifiesta con reutilización del pool de hilos y solicitudes superpuestas, una condición que aparece en producción bajo tráfico normal, no en ninguna configuración de pruebas que lo verifique.
La consecuencia de seguridad
El principal del usuario A se filtra en la solicitud del usuario B. No es un fallo. No es una excepción. Es una violación silenciosa del límite de seguridad: el usuario B realiza acciones como el usuario A, lee datos del usuario A o elude los permisos del usuario B. El sistema no produce ningún error. Los registros muestran que la solicitud B fue autorizada. Todo parece correcto.
Los Cinco Pasos
Los cinco pasos de una fuga de ThreadLocal importan precisamente: el defecto no ocurre en el momento en que se ejecuta el código incorrecto. Ocurre antes, en la ausencia de un paso de limpieza.
Valores Asociados al Ámbito
ThreadLocal asocia un valor a un hilo. Un hilo sobrevive a una petición. Incompatibilidad.
Los valores asociados a un ámbito (scope-attached) vinculan un valor a una unidad de trabajo. Cuando la unidad de trabajo finaliza, el valor desaparece con ella. Sin limpieza explícita. Sin remove() que olvidar.
Java 21: ScopedValue
// ThreadLocal (DEFECT carrier)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // se establece al inicio de la petición
// ... manejo de la petición ...
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 desaparece automáticamente cuando run() retorna
});
Go: context.Context
// context.Context transporta valores explícitamente; scope = cadena de llamadas de función
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx se pasa explícitamente; desaparece al retornar la función
Python asyncio: contextvars.ContextVar
# ContextVar con alcance por cada tarea asíncrona
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # se establece solo para esta tarea
# ... manejo de la tarea ...
PRINCIPAL.reset(token) # o: el alcance termina con la tarea
La propiedad que comparten: el tiempo de vida coincide con la unidad de trabajo. Cuando la solicitud termina (run() retorna, la función retorna, la tarea se completa), el valor termina. Sin limpieza que olvidar. Sin pool que corromper.
Identificar y reemplazar
Una aplicación Java EE almacena el ID del tenant en un ThreadLocal al inicio de la solicitud. Bajo alta carga, el ID del tenant A aparece en solicitudes del tenant B. Las consultas del tenant B devuelven datos del tenant A. No se lanza ninguna excepción. El defecto solo aparece en pruebas de carga en producción.