Módulo 1 · C# — Capítulo 16

Multithreading e Concorrência

Node.js roda em uma única thread e resolve concorrência com um event loop. C# tem outra opção: threads de verdade, rodando ao mesmo tempo, competindo pelos mesmos dados — e o preço dessa opção é aprender a proteger esses dados.

1. Por que isso é diferente de tudo que você já viu no Node

O capítulo 10 mostrou async/await — e se você vem do Node, aquilo pareceu familiar: uma forma de não bloquear enquanto espera I/O (disco, rede, banco de dados). Mas há uma diferença importante que o parecido esconde. No Node, mesmo com async, só existe uma thread executando seu código JavaScript; a concorrência é toda gerenciada pelo event loop, então duas linhas do seu código nunca rodam no mesmo instante. Em C#, async/await também não cria threads por si só — mas o .NET permite, além disso, rodar código de verdade em paralelo, em múltiplos núcleos do processador, ao mesmo tempo. Isso resolve outro problema (uso de CPU, não de I/O) e introduz um risco que não existe no mundo single-threaded do Node: duas threads lendo e escrevendo a mesma variável ao mesmo tempo.

Dois problemas diferentes, duas soluções diferentes

I/O-bound (esperar o banco, uma API, um arquivo): resolvido com async/await (capítulo 10) — nenhuma thread fica bloqueada esperando, mas também não há paralelismo real, é só troca de contexto eficiente. CPU-bound (calcular algo pesado): resolvido com multithreading de verdade — este capítulo. Usar o assunto errado para o problema errado é a causa mais comum de código concorrente que não entrega o ganho de performance esperado.

2. Thread — a unidade mais baixa

Thread é a classe mais básica do .NET para rodar código em paralelo. Na prática, você raramente cria uma Thread diretamente — mas entender essa camada explica tudo que vem depois.

C# criando uma thread manualmente
var thread = new Thread(() =>
{
    Console.WriteLine($"Rodando na thread {Environment.CurrentManagedThreadId}");
});

thread.Start();
thread.Join(); // espera a thread terminar antes de continuar

Console.WriteLine($"De volta na thread {Environment.CurrentManagedThreadId}");
O custo de uma Thread

Cada Thread criada diretamente reserva memória própria (a stack, por padrão 1 MB) e envolve o sistema operacional para ser agendada — é um recurso relativamente caro. Criar uma thread nova para cada pequena tarefa não escala. É exatamente esse problema que o ThreadPool resolve, na próxima seção.

3. Task.Run e o ThreadPool

Na prática do dia a dia, você não usa Thread diretamente — usa Task.Run, que pega uma thread já pronta e reutilizável de um pool mantido pelo próprio .NET (o ThreadPool), em vez de criar uma do zero.

C# Task.Run — trabalho de CPU em paralelo
static int SomaPesada(int[] numeros)
{
    // cálculo intensivo de CPU — não é I/O, então async/await sozinho não ajuda aqui
    return numeros.Select(n => n * n).Sum();
}

var tarefa1 = Task.Run(() => SomaPesada(lote1));
var tarefa2 = Task.Run(() => SomaPesada(lote2));

int[] resultados = await Task.WhenAll(tarefa1, tarefa2);
int total = resultados.Sum();

Repare que Task é a mesma classe do capítulo 10 — a diferença é a intenção. await httpClient.GetAsync(url) devolve uma Task que representa uma espera de I/O; Task.Run(...) devolve uma Task que representa trabalho de CPU rodando de fato em outra thread do pool. Mesma API, propósitos diferentes por baixo.

4. O problema que motiva tudo o resto: condição de corrida

Quando duas threads leem e escrevem a mesma variável sem coordenação, o resultado depende da ordem exata (imprevisível) em que o sistema operacional intercala as duas execuções. Isso é uma race condition, e é o motivo pelo qual multithreading precisa de disciplina extra que single-thread nunca exigiu de você.

C# contador quebrado — não faça isso
int contador = 0;

