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


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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ❌ ABORDAGEM SEQUENCIAL — gargalo em grandes volumes
public class ValidadorDeAcesso
{
    private readonly List<RegistroFuncionario> _registros;

    public ValidadorDeAcesso(List<RegistroFuncionario> registros)
    {
        _registros = registros;
    }

    public ResultadoValidacao ValidarTodos(IEnumerable<string> cracharIds)
    {
        var resultados = new List<ResultadoCracha>();

        foreach (var id in cracharIds)
        {
            // Processamento sequencial: um de cada vez
            var registro = _registros.FirstOrDefault(r => r.CrachaId == id);
            var resultado = ProcessarValidacao(registro, id);
            resultados.Add(resultado);
        }

        return new ResultadoValidacao(resultados);
    }

    private ResultadoCracha ProcessarValidacao(RegistroFuncionario? registro, string id)
    {
        if (registro is null)
            return new ResultadoCracha(id, Acesso.Negado, "Crachá não encontrado");

        // Simula validação de regras de negócio (verificação de status, horário, etc.)
        ValidarStatusContratual(registro);
        ValidarHorarioPermitido(registro);
        ValidarPermissaoUnidade(registro);

        return new ResultadoCracha(id, Acesso.Permitido, registro.NomeFuncionario);
    }
}

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íncronaParalelismo
ResolveGargalos de espera (I/O)Gargalos de processamento (CPU)
ComoLibera a thread durante a esperaUsa múltiplas threads simultâneas
Ferramentaasync/await, TaskParallel, PLINQ, Task.Run
ExemploConsulta ao banco de dadosProcessar 80k registros em memória
Núcleos usadosEficiente com 1 núcleoAproveita 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)

Comparação visual entre processamento sequencial (1 thread, ~420ms) e paralelo com Parallel.ForEach (8 threads, ~68ms) na validação de 80 mil registros de funcionários

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.

1
2
3
4
CPU Core 1: Thread T1 → processa itens [0 ... 19.999]
CPU Core 2: Thread T2 → processa itens [20.000 ... 39.999]
CPU Core 3: Thread T3 → processa itens [40.000 ... 59.999]
CPU Core 4: Thread T4 → processa itens [60.000 ... 79.999]

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System.Collections.Concurrent;
using System.Threading.Tasks;

public class ValidadorDeAcessoParalelo
{
    private readonly List<RegistroFuncionario> _registros;

    public ValidadorDeAcessoParalelo(List<RegistroFuncionario> registros)
    {
        _registros = registros;
    }

    public ResultadoValidacao ValidarTodos(IEnumerable<string> cracharIds)
    {
        // ConcurrentBag: coleção thread-safe para acúmulo de resultados
        var resultados = new ConcurrentBag<ResultadoCracha>();

        Parallel.ForEach(cracharIds, id =>
        {
            // Cada iteração pode rodar em uma thread diferente simultaneamente
            var registro = _registros.FirstOrDefault(r => r.CrachaId == id);
            var resultado = ProcessarValidacao(registro, id);
            resultados.Add(resultado); // Thread-safe: ConcurrentBag
        });

        return new ResultadoValidacao(resultados.ToList());
    }
}

⚠️ Atenção: Note o uso de ConcurrentBag<T> em vez de List<T>. Uma List<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 namespace System.Collections.Concurrent em 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public ResultadoValidacao ValidarComLimite(IEnumerable<string> cracharIds, int maxThreads = 4)
{
    var resultados = new ConcurrentBag<ResultadoCracha>();

    var opcoes = new ParallelOptions
    {
        MaxDegreeOfParallelism = maxThreads // Limita ao número de threads simultâneas
    };

    Parallel.ForEach(cracharIds, opcoes, id =>
    {
        var registro = _registros.FirstOrDefault(r => r.CrachaId == id);
        var resultado = ProcessarValidacao(registro, id);
        resultados.Add(resultado);
    });

    return new ResultadoValidacao(resultados.ToList());
}

💡 Dica: Um bom ponto de partida para MaxDegreeOfParallelism é Environment.ProcessorCount (número de núcleos lógicos da máquina) ou Environment.ProcessorCount / 2 para 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>:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ValidadorOtimizado
{
    // Dictionary permite busca O(1) — tempo constante, independente do tamanho
    private readonly Dictionary<string, RegistroFuncionario> _indice;

    public ValidadorOtimizado(List<RegistroFuncionario> registros)
    {
        // Construir o índice uma única vez na inicialização
        _indice = registros.ToDictionary(r => r.CrachaId);
    }

    public ResultadoValidacao ValidarTodos(IEnumerable<string> cracharIds)
    {
        var resultados = new ConcurrentBag<ResultadoCracha>();

        Parallel.ForEach(cracharIds, id =>
        {
            // O(1) em vez de O(n) — melhora dramática
            _indice.TryGetValue(id, out var registro);
            resultados.Add(ProcessarValidacao(registro, id));
        });

        return new ResultadoValidacao(resultados.ToList());
    }
}

