Partilhar via


Potenciais armadilhas com PLINQ

Em muitos casos, o PLINQ pode fornecer melhorias significativas de desempenho em relação às consultas LINQ to Objects sequenciais. No entanto, o trabalho de paralelização da execução da consulta introduz complexidade que pode levar a problemas que, em código sequencial, não são tão comuns ou não são encontrados. Este tópico lista algumas práticas a serem evitadas ao escrever consultas PLINQ.

Não assuma que o paralelo é sempre mais rápido

Às vezes, a paralelização faz com que uma consulta PLINQ seja executada mais lentamente do que seu equivalente LINQ to Objects. A regra básica é que as consultas com poucos elementos de fonte e delegações rápidas de usuários provavelmente não terão grande aceleração. No entanto, como muitos fatores estão envolvidos no desempenho, recomendamos que você meça os resultados reais antes de decidir se deseja usar o PLINQ. Para obter mais informações, consulte Compreender o Aumento de Velocidade no PLINQ.

Evite gravar em locais de memória compartilhada

Em código sequencial, não é incomum ler ou gravar em variáveis estáticas ou campos de classe. No entanto, sempre que vários threads estão a aceder a essas variáveis simultaneamente, há um grande potencial para condições de corrida. Embora você possa usar bloqueios para sincronizar o acesso à variável, o custo da sincronização pode prejudicar o desempenho. Portanto, recomendamos que você evite, ou pelo menos limite, o acesso ao estado compartilhado em uma consulta PLINQ tanto quanto possível.

Exemplo: condição de competição com memória partilhada

O exemplo seguinte demonstra uma condição de concorrência que ocorre quando múltiplas threads escrevem numa variável partilhada. A variável total é acedida e modificada simultaneamente por múltiplos threads sem sincronização, levando a resultados imprevisíveis:

static void DemonstrateRaceCondition()
{
    int total = 0;
    var numbers = Enumerable.Range(0, 10000);

    // UNSAFE: Multiple threads writing to shared variable
    numbers.AsParallel().ForAll(n => total += n);

    Console.WriteLine($"Total (with race condition): {total}");
    // Expected: 49,995,000 but result is unpredictable due to race condition
}
Shared Sub DemonstrateRaceCondition()
    Dim total As Integer = 0
    Dim numbers = Enumerable.Range(0, 10000)

    ' UNSAFE: Multiple threads writing to shared variable
    numbers.AsParallel().ForAll(Sub(n) total += n)

    Console.WriteLine($"Total (with race condition): {total}")
    ' Expected: 49,995,000 but result is unpredictable due to race condition
End Sub

Neste código, a operação total += n não é atómica. Envolve ler o valor atual de total, somando n, e escrevendo o resultado de volta para total. Quando múltiplas threads executam esta operação simultaneamente, podem ler o mesmo valor, somá-lo em threads diferentes e reescrever resultados que se sobrescribam mutuamente. Isto faz com que algumas adições se percam, produzindo um resultado final incorreto.

A abordagem correta é usar operações thread-safe que não exijam estado mutável partilhado:

static void DemonstrateCorrectApproach()
{
    var numbers = Enumerable.Range(0, 10000);

    // SAFE: Use thread-safe aggregate operation
    int total = numbers.AsParallel().Sum();

    Console.WriteLine($"Total (correct): {total}");
    // Result is always 49,995,000
}
Shared Sub DemonstrateCorrectApproach()
    Dim numbers = Enumerable.Range(0, 10000)

    ' SAFE: Use thread-safe aggregate operation
    Dim total As Integer = numbers.AsParallel().Sum()

    Console.WriteLine($"Total (correct): {total}")
    ' Result is always 49,995,000
End Sub

O Sum método gere a paralelização internamente de forma segura para threads, garantindo resultados corretos sem necessidade de sincronização explícita. Outras abordagens seguras incluem a utilização Aggregate para agregações personalizadas ou a recolha de resultados em coleções thread-safe como ConcurrentBag<T>.

Evite a paralelização excessiva

Ao utilizar o AsParallel método, você incorre nos custos indiretos de particionar a coleção de origem e sincronizar os threads de execução. Os benefícios da paralelização são ainda mais limitados pelo número de processadores no computador. Não há nenhuma aceleração a ser obtida executando vários threads ligados à computação em apenas um processador. Portanto, é preciso ter cuidado para não realizar paralelização excessiva em uma consulta.

O cenário mais comum em que a paralelização excessiva pode ocorrer é em consultas aninhadas, conforme mostrado no trecho a seguir.

var q = from cust in customers.AsParallel()
        from order in cust.Orders.AsParallel()
        where order.OrderDate > date
        select new { cust, order };
Dim q = From cust In customers.AsParallel()
        From order In cust.Orders.AsParallel()
        Where order.OrderDate > aDate
        Select New With {cust, order}

Nesse caso, é melhor paralelizar apenas a fonte de dados externa (clientes), a menos que uma ou mais das seguintes condições se apliquem:

  • A fonte de dados interna (cust.Orders) é conhecida por ser muito longa.

  • Você está realizando um cálculo dispendioso em cada pedido. (A operação mostrada no exemplo não é cara.)

  • O sistema de destino é conhecido por ter processadores suficientes para lidar com o número de threads que serão produzidos paralelizando a consulta no cust.Orders.