var tarefas = Enumerable.Range(0, 1000)
    .Select(_ => Task.Run(() => contador++)) // contador++ NÃO é atômico
    .ToArray();

await Task.WhenAll(tarefas);

Console.WriteLine(contador); // esperado: 1000 — na prática, quase sempre um número menor

contador++ parece uma operação só, mas na verdade são três passos (ler o valor, somar 1, escrever de volta). Se duas threads leem o mesmo valor antes de qualquer uma escrever o resultado, um dos incrementos se perde. Esse tipo de bug é traiçoeiro porque o código parece correto e às vezes até funciona — falha de forma intermitente, difícil de reproduzir, geralmente só em produção sob carga.

contador = 0 Thread A lê contador (0) escreve 0 + 1 Thread B lê contador (0) escreve 0 + 1 mesmo instante contador final = 1

Fig. 1 — Duas threads leem "0" antes de qualquer uma escrever: um dos dois incrementos desaparece.

5. lock / Monitor — protegendo a seção crítica

A solução mais comum é lock: garante que só uma thread por vez execute o trecho de código protegido (a "seção crítica"). Por baixo, lock é açúcar sintático para Monitor.Enter/Monitor.Exit dentro de um try/finally.

C# o mesmo contador, agora protegido
int contador = 0;
readonly object _cadeado = new();

var tarefas = Enumerable.Range(0, 1000)
    .Select(_ => Task.Run(() =>
    {
        lock (_cadeado)
        {
            contador++; // só uma thread por vez entra aqui
        }
    }))
    .ToArray();

await Task.WhenAll(tarefas);

Console.WriteLine(contador); // sempre 1000
Dica

Sempre trave em um objeto privado, dedicado só para isso (private readonly object _cadeado = new();), nunca em this ou em um tipo público — qualquer código externo que também travar nesse mesmo objeto pode causar deadlocks difíceis de rastrear. E mantenha a seção crítica pequena: quanto mais tempo uma thread segura o lock, mais as outras esperam, anulando o ganho de rodar em paralelo.

6. Interlocked — atomicidade sem lock

Para operações simples (incrementar, somar, trocar um valor), Interlocked resolve o mesmo problema sem o custo de um lock, usando instruções atômicas do próprio processador:

C# Interlocked.Increment
int contador = 0;

var tarefas = Enumerable.Range(0, 1000)
    .Select(_ => Task.Run(() => Interlocked.Increment(ref contador)))
    .ToArray();

await Task.WhenAll(tarefas);
Console.WriteLine(contador); // sempre 1000, sem lock nenhum
FerramentaQuando usar
lockProteger um bloco de código com várias operações relacionadas (ex: ler e depois escrever em mais de uma variável).
InterlockedUma única operação simples (incrementar, somar, comparar-e-trocar) — mais rápido que lock por não bloquear a thread.
Coleções concorrentesQuando o que precisa de proteção é uma coleção inteira (dicionário, fila) — próxima seção.

7. Coleções thread-safe: o namespace System.Collections.Concurrent

Dictionary<TKey, TValue> e List<T> (capítulo 7) não são seguros para acesso concorrente — usá-los de várias threads ao mesmo tempo corrompe a estrutura interna, não só o valor. O .NET oferece versões equivalentes já protegidas:

C# ConcurrentDictionary em ação
var cacheDePrecos = new ConcurrentDictionary<string, decimal>();

Parallel.ForEach(produtos, produto =>
{
    decimal preco = ConsultarPrecoNaApi(produto.Codigo); // simulação
    cacheDePrecos[produto.Codigo] = preco; // seguro, mesmo com várias threads escrevendo
});

// AddOrUpdate: incrementa um contador de acessos de forma atômica
var acessos = new ConcurrentDictionary<string, int>();
acessos.AddOrUpdate("produto-42", 1, (chave, valorAtual) => valorAtual + 1);

