Introdução
Imagine a portaria de uma grande empresa — uma corporação centenária com dezenas de unidades e 80.000 funcionários ativos no cadastro. São 8h da manhã de uma segunda-feira: centenas de pessoas chegam ao mesmo tempo, cada uma aproximando seu crachá das catracas eletrônicas. Para cada crachá, o sistema precisa realizar uma validação contra o banco de dados corporativo: buscar o registro, verificar situação, checar permissões de acesso e registrar a entrada.
Se esse sistema for sequencial — processando uma validação de cada vez, em fila — as pessoas vão empilhar na entrada. Com 80.000 registros e um tempo de busca de alguns milissegundos por consulta, a matemática é cruel. É aqui que entra o paralelismo.
Ao contrário da programação assíncrona — que é sobre liberar a thread enquanto espera I/O (leia mais em Programação Assíncrona em C#: async/await, Threads e CancellationToken) — o paralelismo é sobre usar múltiplos núcleos de CPU simultaneamente para processar tarefas ao mesmo tempo. É a estratégia certa quando o gargalo é no processamento e não na espera por I/O.
Neste artigo você vai entender o que é paralelismo, como ele funciona no .NET, quais ferramentas usar (Parallel.ForEach, PLINQ, Task.Run), como evitar os armadilhas clássicas de concorrência e, principalmente, quando faz sentido usar cada abordagem — tudo ancorado no exemplo da catraca corporativa.
📦 Código-fonte: A implementação completa deste artigo está no repositório blog-zocateli-sample no GitHub. Clone, explore e adapte ao seu contexto.
Pré-requisitos
- Familiaridade com C# e .NET (6+)
- Entendimento básico de threads e
Task - Recomendado: leitura do artigo sobre programação assíncrona em C#
O Problema: A Catraca Corporativa
Antes de escrever uma linha de código, vamos entender o problema concreto.
A empresa TechCorp Centenária S.A. possui:
- 80.000 funcionários ativos em 12 unidades
- Um banco de dados corporativo legado com registros de controle de acesso
- 200 catracas espalhadas pelas entradas de todos os prédios
- Pico de acesso: 2.000 funcionários se apresentando nos primeiros 15 minutos do turno
O sistema atual é simples: quando o crachá é lido, uma busca sequencial é feita numa lista em memória carregada do banco. O código original é este:
| |
Com 80.000 registros e 2.000 validações chegando quase simultaneamente, o loop foreach processa uma de cada vez. Isso é um problema de CPU-bound: o processador está trabalhando, não esperando — mas está trabalhando sozinho, em um único núcleo.
💡 Dica: Distinga os problemas antes de escolher a solução. Pergunta-se: “meu processo está esperando dados (I/O-bound) ou processando dados (CPU-bound)?” Para I/O-bound use async/await. Para CPU-bound use paralelismo.
Assíncrono vs Paralelo: a Diferença que Muda Tudo
É fundamental ter clareza sobre essa distinção antes de prosseguir:
| Programação Assíncrona | Paralelismo | |
|---|---|---|
| Resolve | Gargalos de espera (I/O) | Gargalos de processamento (CPU) |
| Como | Libera a thread durante a espera | Usa múltiplas threads simultâneas |
| Ferramenta | async/await, Task | Parallel, PLINQ, Task.Run |
| Exemplo | Consulta ao banco de dados | Processar 80k registros em memória |
| Núcleos usados | Eficiente com 1 núcleo | Aproveita todos os núcleos disponíveis |
No problema da catraca:
- Consultar o banco de dados para carregar os registros → assíncrono (
ToListAsync()) - Processar/validar os 80.000 registros em memória → paralelo (
Parallel.ForEach)
Muitas soluções reais combinam os dois padrões, e você verá isso ao longo deste artigo.
Como o Paralelismo Funciona no .NET
O .NET gerencia threads através do ThreadPool — um conjunto de threads pré-alocadas prontas para uso. Quando você usa Parallel.ForEach ou Task.Run, o runtime distribui o trabalho entre as threads disponíveis no pool, mapeando-as aos núcleos físicos da CPU.
Particionamento Automático
O maior diferencial do Parallel no .NET é o particionamento automático: o runtime divide a coleção em blocos (chunks) e distribui cada bloco para uma thread diferente. Isso evita a overhead de criar uma thread por item.
| |
Em uma máquina com 8 núcleos, 80.000 registros seriam divididos em ~8 partições de 10.000, reduzindo o tempo de processamento em até 8x (na prática um pouco menos devido ao overhead de sincronização).
Parallel.ForEach — A Solução Para a Catraca
A ferramenta mais direta para paralelizar um foreach é o Parallel.ForEach. Veja como transformar o código sequencial:
| |
⚠️ Atenção: Note o uso de
ConcurrentBag<T>em vez deList<T>. UmaList<T>comum não é thread-safe — múltiplas threads adicionando itens simultaneamente causariam corrupção de dados ou exceções. Use sempre coleções do namespaceSystem.Collections.Concurrentem cenários paralelos.
Controlando o Grau de Paralelismo
Por padrão, o .NET usa todos os núcleos disponíveis. Em produção, isso pode ser agressivo demais — você pode querer limitar para não saturar o servidor e impactar outros processos:
| |
💡 Dica: Um bom ponto de partida para
MaxDegreeOfParallelisméEnvironment.ProcessorCount(número de núcleos lógicos da máquina) ouEnvironment.ProcessorCount / 2para deixar folga para outros processos.
Acelerando a Busca: Estruturas de Dados Corretas
Antes de paralelizar, verifique se a estrutura de dados não é o gargalo. No exemplo da catraca, o FirstOrDefault em uma List<T> tem complexidade O(n) — percorre toda a lista até encontrar o item. Com 80.000 registros, isso é caro.
A solução é pré-indexar os dados em um Dictionary<string, RegistroFuncionario>:
| |
ℹ️ Informação:
Dictionary<TKey, TValue>não é thread-safe para escrita, mas é seguro para leituras concorrentes quando o dicionário não está sendo modificado. Como o índice é construído uma vez e apenas lido em paralelo, este padrão é correto. Se precisar de escrita concorrente, useConcurrentDictionary<TKey, TValue>.
PLINQ: Paralelismo com a Elegância do LINQ
O PLINQ (Parallel LINQ) é uma versão paralela do LINQ tradicional. Basta adicionar .AsParallel() a qualquer consulta LINQ para que o runtime distribua automaticamente o processamento entre as threads.
| |
PLINQ com Ordenação
Um ponto importante: PLINQ não garante a ordem dos resultados por default. Se a ordem importar (ex.: gerar relatório na ordem de chegada), use .AsOrdered():
| |
⚠️ Atenção:
.AsOrdered()tem um custo de sincronização adicional — threads precisam coordenar a ordem dos resultados. Use apenas quando realmente necessário.
Parallel.ForEachAsync — Combinando Paralelo e Assíncrono
O cenário real da catraca é mais rico: a validação envolve tanto processamento em memória quanto consultas ao banco de dados. Para isso, o .NET 6+ oferece Parallel.ForEachAsync — que combina paralelismo CPU com operações assíncronas de I/O:
| |
Este é o padrão mais poderoso: não bloqueia threads durante o I/O (assíncrono) e usa múltiplos núcleos para o processamento (paralelo).
ConcurrentDictionary: Estado Compartilhado sem Lock Manual
Além do ConcurrentBag, o ConcurrentDictionary<TKey, TValue> é uma das estruturas mais úteis em cenários paralelos. Imagine que a catraca precisa acumular estatísticas de acesso por unidade:
| |
💡 Dica:
ConcurrentDictionaryé muito mais eficiente que usar umDictionarycomlockmanual em cenários de alta concorrência de leitura/escrita, pois usa lock striping internamente — dividindo o dicionário em segmentos independentes para minimizar contenção.
Controlando Acesso a Recursos Limitados com SemaphoreSlim
Na vida real, catracas têm limites físicos — você não pode deixar 100 pessoas passarem pelo mesmo portão ao mesmo tempo. O mesmo vale para o código: quando você tem um recurso compartilhado limitado (conexões com banco de dados, APIs externas, arquivos), use SemaphoreSlim para controlar o acesso concorrente:
| |
A metáfora é precisa: o SemaphoreSlim é literalmente um semáforo — controla quantas “passagens” simultâneas são permitidas. WaitAsync aguarda uma vaga; Release libera uma.
Thread Safety: Armadilhas Clássicas no Paralelismo
Ao paralelizar código, os erros mais comuns são race conditions — condições de corrida onde o resultado depende da ordem imprevisível de execução das threads. Veja os casos mais frequentes:
Contadores Compartilhados
| |
Listas como Acumuladores
| |
Acesso a Recursos Externos (Banco de Dados, Arquivos)
| |
ℹ️ Informação: O
DbContextdo Entity Framework Core foi projetado para ser scoped (um por request). Nunca compartilhe um únicoDbContextentre threads — isso causa exceções de concorrência e resultados incorretos. Em paralelo, crie umDbContextpor iteração usandoIServiceScopeFactory.
Parallel.For vs Parallel.ForEach vs PLINQ
Qual usar em cada situação?
| Cenário | Recomendação |
|---|---|
| Iterar coleção com índice | Parallel.For(0, n, i => ...) |
| Iterar coleção de objetos | Parallel.ForEach(colecao, item => ...) |
| Transformar coleção (projeção) | colecao.AsParallel().Select(...) |
| Filtrar e transformar | colecao.AsParallel().Where(...).Select(...) |
| Requere I/O async + paralelismo | Parallel.ForEachAsync(colecao, async (item, ct) => ...) |
| Controlar recursos limitados | SemaphoreSlim + Task.WhenAll |
| |
Exemplo Completo: Sistema de Catraca em Produção
Agora vamos montar o cenário completo com todos os padrões aprendidos — do carregamento assíncrono do banco à validação paralela e ao relatório final:
| |
Quando NÃO Usar Paralelismo
Paralelismo não é bala de prata. Há situações em que ele piora a performance:
1. Coleções pequenas: O overhead de criar e sincronizar threads pode ser maior que o benefício. Para menos de ~1.000 itens simples, o código sequencial costuma ser mais rápido.
2. Operações I/O-bound simples: Se o gargalo é a rede ou o disco, adicionar threads não ajuda — o bottleneck continua sendo externo. Use async/await.
3. Operações não thread-safe insanáveis: Se o processamento de cada item depende do resultado dos anteriores (estado compartilhado obrigatório), o paralelismo cria complexidade sem benefício.
4. Código legado com efeitos colaterais ocultos: Variáveis globais, singletons com estado mutável, logging não thread-safe — paralelizar código assim gera bugs difíceis de reproduzir.
| |
Medindo a Diferença: Benchmark
Para sentir a diferença na prática, aqui está um benchmark simples usando System.Diagnostics.Stopwatch:
| |
💡 Dica: Para benchmarks sérios em produção, use a biblioteca BenchmarkDotNet — ela controla variáveis como JIT warm-up, GC e variância estatística, gerando resultados muito mais confiáveis.
Resumo: Quando Usar Cada Ferramenta
| Ferramenta | Melhor Para | Evitar Quando |
|---|---|---|
Parallel.For | Arrays com índice, volume grande, CPU-bound | Coleções pequenas |
Parallel.ForEach | Coleções de objetos, CPU-bound | Operações com I/O puro |
PLINQ (.AsParallel()) | Pipelines de transformação/filtro | Quando a ordem importa (custo) |
Parallel.ForEachAsync | CPU + I/O (async dentro do paralelo) | CPU puro simples |
Task.WhenAll + SemaphoreSlim | Controlar taxa de I/O concorrente | CPU-bound pesado |
ConcurrentDictionary | Estado compartilhado com muita escrita | Leitura exclusiva (use Dictionary) |
Interlocked | Contadores, flags atômicos | Estruturas complexas |
Conclusão
O exemplo da catraca corporativa é perfeito para ilustrar quando e por que o paralelismo importa. Uma empresa centenária não pode ter funcionários esperando na fila porque o sistema processa um crachá de cada vez. Com Parallel.ForEach, PLINQ e as coleções concorrentes do .NET, é possível transformar processos que demoravam centenas de milissegundos em dezenas — ou menos.
Os pontos fundamentais para lembrar:
- Paralelismo é para CPU-bound — quando o processador está trabalhando, não esperando
- Combine com async/await para cenários que envolvem I/O
- Sempre use coleções thread-safe (
ConcurrentBag,ConcurrentDictionary) para acúmulo de resultados - Controle o grau de paralelismo com
MaxDegreeOfParallelism— nem sempre “máximo = melhor” - Use
SemaphoreSlimpara proteger recursos limitados (conexões, APIs externas) - Meça antes e depois — paralelismo mal aplicado pode ser mais lento que o código sequencial
💡 Dica Final: C# e o ecossistema .NET oferecem uma das implementações de paralelismo mais completas e bem documentadas do mercado. A TPL (Task Parallel Library), o PLINQ e as coleções concorrentes fazem parte do runtime padrão — sem dependências externas, sem configuração adicional. Se você está construindo sistemas que precisam escalar, C# é uma escolha excelente pela sua maturidade nesse domínio. Para ir mais fundo, explore também Channels e System.Threading.Tasks.Dataflow para pipelines de alta performance.
Leia Também
- Programação Assíncrona em C#: async/await do Fundamento à Produção
- Gargalo em Banco de Dados com C# e EF Core: Mensageria e Paginação
- Paginação em APIs REST com C# e EF Core 8: Todos os Tipos, Todos os Bancos
- .NET Worker e Background Service para Alto Volume
Referências do Artigo
- Task Parallel Library (TPL) — Microsoft Docs
- Parallel LINQ (PLINQ) — Microsoft Docs
- System.Collections.Concurrent — Microsoft Docs
- Parallel.ForEachAsync — .NET 6+ — Microsoft Docs
- BenchmarkDotNet — Biblioteca de Benchmarks para .NET
- Programação Assíncrona em C# — zocate.li
- Repositório blog-zocateli-sample — AsyncParallel — Código-fonte completo dos exemplos deste artigo
Ao comentar, você concorda com nossa Política de Privacidade, Termos de Uso e Política de Exclusão de Dados.