Em todos os casos, a melhor maneira de determinar a forma de consulta ideal é testar e medir. Para obter mais informações, consulte Como medir o desempenho da consulta PLINQ.

Evite chamadas para métodos não thread-safe

Gravar em métodos de instância não seguros para threads a partir de uma consulta PLINQ pode levar à corrupção de dados que pode ou não passar despercebida no seu programa. Pode também conduzir a exceções. No exemplo a seguir, várias threads estariam tentando chamar simultaneamente o método FileStream.Write, o que não é suportado pela classe.

Dim fs As FileStream = File.OpenWrite(…)
a.AsParallel().Where(...).OrderBy(...).Select(...).ForAll(Sub(x) fs.Write(x))
FileStream fs = File.OpenWrite(...);
a.AsParallel().Where(...).OrderBy(...).Select(...).ForAll(x => fs.Write(x));

Limitar chamadas a métodos thread-safe

A maioria dos métodos estáticos em .NET são seguros para threads e podem ser chamados a partir de múltiplas threads em simultâneo. No entanto, mesmo nesses casos, a sincronização envolvida pode levar a uma lentidão significativa na consulta.

Observação

Você mesmo pode testar isso inserindo algumas chamadas para WriteLine em suas consultas. Embora este método seja usado nos exemplos de documentação para fins de demonstração, não o use em consultas PLINQ.

Evite operações de encomenda desnecessárias

Quando o PLINQ executa uma consulta em paralelo, ele divide a sequência de origem em partições que podem ser operadas simultaneamente em vários threads. Por padrão, a ordem em que as partições são processadas e os resultados são entregues não é previsível (exceto para operadores como OrderBy). Você pode instruir o PLINQ a preservar a ordenação de qualquer sequência de origem, mas isso tem um impacto negativo no desempenho. A melhor prática, sempre que possível, é estruturar as consultas de forma que não dependam da preservação da ordem. Para obter mais informações, consulte Preservação de Ordem no PLINQ.

Prefira ForAll a ForEach quando for possível

Embora o PLINQ execute uma consulta em múltiplas threads, se consumir os resultados num loop foreach (For Each em Visual Basic), então os resultados da consulta devem ser fundidos novamente numa única thread e acedidos em série pelo enumerador. Em alguns casos, isso é inevitável; No entanto, sempre que possível, use o ForAll método para permitir que cada thread produza seus próprios resultados, por exemplo, gravando em uma coleção thread-safe, como System.Collections.Concurrent.ConcurrentBag<T>.

A mesma questão se aplica ao Parallel.ForEach. Por outras palavras, source.AsParallel().Where().ForAll(...) deve ser fortemente preferido a Parallel.ForEach(source.AsParallel().Where(), ...).

Esteja ciente dos problemas de afinidade de thread

Algumas tecnologias, por exemplo, a interoperabilidade COM para componentes Single-Threaded Apartment (STA), Windows Forms e Windows Presentation Foundation (WPF), impõem restrições de afinidade de threads que exigem que o código seja executado numa thread específica. Por exemplo, tanto no Windows Forms como no WPF, um controlo só pode ser acedido na thread onde foi criado. Se você tentar aceder ao estado partilhado de um controle do Windows Forms numa consulta PLINQ, é gerada uma exceção se estiver a executar no depurador. (Esta definição pode ser desativada.) No entanto, se a sua consulta for consumida no thread da UI, poderá aceder ao controlo a partir do loop que enumera os resultados da foreach consulta, uma vez que esse código é executado em apenas um thread.

Não assuma que as iterações de ForEach, For e ForAll sempre são executadas em paralelo

É importante ter em mente que as iterações individuais num loop Parallel.For, Parallel.ForEach ou ForAll podem ser, mas não precisam, executadas em paralelo. Portanto, você deve evitar escrever qualquer código que dependa para a correção da execução paralela de iterações ou da execução de iterações em qualquer ordem específica.

Por exemplo, é provável que este código bloqueie:

Dim mre = New ManualResetEventSlim()
Enumerable.Range(0, Environment.ProcessorCount * 100).AsParallel().ForAll(Sub(j)
   If j = Environment.ProcessorCount Then
       Console.WriteLine("Set on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j)
       mre.Set()
   Else
       Console.WriteLine("Waiting on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j)
       mre.Wait()
   End If
End Sub) ' deadlocks
ManualResetEventSlim mre = new ManualResetEventSlim();
Enumerable.Range(0, Environment.ProcessorCount * 100).AsParallel().ForAll((j) =>
{
    if (j == Environment.ProcessorCount)
    {
        Console.WriteLine("Set on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j);
        mre.Set();
    }
    else
    {
        Console.WriteLine("Waiting on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j);
        mre.Wait();
    }
}); //deadlocks

Neste exemplo, uma iteração define um evento e todas as outras iterações aguardam o evento. Nenhuma das iterações em espera pode ser concluída até que a iteração de configuração de eventos seja concluída. No entanto, é possível que as iterações em espera bloqueiem todos os threads usados para executar o loop paralelo, antes que a iteração de configuração de eventos tenha tido a chance de ser executada. Isso resulta em um impasse – a iteração de configuração de eventos nunca será executada e as iterações em espera nunca serão ativadas.

Em particular, uma iteração de um loop paralelo nunca deve esperar por outra iteração do loop para progredir. Se o loop paralelo decidir agendar as iterações sequencialmente, mas na ordem oposta, ocorrerá um impasse.

Ver também