Também existem ConcurrentQueue<T>, ConcurrentBag<T> e ConcurrentStack<T> — a regra prática é: se uma coleção vai ser acessada por mais de uma thread, use a versão Concurrent*, nunca a versão comum protegida na mão com lock espalhado pelo código.

8. Parallel.For / Parallel.ForEach — paralelismo de dados

Quando você tem uma coleção grande e quer aplicar a mesma operação (CPU-bound) em cada item, sem depender da ordem, Parallel.ForEach divide o trabalho entre várias threads do pool automaticamente:

C# Parallel.ForEach
var relatorios = new ConcurrentBag<RelatorioMensal>();

Parallel.ForEach(clientes, cliente =>
{
    var relatorio = GerarRelatorioPesado(cliente); // CPU-bound
    relatorios.Add(relatorio);
});

Console.WriteLine($"{relatorios.Count} relatórios gerados");
Paralelo não é sempre mais rápido

Dividir trabalho entre threads tem custo (coordenação, troca de contexto). Para coleções pequenas ou operações rápidas, um foreach sequencial comum é mais rápido que Parallel.ForEach — o ganho só aparece quando o trabalho por item é pesado o suficiente para compensar esse overhead. Meça antes de assumir.

9. Deadlock — quando duas threads travam uma na outra

Um deadlock acontece quando a Thread A espera um recurso que a Thread B segura, e a Thread B espera um recurso que a Thread A segura — as duas ficam paradas para sempre.

C# deadlock clássico — ordem de locks trocada
object cadeadoA = new();
object cadeadoB = new();

// Thread 1
lock (cadeadoA)
{
    Thread.Sleep(50);
    lock (cadeadoB) { /* ... */ } // espera a Thread 2 soltar cadeadoB
}

// Thread 2 (rodando ao mesmo tempo)
lock (cadeadoB)
{
    Thread.Sleep(50);
    lock (cadeadoA) { /* ... */ } // espera a Thread 1 soltar cadeadoA — travado para sempre
}
Como evitar

A regra mais simples e mais eficaz: se algum trecho do código precisa travar mais de um objeto, sempre trave na mesma ordem em todos os lugares do sistema. Se toda thread trava cadeadoA antes de cadeadoB, o cenário acima não existe mais. Prefira também manter seções críticas curtas e, quando possível, usar Interlocked ou coleções concorrentes em vez de lock manual — menos lugares para errar a ordem.

10. Onde isso te leva

Você agora distingue os dois tipos de concorrência do .NET: async/await para I/O (capítulo 10) e threads reais para CPU (este capítulo) — e sabe proteger dados compartilhados com lock, Interlocked e coleções concorrentes. Isso fecha o Módulo 1. O Módulo 2 aplica tudo isso — junto com LINQ, generics e o resto do C# — na parte que realmente domina este curso: SQL Server e Stored Procedures.

📌 Resumo do capítulo

  • async/await resolve I/O; multithreading real (Thread, Task.Run) resolve trabalho pesado de CPU — são problemas diferentes.
  • Task.Run usa o ThreadPool em vez de criar uma Thread nova a cada chamada.
  • Condição de corrida (race condition) acontece quando threads leem/escrevem o mesmo dado sem coordenação — o bug clássico é contador++ sem proteção.
  • lock protege blocos de código; Interlocked protege uma operação simples sem o custo de um lock.
  • Coleções normais (Dictionary, List) não são thread-safe — use as equivalentes em System.Collections.Concurrent.
  • Deadlock se evita travando múltiplos objetos sempre na mesma ordem em todo o código.

✏️ Praticando

  1. Reproduza o exemplo do contador quebrado (seção 4) e confirme que o resultado varia entre execuções. Depois corrija com lock e, em seguida, com Interlocked, comparando as duas soluções.
  2. Use Parallel.ForEach para processar uma lista de 100.000 números calculando algo custoso (ex: verificar se é primo) e compare o tempo total com um foreach sequencial.
  3. Escreva um ConcurrentDictionary<string, int> que conta quantas vezes cada palavra aparece em uma lista de textos processada em paralelo com Parallel.ForEach.