Paralelismo de Dados: Parallel e PLINQ
Objective
Paralelismo de dados divide uma coleção de trabalho independente entre vários
núcleos. O .NET oferece Parallel.For, Parallel.ForEach,
Parallel.ForEachAsync e PLINQ (AsParallel). Eles parecem um laço com ganho
de velocidade de graça, e essa é a armadilha: o paralelismo só ajuda quando o
trabalho é CPU-bound, grande o bastante para pagar o custo de agendamento e sem
estado compartilhado. Usado em itens minúsculos, em I/O ou dentro de uma
requisição web, ele deixa tudo mais lento ou esgota o thread pool que atende as
outras requisições. Este conceito cobre quando usar cada ferramenta, como
agregar sem locks e quando ficar sequencial.
Use Cases
- Redimensionar alguns milhares de imagens num worker, onde cada imagem é um trabalho de CPU independente.
- Chamar uma API externa para 500 ids com no máximo 8 requisições em andamento, sem semáforos escritos à mão.
- Somar ou filtrar um grande array em memória com uma função pura.
- Decidir se um endpoint lento deve usar
Parallel.ForEach(em geral: não).
Deep Dive
CPU-bound ou I/O-bound
Trabalho CPU-bound mantém um núcleo ocupado calculando (hash, processamento de
imagem, parsing). Mais núcleos ajudam, até o número de núcleos. Trabalho
I/O-bound espera um disco ou a rede, então a thread fica ociosa na maior parte
do tempo. Para I/O, a resposta é async/await, que libera a thread durante a
espera. Gastar uma thread por chamada em espera com Parallel.ForEach é a
ferramenta errada.
csharp// CPU-bound: corpo síncrono, uma thread de trabalho por núcleo.
Parallel.ForEach(files, file => Resize(file));
// I/O-bound: corpo assíncrono, concorrência limitada, sem threads bloqueadas.
await Parallel.ForEachAsync(
ids,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
async (id, token) => await client.GetFromJsonAsync<Item>($"items/{id}", token));Parallel.ForEach recebe um delegate síncrono. Passar uma lambda async
compila como async void, então o laço retorna antes de o trabalho terminar e
as exceções se perdem. Parallel.ForEachAsync (desde o .NET 6) é o que faz
await.
Agregando sem lock
Escrever numa variável compartilhada a cada iteração é uma race, e envolver cada
escrita num lock serializa o trabalho que você queria paralelizar. A sobrecarga
com localInit e localFinally dá a cada worker seu próprio acumulador e faz o
merge uma vez por worker:
csharplong total = 0;
Parallel.For(
0, numbers.Length,
() => 0L, // localInit: um acumulador por task
(i, state, local) => local + Expensive(numbers[i]),
local => Interlocked.Add(ref total, local)); // localFinally: merge uma vez por taskPLINQ
AsParallel() transforma uma query LINQ numa query paralela. Os resultados
voltam sem ordem definida, a menos que você peça:
csharpvar squares = numbers
.AsParallel()
.WithDegreeOfParallelism(Environment.ProcessorCount)
.Where(n => IsPrime(n))
.Select(n => n * n)
.ToArray();
var inOrder = numbers.AsParallel().AsOrdered().Select(Expensive).ToArray();AsOrdered mantém a ordem da origem e custa buffering. Prefira Aggregate ou
Sum a alterar estado externo em ForAll, porque eles combinam resultados
parciais com segurança.
Não dentro de uma requisição, sem pensar
Uma requisição ASP.NET Core já roda numa thread do thread pool, e o pool é
compartilhado por todas as outras requisições. Parallel.ForEach dentro de um
handler toma várias threads a mais do mesmo pool, então uma requisição pode
esgotar as outras sob carga. Para um job grande de CPU disparado por um
endpoint, enfileire-o para um worker em segundo plano e retorne, e mantenha os
handlers async para I/O.
Trade-offs
- Trabalho pequeno fica mais lento em paralelo. Particionar, agendar e fazer
merge custam mais que o corpo do laço.
Meça com um tamanho realista antes de paralelizar.csharpParallel.For(0, 1_000, i => sum[i] = i * 2); // em geral mais lento que um for simples - Estado mutável compartilhado é uma race.
results.Add(x)numList<T>a partir de várias iterações corrompe a lista. UselocalInit/localFinally, umConcurrentBag<T>ou valores de retorno do PLINQ. - Contenção cancela o ganho. Se toda iteração trava o mesmo objeto, ou acessa a mesma linha do banco, os workers entram em fila e você paga por threads que esperam.
- Ordenação tem custo.
AsOrderedeParallel.ForEachsobre uma origem com custo imprevisível por item podem deixar núcleos ociosos no final enquanto um pedaço lento termina. Não dependa da ordem de iteração doParallel.ForEach. - Exceções são agregadas. Uma iteração que falha cancela as demais na medida
do possível, e quem chama vê uma
AggregateExceptionnas APIs síncronas, enquantoForEachAsynclança a primeira quando recebe o await. - Concorrência sem limite sobrecarrega o destino.
Parallel.ForEachAsyncusa por padrão o número de processadores emMaxDegreeOfParallelism, o que muitas vezes é errado para uma API com rate limit. Defina-o de propósito.