AAAlison AmorimBlog
alisonamorim.com ↗

Performance

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.

  • 2 min de leitura
  • Performance
Fonte do texto
Cadeado azul e um monitor com código diante de um sol synthwave, com o título Thread Safety em letras cromadas
Neste artigo · 9 seções
  1. Introdução
  2. Quando tudo parece certo, mas não é
  3. Sua primeira linha de defesa: lock
  4. Visibilidade sem atomicidade: volatile
  5. Coleções: por que List<T> “quebra” e o que usar no lugar
  6. Sinalização entre threads sem “busy waiting”
  7. Como testar concorrência de forma realista
  8. Conclusão
  9. Insights & Takeaways

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”.

Comente no LinkedIn

A conversa sobre este artigo acontece no LinkedIn, onde ele também foi publicado. Discordou de algo, viu de outro jeito em produção? Conta lá.

Leia também