English· Español· Deutsch· Nederlands· Français· 日本語· ქართული· 繁體中文· 简体中文· Português· Русский· العربية· हिन्दी· Italiano· 한국어· Polski· Svenska· Türkçe· Українська· Tiếng Việt· Bahasa Indonesia

un

visitante
1 / ?

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.

Percorra os 5 passos. Em qual passo o defeito ocorre e por que uma suíte de testes poderia não detectá-lo?

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.

Que MOAD isso descreve? Qual carrier tornou o defeito possível? O que o substitui e qual propriedade da substituição impede o vazamento?