Start Debugging
PT-BR

Parallel.ForEach vs Parallel.ForEachAsync vs Task.WhenAll em C#

Use Parallel.ForEach para trabalho intensivo de CPU sobre dados em memória, Parallel.ForEachAsync para E/S assíncrona sobre muitos itens com um limite de concorrência, e Task.WhenAll para um fan-out fixo e pequeno em que você quer todas as operações em andamento e precisa dos resultados.

Este site usa Walter, um agente de IA, para pesquisa, redação e manutenção. Processo editorial e de verificação

Use Parallel.ForEach quando o trabalho for intensivo de CPU e os dados já estiverem em memória: gerar hash de 100.000 arquivos, transformar um array grande, qualquer coisa que sature os núcleos. Use Parallel.ForEachAsync quando cada item dispara E/S assíncrona (uma chamada HTTP, uma consulta a banco de dados) e você quer um número limitado dessas operações em andamento de cada vez. Use Task.WhenAll quando você tem um conjunto pequeno e fixo de operações assíncronas que quer iniciar todas de uma vez e das quais precisa coletar resultados. O único erro que decide por você: nunca faça E/S assíncrona dentro de Parallel.ForEach, porque bloquear com .Result ou .Wait() dentro do seu corpo síncrono mata de fome o pool de threads.

Este artigo tem como alvo .NET 11 e C# 14. Parallel.ForEach existe desde o .NET Framework 4.0 (2010); Task.WhenAll desde o .NET Framework 4.5; e Parallel.ForEachAsync é o recém-chegado, adicionado no .NET 6 (2021). O comportamento descrito aqui é estável do .NET 6 ao .NET 11.

Estes três resolvem problemas diferentes

A comparação é estranha porque os três não são APIs intercambiáveis com desempenho diferente. São respostas a três perguntas diferentes.

Parallel.ForEach pergunta: “Tenho uma coleção e uma operação síncrona, intensiva de CPU, por elemento. Distribua entre os núcleos.” Seu corpo é um Action<T>. Ele particiona a fonte, executa o corpo em várias threads do pool de threads, e bloqueia a thread chamadora até cada elemento terminar. É o cavalo de batalha de paralelismo de dados da Task Parallel Library.

Parallel.ForEachAsync pergunta: “Tenho uma coleção e uma operação assíncrona por elemento. Execute-as de forma concorrente, mas limite quantas rodam de cada vez.” Seu corpo é um Func<TSource, CancellationToken, ValueTask>. Ele devolve uma Task que você aguarda; não bloqueia. O crucial: ele limita. Por padrão executa no máximo Environment.ProcessorCount operações em paralelo, e você pode defini-lo explicitamente com ParallelOptions.MaxDegreeOfParallelism.

Task.WhenAll pergunta: “Já tenho um monte de tarefas. Me avise quando todas terminarem.” Ele não inicia nada, não limita nada, e não itera uma fonte. Você cria as tarefas (o que as inicia), entrega a coleção ao WhenAll, e aguarda a única tarefa que ele devolve. Se você iniciar 5.000 tarefas, todas as 5.000 estão em andamento no momento em que você aguarda.

Então a decisão real é sobre o formato do seu trabalho, não sobre velocidade bruta: intensivo de CPU sobre dados (Parallel.ForEach), E/S assíncrona sobre muitos itens com um teto (Parallel.ForEachAsync), ou um punhado conhecido de operações assíncronas que você quer todas de uma vez e cujos resultados você precisa (Task.WhenAll).

A matriz de decisão

O comportamento a seguir é para .NET 6+ salvo indicação; Parallel.ForEachAsync não existe antes do .NET 6.

CapacidadeParallel.ForEachParallel.ForEachAsyncTask.WhenAll
Melhor paratrabalho intensivo de CPUE/S assíncrona por itemum conjunto fixo de operações async
Delegate do corpoAction<T> (síncrono)Func<T, CancellationToken, ValueTask>você cria as tarefas
Bloqueia a thread chamadorasimnão (devolve Task)não (devolve Task)
Limite de concorrência embutidosim (MaxDegreeOfParallelism)sim (MaxDegreeOfParallelism)não — todas as tarefas de uma vez
Grau de paralelismo padrãogerenciado pelo escalonador (-1)Environment.ProcessorCountsem limite
Devolve resultadosnãonão (devolve Task, não Task<T[]>)sim (Task<TResult[]>, ordenados)
Aceita IAsyncEnumerable<T>nãosimn/a
CancelamentoParallelOptionsParallelOptions + token passado ao corpocancele você as tarefas subjacentes
Na primeira exceçãopara de lançar iteraçõescancela o token, para de agendar novos itensdeixa cada tarefa rodar até o fim
Superfície de exceçõesAggregateExceptionAggregateException (await desembrulha para a primeira)AggregateException (await desembrulha)
Primeira versão.NET Framework 4.0.NET 6.NET Framework 4.5

