Thread Safety em .NET: exemplos práticos para não "quebrar" sob concorrência
Thread safety no .NET em exemplos curtos: Interlocked para contadores, lock, volatile, coleções concorrentes e Monitor entre produtor e consumidor.

Neste artigo · 9 seções
Introdução
Aplicações modernas executam muitas coisas ao mesmo tempo, explorando múltiplos núcleos. Isso aumenta a performance, mas também expõe o código a condições de corrida, visibilidade de memória e corrupção de dados. “Thread safety” é a capacidade do seu código de permanecer correto quando múltiplas threads acessam o mesmo recurso — e, no ecossistema .NET, dominar esse tema é essencial para garantir integridade e previsibilidade sob carga real.
Quando tudo parece certo, mas não é
Um erro clássico ocorre com operações “aparentemente atômicas”, como x++. Na prática, esse incremento é quebrado em ler–somar–escrever; sob concorrência, duas threads podem ler o mesmo valor, somar e sobrescrever resultados, “perdendo” incrementos. Esse é o retrato da race condition e explica por que bugs de concorrência costumam ser intermitentes e difíceis de reproduzir: às vezes o código passa em desenvolvimento, mas falha em produção, onde a contenção é maior.
// NÃO seguro sob concorrência
int counter = 0;
Parallel.For(0, 1_000_000, _ => counter++);
// Seguro e rápido para números
int atomic = 0;
Parallel.For(0, 1_000_000, _ => Interlocked.Increment(ref atomic));
Em cenários de contadores, Interlocked resolve com alta performance, desde que a necessidade seja básica (incrementos/trocas atômicas de tipos primitivos). Para sequências de operações relacionadas, será preciso outra estratégia.
Sua primeira linha de defesa: lock
O lock implementa exclusão mútua de forma direta: uma thread entra na seção crítica e as demais aguardam. A boa prática é usar um objeto privado dedicado à sincronização, nunca this, tipos públicos ou strings; isso evita contenção acidental e deadlocks difíceis de diagnosticar.
private readonly object _gate = new();
private int _balance;
public void Deposit(int amount)
{
lock (_gate)
{
_balance += amount;
}
}
Visibilidade sem atomicidade: volatile
Algumas variáveis servem só para sinalização (por exemplo, mandar uma thread parar). Para esses casos, volatile garante que atualizações fiquem imediatamente visíveis a outras threads. Importante: volatile não torna operações compostas atômicas; para incrementos/leituras-modificações-escritas, use Interlocked ou lock.
private volatile bool _stop;
public void Run()
{
while (!_stop) { /* trabalho ... */ }
}
public void Stop() => _stop = true;
Coleções: por que List<T> “quebra” e o que usar no lugar
List<T>, Dictionary<TKey,TValue> e Queue<T> não são thread-safe para escrita concorrente; tentar mutá-las de múltiplas threads pode gerar corrupção de dados e exceções como “Collection was modified during enumeration”. O namespace System.Collections.Concurrent oferece coleções desenhadas para carga concorrente com sincronização interna, como ConcurrentDictionary, ConcurrentQueue, ConcurrentStack e ConcurrentBag.
var hits = new ConcurrentDictionary<string, int>();
int value = hits.AddOrUpdate("user:42", _ => 1, (_, curr) => curr + 1);
Sinalização entre threads sem “busy waiting”
Quando é preciso avisar que “chegou trabalho novo”, use o padrão produtor–consumidor. Monitor.Pulse/Monitor.Wait permite notificar uma thread parada de forma eficiente, sem desperdiçar CPU. Em termos práticos: quem produz itens entra no lock, enfileira e dá Pulse; quem consome, ao perceber fila vazia, faz Wait e dorme até alguém sinalizar.
private readonly object _lock = new();
private readonly Queue<string> _q = new();
void Produce(string item)
{
lock (_lock) { _q.Enqueue(item); Monitor.Pulse(_lock); }
}
string Consume()
{
lock (_lock)
{
while (_q.Count == 0) Monitor.Wait(_lock);
return _q.Dequeue();
}
}
Como testar concorrência de forma realista
Bugs de concorrência são não-determinísticos. Testes eficazes criam contenção intencional: várias Tasks executando milhares de operações sobre o mesmo objeto, coletando exceções e verificando invariantes ao fim. Esse “harness de estresse” aumenta a chance de expor condições de corrida que não aparecem em execuções únicas.
Conclusão
Escrever código thread-safe é combinar simplicidade e disciplina. O trio lock/Interlocked/coleções concorrentes cobre a maioria dos casos, enquanto volatile resolve sinalização simples e Monitor ajuda a coordenar produtores e consumidores sem desperdiçar CPU. Projetar com concorrência em mente, desde o início, evita “caças a fantasmas” em produção e entrega performance com correção.
Insights & Takeaways
-
x++ não é atômico; use Interlocked para contadores simples.
-
lock com objeto privado dedicado evita contenção acidental e deadlocks.
-
volatile garante visibilidade de flags, mas não resolve operações compostas.
-
Prefira System.Collections.Concurrent para mutações compartilhadas.
-
Coordene produtor–consumidor com Monitor.Wait/Monitor.Pulse para evitar “busy waiting”.


