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.
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.
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}");
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.
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ê.
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.
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.
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
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:
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
| Ferramenta | Quando usar |
|---|---|
lock | Proteger um bloco de código com várias operações relacionadas (ex: ler e depois escrever em mais de uma variável). |
Interlocked | Uma única operação simples (incrementar, somar, comparar-e-trocar) — mais rápido que lock por não bloquear a thread. |
| Coleções concorrentes | Quando 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:
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:
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");
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.
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
}
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.