ThreadLocal: Idioma Correto, Era Errada
Contêineres Servlet Java EE, por volta de 1999: uma thread por requisição. Uma thread lida com exatamente uma requisição do início ao fim, depois termina. ThreadLocal armazena um valor associado à thread atual. Com uma thread por requisição, um valor armazenado em ThreadLocal pertence a exatamente uma requisição. O idioma: correto.
Pools de threads mudaram o contrato. Uma thread lida com a requisição A, armazena o principal A em ThreadLocal, finaliza a requisição A e retorna ao pool. Pools de threads não resetam o estado da thread. ThreadLocal.remove() limpa, mas chamá-lo exige disciplina explícita. Quando a disciplina falha, a requisição B é executada na mesma thread e lê o principal A em ThreadLocal.
O vazamento em 5 passos:
1. Requisição A chega. Servidor atribui Thread-7.
2. Thread-7 executa ThreadLocal.set(principal_A) no início da requisição.
3. Requisição A é concluída. Thread-7 retorna ao pool. ThreadLocal.remove() não é chamado.
4. Requisição B chega. O servidor atribui Thread-7 (reutilização do pool).
5. Thread-7 lê ThreadLocal.get(): retorna principal_A. A requisição B é executada com a identidade incorreta.
Por que os testes não detectam o problema
Testes unitários são executados de forma isolada: sem pool de threads, sem reutilização. Testes de integração usam threads novas ou resetam o estado entre testes. Testes de carga aquecem com usuários corretos e baixa concorrência. O defeito só se manifesta quando há reutilização de threads do pool com requisições sobrepostas, condição que ocorre em produção sob tráfego normal, e não em nenhuma configuração de teste que o verifique.
A consequência de segurança
O principal do usuário A vaza para a requisição do usuário B. Não é um crash. Não é uma exceção. É uma violação silenciosa do limite de segurança: o usuário B executa ações como o usuário A, lê dados do usuário A ou contorna as permissões do usuário B. O sistema não produz erro. Os logs mostram que a requisição B foi autorizada. Tudo parece correto.
Os Cinco Passos
Os cinco passos de um vazamento de ThreadLocal importam precisamente: o defeito não ocorre no momento em que o código errado é executado. Ele ocorre antes, na ausência de uma etapa de limpeza.
Valores Anexados ao Escopo
ThreadLocal anexa um valor a uma thread. Uma thread sobrevive a uma requisição. Incompatibilidade.
Valores anexados a escopo vinculam um valor a uma unidade de trabalho. Quando a unidade de trabalho termina, o valor termina junto. Sem limpeza explícita. Sem remove() para esquecer.
Java 21: ScopedValue
// ThreadLocal (DEFECT carrier)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // definido no início da requisição
// ... processamento da requisição ...
PRINCIPAL.remove(); // DEVE ser chamado; frequentemente esquecido
// ScopedValue (transportador CORRETO)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... tratamento da requisição ...
// valor é removido automaticamente quando run() retorna
});
Go: context.Context
// context.Context transporta valores explicitamente; escopo = cadeia de chamadas de função
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx passado explicitamente; desaparece quando a função retorna
Python asyncio: contextvars.ContextVar
# ContextVar com escopo para cada tarefa assíncrona
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # define apenas para esta tarefa
# ... processamento da tarefa ...
PRINCIPAL.reset(token) # ou: o escopo termina com a tarefa
A propriedade que eles compartilham: o tempo de vida coincide com a unidade de trabalho. Quando a requisição termina (o run() retorna, a função retorna, a tarefa é concluída), o valor termina. Sem limpeza para esquecer. Sem pool para corromper.
Identificar & Substituir
Uma aplicação Java EE armazena o ID do tenant em um ThreadLocal no início da requisição. Sob alta carga, o ID do tenant A aparece em requisições do tenant B. As consultas do tenant B retornam dados do tenant A. Nenhuma exceção é lançada. O defeito só aparece em testes de carga em produção.