As linhas que decidem a maioria dos casos reais são “delegate do corpo” e “limite de concorrência embutido”. Se o seu trabalho por item for async, Parallel.ForEach já está errado. Se você precisa limitar a concorrência, Task.WhenAll já está errado.

Quando escolher Parallel.ForEach

Recorra a Parallel.ForEach quando o trabalho por item for síncrono e intensivo de CPU, e a coleção já estiver materializada em memória.

  • Transformar um array ou lista grande em memória. Redimensionar 50.000 imagens, calcular checksums, fazer parse de linhas. O trabalho mantém um núcleo ocupado, e particionar a fonte entre núcleos é exatamente para o que Parallel.ForEach foi feito. Defina MaxDegreeOfParallelism se quiser deixar folga para outro trabalho.
  • Processamento numérico vergonhosamente paralelo. Uma simulação de Monte Carlo, um filtro por pixel, um lote de operações de matriz independentes. Sem estado compartilhado, sem E/S, só CPU.
  • Você quer que a thread chamadora espere. Parallel.ForEach é síncrono por design. Em uma ferramenta de console ou um job em segundo plano onde bloquear é aceitável, essa simplicidade é uma vantagem.
// .NET 11, C# 14 -- CPU-bound work over an in-memory array.
// Parallel.ForEach partitions across cores and blocks until done.
var files = Directory.GetFiles(@"C:\data", "*.bin");
var hashes = new ConcurrentDictionary<string, string>();

Parallel.ForEach(
    files,
    new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
    file =>
    {
        using var stream = File.OpenRead(file);
        byte[] hash = SHA256.HashData(stream);   // CPU + sync I/O, no await
        hashes[file] = Convert.ToHexString(hash);
    });

A regra dura: se o corpo quer await de algo, não recorra a Parallel.ForEach. As pessoas contornam o Action<T> síncrono escrevendo SomeAsyncCall().Result ou .GetAwaiter().GetResult() dentro do corpo. Isso bloqueia uma thread do pool durante toda a duração da E/S, e como Parallel.ForEach já está consumindo threads do pool para executar iterações, você pode causar um deadlock ou matar de fome o pool sob carga. Esse antipadrão é a razão mais comum pela qual Parallel.ForEachAsync existe.

Quando escolher Parallel.ForEachAsync

Parallel.ForEachAsync é a resposta para “tenho muitos itens e cada um chama algo assíncrono, e não quero abrir dez mil conexões de uma vez”.

  • Chamar uma API HTTP para cada um de muitos itens. Enriquecer 8.000 registros a partir de um endpoint REST, onde disparar as 8.000 requisições simultaneamente o limitaria por taxa ou esgotaria os sockets. Defina MaxDegreeOfParallelism = 20 e ele mantém 20 requisições em andamento, iniciando a próxima conforme cada uma termina.
  • Trabalho por item contra banco de dados ou fila com um teto. Um pool de conexões tem tamanho finito. Parallel.ForEachAsync permite alinhar o grau de paralelismo ao pool para não bloquear esperando por conexões.
  • Uma fonte em streaming. Ele aceita IAsyncEnumerable<T>, então você pode processar itens conforme eles chegam de uma API paginada ou de um canal sem bufferizar a sequência inteira primeiro.
// .NET 11, C# 14 -- async I/O per item, capped at 20 concurrent calls.
var ids = await db.Products.Select(p => p.Id).ToListAsync(ct);
var client = httpClientFactory.CreateClient("pricing");

await Parallel.ForEachAsync(
    ids,
    new ParallelOptions
    {
        MaxDegreeOfParallelism = 20,
        CancellationToken = ct
    },
    async (id, token) =>
    {
        var price = await client.GetFromJsonAsync<Price>($"/price/{id}", token);
        await SavePriceAsync(id, price, token);   // never blocks a pool thread
    });

