ThreadLocal: Idioma Correto, Era Errada
Java EE Servlet containers, cerca de 1999: um thread por solicitação. Um thread trata exatamente uma solicitação de começar ao fim, depois termina. ThreadLocal armazena um valor associado ao thread atual. Com um-thread-per-request, um valor armazenado em ThreadLocal pertence a exatamente uma solicitação. O idioma: correto.
Pool de threads mudou o contrato. Um thread trata solicitação A, armazena principal A em ThreadLocal, termina solicitação A e retorna para o pool. Pool de threads não reinicia o estado do thread. ThreadLocal.remove() limpa, mas chamá-lo requer disciplina explícita. Quando a disciplina falha, solicitação B roda no mesmo thread e lê principal A em ThreadLocal.
A 5-passo vazamento:
1. Solicitação A chega. O servidor atribui Thread-7.
2. Thread-7 define ThreadLocal.set(principal_A) no início da solicitação.
3. Solicitação A completa. Thread-7 retorna para o pool. ThreadLocal.remove() não é chamado.
4. Solicitação B chega. O servidor atribui Thread-7 (reuso do pool).
5. Thread-7 lê ThreadLocal.get(): retorna principal_A. Solicitação B roda com a identidade errada.
Por Que Os Testes O Perdem
Testes unitários correm em isolamento: nenhum pool de threads, nenhum reuso. Testes de integração usam threads frescas ou reinicia o estado entre os testes. Testes de carga aquecem com usuários corretos e baixa concorrência. O defeito só se manifesta com o reuso de pool de threads e solicitações sobrepôs, uma condição que aparece em produção sob tráfego normal, não em qualquer configuração de teste que verifica isso.
A Consequência de Segurança
O principal do usuário A se derrama na solicitação do usuário B. Não é um crash. Não é uma exceção. Uma violação silenciosa da fronteira de segurança: o usuário B executa ações como usuário A, lê dados do usuário A ou bypassa as permissões do usuário B. O sistema produz nenhum erro. Os logs mostram que a solicitação B foi autorizada. Tudo parece correto.
Os Cinco Passos
Os cinco passos de um vazamento ThreadLocal importam precisamente: o defeito não ocorre no momento que o código errado roda. Ele ocorre mais cedo, na ausência de um passo de limpeza.
Valores Associados ao Escopo
ThreadLocal associa um valor a uma thread. Uma thread vive além de uma solicitação. Colisão.
Valores associados ao escopo associam um valor a uma unidade de trabalho. Quando a unidade de trabalho termina, o valor também termina. Nenhuma limpeza explícita. Nenhuma remove() para esquecer.
Java 21: ScopedValue
// ThreadLocal (carro de defeito)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // definido no início da solicitação
// ... manipulação da solicitação ...
PRINCIPAL.remove(); // DEVE ser chamado; frequentemente esquecido
// ScopedValue (carro correto)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... manipulação da solicitação ...
// o valor é automaticamente perdido quando a run() retorna
});
Go: context.Context
// context.Context carrega valores explicitamente; escopo = cadeia de chamada de funções
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx passado explicitamente; perdido quando a função retorna
Python asyncio: contextvars.ContextVar
# ContextVar escopo para cada tarefa assíncrona
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # definido somente para essa tarefa
# ... manipulação de tarefas ...
PRINCIPAL.reset(token) # ou: o escopo termina com a tarefa
A propriedade que eles compartilham: o tempo de vida corresponde à unidade de trabalho. Quando a solicitação termina (a run() retorna, a função retorna, a tarefa é concluída), o valor também termina. Nenhuma limpeza para esquecer. Nenhuma piscina para corromper.
Identificar e Substituir
Uma aplicação Java EE armazena o ID do inquilino em um ThreadLocal no início da solicitação. Sob alto volume de carga, o ID do inquilino A aparece em solicitações do inquilino B. As consultas do inquilino B retornam dados do inquilino A. Nenhuma exceção é lançada. O defeito só aparece no teste de carga em produção.