ℹ️ 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, use ConcurrentDictionary<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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ValidadorComPLINQ
{
    private readonly Dictionary<string, RegistroFuncionario> _indice;

    public ValidadorComPLINQ(Dictionary<string, RegistroFuncionario> indice)
    {
        _indice = indice;
    }

    public List<ResultadoCracha> ValidarTodos(IEnumerable<string> cracharIds)
    {
        return cracharIds
            .AsParallel()                           // Habilita PLINQ
            .WithDegreeOfParallelism(4)             // Limita threads simultâneas
            .WithExecutionMode(ParallelExecutionMode.ForceParallelism) // Força paralelismo
            .Select(id =>
            {
                _indice.TryGetValue(id, out var registro);
                return ProcessarValidacao(registro, id);
            })
            .ToList();
    }
}

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():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public List<ResultadoCracha> ValidarOrdenado(IEnumerable<string> cracharIds)
{
    return cracharIds
        .AsParallel()
        .AsOrdered()               // Preserva a ordem de entrada
        .WithDegreeOfParallelism(4)
        .Select(id =>
        {
            _indice.TryGetValue(id, out var registro);
            return ProcessarValidacao(registro, id);
        })
        .ToList();
}

⚠️ 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public async Task<ResultadoValidacao> ValidarComBancoDeDadosAsync(
    IEnumerable<string> cracharIds,
    CancellationToken cancellationToken = default)
{
    var resultados = new ConcurrentBag<ResultadoCracha>();

    var opcoes = new ParallelOptions
    {
        MaxDegreeOfParallelism = 8,
        CancellationToken = cancellationToken // Propaga cancelamento
    };

    await Parallel.ForEachAsync(cracharIds, opcoes, async (id, ct) =>
    {
        // Parte assíncrona: busca no banco (I/O-bound)
        var registro = await _repositorio.BuscarPorCrachaAsync(id, ct);

        // Parte paralela: validação de regras (CPU-bound)
        var resultado = ProcessarValidacao(registro, id);

        resultados.Add(resultado);
    });

    return new ResultadoValidacao(resultados.ToList());
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class EstatisticasDeAcesso
{
    // ConcurrentDictionary: thread-safe para leitura e escrita simultâneas
    private readonly ConcurrentDictionary<string, int> _acessosPorUnidade = new();

    public void RegistrarAcesso(string unidade)
    {
        // AddOrUpdate é atômico: não precisa de lock
        _acessosPorUnidade.AddOrUpdate(
            key: unidade,
            addValue: 1,
            updateValueFactory: (chave, valorAtual) => valorAtual + 1
        );
    }

    public void ProcessarEntradas(IEnumerable<EntradaFuncionario> entradas)
    {
        Parallel.ForEach(entradas, entrada =>
        {
            // Múltiplas threads chamando AddOrUpdate simultaneamente — seguro!
            RegistrarAcesso(entrada.Unidade);
        });
    }

    public IReadOnlyDictionary<string, int> ObterEstatisticas()
        => _acessosPorUnidade;
}

💡 Dica: ConcurrentDictionary é muito mais eficiente que usar um Dictionary com lock manual 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ValidadorComSemaforo
{
    // Limita a no máximo 10 validações simultâneas no banco externo
    private readonly SemaphoreSlim _semaforo = new SemaphoreSlim(initialCount: 10, maxCount: 10);
    private readonly IRepositorioAcesso _repositorio;

    public async Task ProcessarFilaDeEntradaAsync(
        IEnumerable<string> cracharIds,
        CancellationToken ct = default)
    {
        var tasks = cracharIds.Select(async id =>
        {
            await _semaforo.WaitAsync(ct); // Aguarda uma "vaga" no semáforo
            try
            {
                // Apenas 10 execuções simultâneas chegam aqui
                var registro = await _repositorio.BuscarPorCrachaAsync(id, ct);
                return ProcessarValidacao(registro, id);
            }
            finally
            {
                _semaforo.Release(); // Libera a vaga para a próxima Task
            }
        });

        await Task.WhenAll(tasks);
    }
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// ❌ PERIGO: contador++ não é atômico — race condition garantida
var totalAcessos = 0;
Parallel.ForEach(entradas, entrada =>
{
    if (ValidarAcesso(entrada))
        totalAcessos++; // Leitura + adição + escrita: 3 ops não atômicas
});

// ✅ CORRETO: Interlocked.Increment é atômico
var totalAcessos = 0;
Parallel.ForEach(entradas, entrada =>
{
    if (ValidarAcesso(entrada))
        Interlocked.Increment(ref totalAcessos); // 1 operação atômica
});

Listas como Acumuladores

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// ❌ PERIGO: List<T> não é thread-safe
var aprovados = new List<string>();
Parallel.ForEach(entradas, entrada =>
{
    if (ValidarAcesso(entrada))
        aprovados.Add(entrada.Nome); // Corrupção de dados em alta concorrência
});

// ✅ CORRETO: use ConcurrentBag ou ConcurrentQueue
var aprovados = new ConcurrentBag<string>();
Parallel.ForEach(entradas, entrada =>
{
    if (ValidarAcesso(entrada))
        aprovados.Add(entrada.Nome); // Thread-safe
});

Acesso a Recursos Externos (Banco de Dados, Arquivos)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// ❌ PERIGO: DbContext do EF Core não é thread-safe
var resultados = new ConcurrentBag<RegistroFuncionario>();
Parallel.ForEach(ids, id =>
{
    // DbContext compartilhado entre threads — exception garantida
    var reg = _context.Funcionarios.Find(id);
    resultados.Add(reg);
});

// ✅ CORRETO: crie um scope por thread (padrão fábrica)
Parallel.ForEach(ids, id =>
{
    using var scope = _serviceProvider.CreateScope();
    var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    var reg = context.Funcionarios.Find(id);
    resultados.Add(reg);
});

ℹ️ Informação: O DbContext do Entity Framework Core foi projetado para ser scoped (um por request). Nunca compartilhe um único DbContext entre threads — isso causa exceções de concorrência e resultados incorretos. Em paralelo, crie um DbContext por iteração usando IServiceScopeFactory.


Parallel.For vs Parallel.ForEach vs PLINQ

Qual usar em cada situação?

CenárioRecomendação
Iterar coleção com índiceParallel.For(0, n, i => ...)
Iterar coleção de objetosParallel.ForEach(colecao, item => ...)
Transformar coleção (projeção)colecao.AsParallel().Select(...)
Filtrar e transformarcolecao.AsParallel().Where(...).Select(...)
Requere I/O async + paralelismoParallel.ForEachAsync(colecao, async (item, ct) => ...)
Controlar recursos limitadosSemaphoreSlim + Task.WhenAll
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Parallel.For — quando o índice importa
var resultados = new ConcurrentBag<string>();
Parallel.For(0, _registros.Count, i =>
{
    var registro = _registros[i];
    if (ValidarRegra(registro, i)) // 'i' disponível
        resultados.Add($"[{i}] {registro.Nome}");
});

// PLINQ — para pipelines de transformação complexos
var relatorio = _registros
    .AsParallel()
    .WithDegreeOfParallelism(Environment.ProcessorCount)
    .Where(r => r.Status == StatusFuncionario.Ativo)
    .Where(r => r.DataAdmissao.Year < 2020) // Funcionários com mais de 5 anos
    .Select(r => new EntradaRelatorio(r.Nome, r.Unidade, r.CrachaId))
    .OrderBy(r => r.Unidade) // Atenção: AsOrdered() implícito
    .ToList();

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:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
public class SistemaDeAcesso
{
    private readonly IRepositorioFuncionarios _repositorio;
    private readonly IServiceScopeFactory _scopeFactory;
    private Dictionary<string, RegistroFuncionario> _indiceEmMemoria = [];

    public SistemaDeAcesso(
        IRepositorioFuncionarios repositorio,
        IServiceScopeFactory scopeFactory)
    {
        _repositorio = repositorio;
        _scopeFactory = scopeFactory;
    }

    // Passo 1: Carga assíncrona dos dados (I/O-bound → async/await)
    public async Task CarregarRegistrosAsync(CancellationToken ct = default)
    {
        var registros = await _repositorio.ObterTodosAsync(ct);

        // Indexar em memória para lookups O(1)
        _indiceEmMemoria = registros.ToDictionary(r => r.CrachaId);

        Console.WriteLine($"[Sistema] {_indiceEmMemoria.Count:N0} registros indexados.");
    }

    // Passo 2: Processamento paralelo (CPU-bound → Parallel)
    public RelatorioDeAcesso ProcessarLoteDeCatracas(IReadOnlyList<string> cracharIds)
    {
        var permitidos = new ConcurrentBag<string>();
        var negados = new ConcurrentBag<string>();
        var totalProcessado = 0;

        var opcoes = new ParallelOptions
        {
            MaxDegreeOfParallelism = Environment.ProcessorCount
        };

        Parallel.ForEach(cracharIds, opcoes, id =>
        {
            // Lookup O(1) no índice em memória
            if (_indiceEmMemoria.TryGetValue(id, out var registro)
                && registro.AcessoPermitido)
            {
                permitidos.Add(registro.NomeFuncionario);
                RegistrarEntradaLocal(registro); // Lógica CPU-bound
            }
            else
            {
                negados.Add(id);
            }

            Interlocked.Increment(ref totalProcessado);
        });

        return new RelatorioDeAcesso(
            TotalProcessado: totalProcessado,
            Permitidos: permitidos.ToList(),
            Negados: negados.ToList()
        );
    }

    // Passo 3: Persistência assíncrona em paralelo controlado (I/O + CPU)
    public async Task SincronizarComBancoAsync(
        IEnumerable<string> cracharIds,
        CancellationToken ct = default)
    {
        // SemaphoreSlim: limita a 20 chamadas ao banco simultâneas
        var semaforo = new SemaphoreSlim(20, 20);

        var tasks = cracharIds.Select(async id =>
        {
            await semaforo.WaitAsync(ct);
            try
            {
                using var scope = _scopeFactory.CreateScope();
                var context = scope.ServiceProvider
                    .GetRequiredService<AppDbContext>();

                var log = new LogAcesso
                {
                    CrachaId = id,
                    Timestamp = DateTimeOffset.UtcNow,
                    Origem = "Catraca"
                };

                context.LogsDeAcesso.Add(log);
                await context.SaveChangesAsync(ct);
            }
            finally
            {
                semaforo.Release();
            }
        });

        await Task.WhenAll(tasks);
    }

    private void RegistrarEntradaLocal(RegistroFuncionario registro)
    {
        // Operações locais CPU-bound (sem I/O)
        AtualizarCacheDeEntradas(registro);
        ValidarHorarioPermitido(registro);
    }
}

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.

1
2
3
4
5
6
7
// ❌ Não paralelizar: cada item depende do anterior
var soma = 0;
foreach (var valor in numeros)
    soma += valor; // Soma acumulativa: dependência sequencial

// ✅ Use LINQ agregação normal ou Parallel.For com particionamento manual
var soma = numeros.Sum(); // LINQ paralelo não ajuda aqui de qualquer forma

Medindo a Diferença: Benchmark

Para sentir a diferença na prática, aqui está um benchmark simples usando System.Diagnostics.Stopwatch:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System.Diagnostics;

var registros = GerarRegistros(80_000);
var indice = registros.ToDictionary(r => r.CrachaId);
var cracharParaValidar = GerarCrachars(2_000);

// Benchmark sequencial
var sw = Stopwatch.StartNew();
var resultadoSeq = cracharParaValidar
    .Select(id =>
    {
        indice.TryGetValue(id, out var r);
        return ProcessarValidacao(r, id);
    })
    .ToList();
sw.Stop();
Console.WriteLine($"Sequencial:  {sw.ElapsedMilliseconds}ms — {resultadoSeq.Count} itens");

// Benchmark paralelo (PLINQ)
sw.Restart();
var resultadoPar = cracharParaValidar
    .AsParallel()
    .WithDegreeOfParallelism(Environment.ProcessorCount)
    .Select(id =>
    {
        indice.TryGetValue(id, out var r);
        return ProcessarValidacao(r, id);
    })
    .ToList();
sw.Stop();
Console.WriteLine($"PLINQ ({Environment.ProcessorCount} cores): {sw.ElapsedMilliseconds}ms — {resultadoPar.Count} itens");

// Resultados típicos em um i7 (8 cores):
// Sequencial:          420ms — 2000 itens
// PLINQ (8 cores):      68ms — 2000 itens  (~6x mais rápido)

💡 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

FerramentaMelhor ParaEvitar Quando
Parallel.ForArrays com índice, volume grande, CPU-boundColeções pequenas
Parallel.ForEachColeções de objetos, CPU-boundOperações com I/O puro
PLINQ (.AsParallel())Pipelines de transformação/filtroQuando a ordem importa (custo)
Parallel.ForEachAsyncCPU + I/O (async dentro do paralelo)CPU puro simples
Task.WhenAll + SemaphoreSlimControlar taxa de I/O concorrenteCPU-bound pesado
ConcurrentDictionaryEstado compartilhado com muita escritaLeitura exclusiva (use Dictionary)
InterlockedContadores, flags atômicosEstruturas 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 SemaphoreSlim para 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


Referências do Artigo