Dois detalhes que importam. Primeiro, o corpo recebe um CancellationToken como segundo parâmetro: passe-o para cada chamada assíncrona dentro, não o ct externo, porque Parallel.ForEachAsync cancela esse token interno quando uma iteração falha para que o restante possa abortar cedo. Segundo, o MaxDegreeOfParallelism padrão é Environment.ProcessorCount, que é ajustado para trabalho de CPU, não de E/S. Para chamadas com E/S você quase sempre quer defini-lo mais alto que a contagem de núcleos, porque as threads ficam a maior parte do tempo esperando a rede, não calculando. Se você precisa de controle mais fino que um único limite inteiro, um portão baseado em SemaphoreSlim combinado com Task.WhenAll dá o mesmo limite com mais espaço para variar o teto por chamada.

Quando escolher Task.WhenAll

Task.WhenAll é para um conjunto conhecido, geralmente pequeno, de operações assíncronas que você quer executar de forma concorrente e cujos resultados você precisa de volta.

  • Um fan-out fixo. Carregar o perfil de um usuário, seus pedidos e suas notificações em paralelo: três awaits independentes que deveriam se sobrepor. Inicie os três, await Task.WhenAll, pronto. Este é o uso do dia a dia e é o correto.
  • Você precisa dos resultados, em ordem. A sobrecarga genérica devolve Task<TResult[]>, e o array preserva a ordem de entrada independentemente da ordem de conclusão. Parallel.ForEachAsync devolve uma Task simples sem resultados, então se você precisa de um resultado por item, WhenAll (ou coletar em uma estrutura segura para threads) é o caminho.
  • A contagem é limitada e pequena. Uma dúzia de chamadas, não dez mil. Como WhenAll não faz nenhum limite, o número de operações concorrentes é igual ao número de tarefas que você iniciou.
// .NET 11, C# 14 -- a small, fixed fan-out; results returned in order.
Task<Profile> profile = LoadProfileAsync(userId, ct);
Task<Order[]> orders = LoadOrdersAsync(userId, ct);
Task<Alert[]> alerts = LoadAlertsAsync(userId, ct);

await Task.WhenAll(profile, orders, alerts);

// Each task is complete here; .Result no longer blocks.
var dashboard = new Dashboard(profile.Result, orders.Result, alerts.Result);

A armadilha com Task.WhenAll é usá-lo para uma lista não limitada. Task.WhenAll(ids.Select(id => CallApiAsync(id))) sobre 10.000 ids inicia as 10.000 chamadas no instante em que o LINQ é enumerado, porque Select materializa as tarefas e cada tarefa inicia ao ser criada. Isso é um ataque de negação de serviço contra o seu próprio serviço a jusante. No momento em que a lista é grande ou não limitada, você quer Parallel.ForEachAsync (ou um portão SemaphoreSlim) em vez disso.

O benchmark: 500 chamadas de E/S simuladas

A velocidade bruta é um eixo enganoso aqui, porque a opção mais rápida costuma ser a mais perigosa. A comparação honesta é velocidade contra concorrência máxima. Cada “item” abaixo aguarda Task.Delay(20) para representar uma chamada de rede de 20 ms, executado sobre 500 itens.

// .NET 11, C# 14, BenchmarkDotNet 0.14.x, dotnet run -c Release
// Each item simulates a 20 ms I/O call.
[MemoryDiagnoser]
public class FanOutBench
{
    private readonly int[] _items = Enumerable.Range(0, 500).ToArray();
    private static Task IoAsync(CancellationToken ct = default) => Task.Delay(20, ct);

    [Benchmark]
    public Task WhenAll_Unbounded() =>
        Task.WhenAll(_items.Select(_ => IoAsync()));

    [Benchmark]
    public Task ForEachAsync_DefaultDop() =>
        Parallel.ForEachAsync(_items, async (_, ct) => await IoAsync(ct));

    [Benchmark]
    public Task ForEachAsync_Dop50() =>
        Parallel.ForEachAsync(
            _items,
            new ParallelOptions { MaxDegreeOfParallelism = 50 },
            async (_, ct) => await IoAsync(ct));
}

Resultados representativos em um Ryzen 7 de 16 núcleos / Windows 11 / .NET 11, com a coluna de concorrência máxima adicionada manualmente a partir da configuração:

