Olá galera, tudo certin? Em um sábado chuvoso em Caxias do Sul, ansioso para iniciar minha jornada rumo ao TechEd (na distante São Paulo [uma horinha e meia de vôo]), escrevo mais um “postizinho”!
Que assunto abordo hoje? PLINQ.
Importante dizer que este não é, exatamente, um post com material introdutório sobre o assunto. Presumo que você, leitor, tenha algum conhecimento sobre o tema. [Se houver feedback positivo, posso até iniciar uma série mostrando os primeiros passos com essa tecnologia.]
Pela complexidade do assunto, resolvi não tentar, de forma alguma esgotar o tema. O que mostro aqui é uma visão alta, conceitual, que visa permitir maior segurança para quem estiver começando a usar essa ferramenta seriamente.
Go Code!
Há dois pontos que gostaria de colocar antes de começar a falar, efetivamente, sobre PLINQ:
O .NET Framework 4 nos dá mecanismos poderosos para que possamos aproveitar todo poder computacional de nossos computadores. Vamos usar isso e, se possível, sempre buscando entregar o melhor valor percebido possível para os stakeholders.
Gostaria de dizer que deixar nossos programas prontos para utilizar paralelismo fosse apenas questão de adicionar a operação AsParallel em todos os nossos loops! Infelizmente, não é assim.
PLINQ torna muito mais fácil utilizar diversos “core” em nossos programas, mas ainda temos que fazer a coisa da forma certa. Ou seja, ainda não é uma tarefa trivial, mas ficou bem mais fácil.
Importante destacar que as extensões de paralelismo do .net estão relacionadas a Enumerable, não com Queryable. Isso significa que PLINQ não ajuda com LINQ to SQL ou EF. Embora pareça estranho, é lógico, visto que, nesses casos, o paralelismo que deva existir deveria ser implementado pelo banco de dados.
Respondendo de forma simples, ainda temos que entender e planejar quando o acessso a dados precisa ser sincronizado. Ainda temos que medir os efeitos das versões paralelizadas e sequenciais dos métodos declarados em ParallelEnumerable. Algumas operações LINQ funcionam facin em paralelo. Outras, nem tanto (por exemplo, sorting requer entradas completa).
Considere o código que segue:
1 var nums = data.Where(n => n < 200).Select(n => Fibonacci(n));
A funcionalidade do código não é relevante aqui. Importante apenas é saber que data consiste de uma fonte IEnumerable, que serve como entrada para os métodos Where e Select. Se desejássemos tornar esse código “paralelizado”, basta:
1 var nums = data.AsParallel() 2 .Where(n => n < 200).Select(n => Fibonacci(n));
Lindo! ![]()
Claro, podemos escrever facilmente uma versão em sintax query:
1 var nums = from n in data.AsParallel() 2 where n < 200 3 select Fibonacci(n);
Este primeiro exemplo é muito simples, mas nos permite perceber alguns conceitos importantes utilziados em PLINQ. O método AsParallel() é o responsável por converter nossa consulta normal, em uma consulta paralela. Uma vez que tenhamos chamado AsParallel, todas as operações que seguem serão executadas em multiplos cores, com multiplas threads. AsParallel() é um extension method que recebe um IEnumerable e retorna um IParallelEnumerable. PLINQ foi implementado como um conjunto de extension methods para IParallelEnumerable. Eles tem, quase todos, a mesma assinatura que os extension methods definidos na classe Enumerable que estendem objetos IEnumerable. De forma geral, tudo aquilo que sabemos fazer usando LINQ, continua válido para PLINQ.
Claro que, este primeiro exemplo é simples demais. Nossa consulta é muito simples mas não tem qualquer dado compartilhado. A ordem dos resultados também não importa. Obviamente, isso torna simples evidente a o incremento de performance na proporção em que temos mais “processadores” em nossos computadores é fácil para essas condições.
Cada consulta com PLINQ começa pelo particionamento das entradas. Esse particionamento pega os dados recebidos na entrada e os distribuí entre as diversas “tasks” criadas para executar a consulta. Particionamento é um dos aspectos mais importantes do PLINQ. Há diferentes abordagens para essa operação. Entender como PLINQ decide qual usar, e como cada uma funciona é, fundamental para entender PLINQ. Perceba: particionamento não pode consumir muito tempo – se não fosse assim, PLINQ tomaria muito tempo organizando os dados e pouco tempo processando-os. ![]()
PLINQ usa quatro diferentes métodos de particionamento. O método utilizado é usado de acordo com os dados recebidos na entrada, e o tipo que consulta estamos criando. São tipos de particionamento:
Independente do método usado para particionamento, há três diferentes abordagens usadas pelo PLINQ para paralelizar as tarefas em nosso código: Pipelining, Stop and Go e enumeração invertida. Veja:
Todas as operações LINQ são executadas de forma tardia (lazily). Criamos consultas e estas consultas somente são executadas quando uma tarefa requisitar items produzidos por elas. LINQ to Objects dá um passo adiante, executando a consulta a medida que cada item for executado (salve yield return). PLINQ trabalha de forma diferente! Seu modelo se aproxima, muito da forma como o EF funciona, onde, quando requisitamos por um primeiro item, toda sequência é gerada. Repare que eu disse que o modelo de execução do PLINQ se aproxima do modelo do EF. Aproximado sim, igual não! Uma mal compreensão de como PLINQ executa suas consultas faz com que utilizemos mais recursos que o necessário.
Para ilustrar bem o modelo, proponho o seguinte exemplo:
using System; using System.Linq; namespace Parallel { class Program { static void Main(string[] args) { var answers = from n in Enumerable.Range(0, 60) where n.SomeTest() select n.SomeProjection(); var iter = answers.GetEnumerator(); Console.WriteLine("Starting ..."); while (iter.MoveNext()) { Console.WriteLine("called movenext"); Console.WriteLine(iter.Current); } Console.ReadLine(); } } static class ExtensionMethods { public static bool SomeTest(this int that) { Console.WriteLine("Testing {0}.", that); return that % 10 == 0; } public static string SomeProjection(this int that) { Console.WriteLine("projecting {0}.", that); return string.Format("{0} in {1}", that, DateTime.Now.ToLongTimeString()); } } }
Nessa implementação, optei por usar métodos simples de teste e de projeção. A razão de criar eles, é para poder mostrar a sequência em que as operações são executadas. Da mesma forma, no lugar de usar um for each optei por usar o enumerator. A idéia é ter condições de mostrar com mais detalhes o que acontece. A saída desse programa, quando executado, seria essa:
Starting ... Testing 0. projecting 0. called movenext 0 in 18:47:43 Testing 1. Testing 2. Testing 3. Testing 4. Testing 5. Testing 6. Testing 7. Testing 8. Testing 9. Testing 10. projecting 10. called movenext 10 in 18:47:43 Testing 11. Testing 12. Testing 13. Testing 14. Testing 15. Testing 16. ...
A query não começa até que o primeiro MoveNext seja evocado no enumerador. A primeira chamada executa a consulta na medida exata para prover o primeiro elemento. A próxima chamada para MoveNext() processa o segundo elemento e.. assim caminha a humanidade ![]()
As coisas mudam um cadin quando introduzimos paralelismo… Observe a mudança de atribuição para Answers:
var answers = from n in ParallelEnumerable.Range(0, 60) where n.SomeTest() select n.SomeProjection();
que dá por resultado:
Starting ... Testing 0. Testing 15. Testing 16. Testing 17. projecting 0. Testing 45. Testing 46. Testing 47. Testing 48. Testing 49. Testing 50. projecting 50. Testing 30. projecting 30. Testing 18. Testing 19. Testing 20. projecting 20. Testing 31. Testing 32. Testing 33. Testing 34.
Observe que o resultado é muito diferente. A primeira chamada para MoveNext faz com que PLINQ inicie todas as threads envolvidas na geração de resultados. Isso faz com que alguns resultados sejam produzidos . Cada MoveNext() vai pegar um resultado já produzido. Não há maneiras de determinar em que ordem as entradas serão processadas. Tudo que sabemos é que a consulta começa em diversas threads assim que o primeiro MoveNext for executado.
Agora, observe essa consulta:
var answers = (from n in ParallelEnumerable.Range(0, 500) where n.SomeTest() select n.SomeProjection()) .Skip(20).Take(20);
Essa consulta irá resultar algo parecido com nosso primeiro exemplo. O que aconteceu aqui? PLINQ interpretou o que desejamos e, veja, não há forma de obter o resultado correto sem, ou gerar o conjunto completo, ou processar em sequência .. Qual é mais rápido .. ?? É muito bom esse PLINQ ![]()
Agora, uma versão que força a geração da lista completa:
var answers = (from n in ParallelEnumerable.Range(0, 500) where n.SomeTest() orderby n.ToString().Length select n.SomeProjection()) .Skip(20).Take(20);
O que acontece aqui? O framework usa todos os processadores para gerar todas as alternativas o mais rápido possível. Depois, Stop And Go para o Order By. Por fim, sequência para Skip e Take. É realemente muito bom esse PLINQ.
PLINQ tenta criar a melhor implementação para as consultas que escrevemos de forma a gerar os resultados com o menor consumo possível de recursos. Algumas vezes, isso significa que agir mais como LINQ to Objects, requisitando entradas uma por uma. Em outros casos, se comportando mais como EF em que a requisição do primeiro item gera toda a sequência. Por fim, há casos onde um “mix” das duas abordagens é executado. Entender os conceitos que apresentei aqui hoje, permitem que escrevamos consultas mais eficientes.
PLINQ é um framework muito rico. Há varias possibilidades de tuning para as consultas. Embora não possamos determinar diretamente o funcionamento de nossas consultas, podemos, assim como ocorre em SQL, dar dicas para o “analisador” sobre qual seria o plano mais adequado para nossa consulta.
PLINQ torna computação paralela muito mais fácil do que era antes de seu advento. Além disso, é importante que tenhamos consciência de que a importância da computação paralela tende a aumentar nos próximos anos. Mas, a coisa continua não sendo fácil. Nossa tarefa, enquanto desenvolvedores é olhar para nossos loops e tentar determinar o que podemos paralelizar. Tentar implementar sempre versões “paralelizáveis” de nossos algorítmos. Medir resultados..
Por hoje, era isso ![]()
Pingback: Como se fosse a primeira vez… « Elemar DEV
Muito bom o artigo !! Parabéns. Se puder poderia escrever uma série introdutória sobre o plinq ?
[]‘s
Excelente!!! Meus parabéns! Artigo simplesmente Fantástico!
Obrigado
Realmente muito bom o artigo..