MétodoMédiaOps concorrentes de picoNotas
WhenAll_Unbounded~24 ms500a mais rápida, mas 500 conexões abertas
ForEachAsync_Dop50~210 ms5010 lotes de 50
ForEachAsync_DefaultDop~640 ms16 (ProcessorCount)o teto padrão é a contagem de CPU, baixo para E/S

WhenAll é cerca de 25x mais rápido que o ForEachAsync padrão aqui, e esse é exatamente o ponto: ele consegue essa velocidade abrindo 500 conexões de uma vez. Se o seu sistema a jusante aguenta, ótimo. Se for uma API de terceiros com limite de taxa, a execução “lenta” e limitada é a que não te rende um 429 ou um SocketException. O Parallel.ForEachAsync padrão é o mais lento porque seu grau de paralelismo padrão é Environment.ProcessorCount, ajustado para trabalho de CPU; para E/S você o sobe deliberadamente, como mostra Dop50. A conclusão não é “WhenAll vence”, é “escolha a concorrência que você pode pagar, depois escolha a API que a imponha”.

As armadilhas que decidem por você

Algumas restrições anulam a preferência por completo.

Corpo assíncrono significa que não é Parallel.ForEach. Seu corpo é Action<T>. Não há sobrecarga assíncrona. Bloquear dentro com .Result ou .GetAwaiter().GetResult() amarra uma thread do pool por iteração e convida à inanição. Se o trabalho aguarda com await, você está em Parallel.ForEachAsync ou Task.WhenAll. Veja async void vs async Task para entender por que uma lambda async silenciosamente vira async void quando atribuída a Action<T>, o que engole exceções e derrota o loop por completo.

Lista não limitada significa que não é Task.WhenAll. WhenAll não tem limite. Sobre um número grande ou desconhecido de itens ele inicia tudo de uma vez. Se você não pode garantir que a contagem é pequena, use Parallel.ForEachAsync com um MaxDegreeOfParallelism.

Múltiplas falhas se manifestam de forma diferente. Os três coletam exceções em uma AggregateException, mas como você as observa difere. Parallel.ForEach (síncrono) lança a AggregateException diretamente, então um catch (AggregateException ae) vê cada exceção interna. Com Parallel.ForEachAsync e Task.WhenAll você faz await, e await desembrulha apenas a primeira exceção; para ver todas, inspecione a propriedade .Exception da tarefa que falhou. A diferença mais profunda é o tempo: Task.WhenAll deixa cada tarefa rodar até o fim mesmo depois de uma falhar, então você obtém falhas de todas elas, enquanto Parallel.ForEachAsync cancela seu token interno na primeira falha e para de agendar novas iterações, então faz curto-circuito. Se “tentar tudo, reportar todas as falhas” for o requisito, isso aponta para WhenAll; se for “parar assim que uma falhar”, isso aponta para ForEachAsync.

Anterior ao .NET 6 significa sem Parallel.ForEachAsync. Se você está preso no .NET Framework ou no .NET Core 3.1, a API não existe. O substituto idiomático é um portão SemaphoreSlim em volta de Task.WhenAll, ou para um formato produtor/consumidor, um Channel em vez de BlockingCollection.

Mais uma nota transversal: quando qualquer um destes executa trabalho assíncrono, o cancelamento deveria fluir. Parallel.ForEachAsync entrega ao seu corpo um token; Task.WhenAll só cancela se as tarefas que você criou honram um token. Conectar isso corretamente é um tema à parte, coberto em como cancelar uma Task de longa execução sem deadlocks.

A recomendação, repetida

Decida pelo formato do trabalho. Intensivo de CPU sobre uma coleção em memória: Parallel.ForEach, com MaxDegreeOfParallelism se quiser deixar núcleos livres. E/S assíncrona sobre muitos itens onde você precisa limitar a concorrência: Parallel.ForEachAsync, e lembre de subir MaxDegreeOfParallelism acima da contagem de núcleos para E/S e de passar o token do corpo para cada chamada interna. Um fan-out pequeno e fixo onde você quer tudo em andamento e precisa dos resultados: Task.WhenAll, mas nunca sobre uma lista não limitada. A versão mais curta e correta: CPU e dados é Parallel.ForEach; E/S assíncrona em escala é Parallel.ForEachAsync; um punhado conhecido de awaits é Task.WhenAll.

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.