Programação Paralela com C#

Por Bruno Sonnino

Os processadores com múltiplos núcleos (cores) estão no mercado há muitos anos e, atualmente, estão disponíveis na maioria dos dispositivos. Entretanto, muitos desenvolvedores continuam a fazer o que sempre fizeram: criar programas que usam uma única thread. Isto faz com que eles não usem todo o poder de processamento extra disponível na máquina. Imagine que você tem diversas tarefas a fazer e muitas pessoas para fazê-las, mas está usando apenas uma pessoa pois não sabe como solicitar mais que uma. Isto é realmente ineficiente, não é?. Os usuários estão pagando pelo processamento extra, mas o seu software não permite que eles usem-no.

Processamento de múltiplas threads não é novidade para desenvolvedores C# experientes, mas nem sempre é fácil desenvolver programas que usem todo o poder de processamento. Este artigo mostra a evolução do processamento paralelo em C# e como usar o novo paradigma Async, introduzido na versão 5.0 do C#.

O que é Programação Paralela?

Antes de falar sobre programação paralela, deixe-me explicar dois conceitos diretamente ligados a ela: execução síncrona e assíncrona. Estes modos de execução são importantes para aprimorar a performance de suas apps. Quando você executa uma operação em modo síncrono, o programa executa todas as tarefas em sequência, como mostrado na Figura 1. Você dispara a execução de cada tarefa e espera até ela terminar antes de disparar a próxima.


Figura 1. Execução síncrona

Ao executar de modo assíncrono, o programa não executa todas as tarefas em sequência: ele dispara as tarefas e espera que sejam finalizadas, como mostra a Figura 2.


Figure 2. Execução assíncrona

Se a execução assíncrona leva menos tempo total para executar que a execução síncrona, por que alguém iria escolher a execução síncrona? Bem, como você pode ver na Figura 1, cada tarefa executa em sequência, assim é mais fácil de programar. Esta é a maneira que você vem fazendo há anos. Com programação assíncrona, você tem alguns desafios:

  • Você deve sincronizar tarefas. Imagine que na Figura 2 você deve ter uma tarefa que deve ser executada depois que as outras três terminaram. Você deve criar um mecanismo para esperar que todas as tarefas terminem antes de executar a nova tarefa.
  • Você deve resolver problemas de concorrência. Se você tem um recurso compartilhado, como uma lista que é escrita em uma tarefa e lida em outra, você deve ter certeza que ela será mantida em um estado conhecido.
  • A lógica de programação é completamente bagunçada. Não há mais sequência lógica. As tarefas podem terminar em qualquer hora e você não tem mais controle de qual termina primeiro.

Por outro lado, a programação síncrona tem as seguintes desvantagens:

  • Ela leva mais tempo para terminar.
  • Ela pode interromper a execução da interface do usuário (UI). Na maioria das vezes, estes programas têm apenas thread para a UI e, quando você executa uma tarefa que bloqueia a execução, você tem o cursor de espera do Windows (e a mensagem “Não Respondendo” na barra de título) em seu programa – com certeza, não é a melhor experiência para seus usuários.
  • Ela não usa a arquitetura de múltiplos núcleos dos novos processadores. Não importa se seu programa executa em um processador com 1 núcleo ou com 64 núcleos, ele irá executar com a mesma velocidade em ambos.

A programação assíncrona elimina estas desvantagens: ela não vai travar a execução da UI (pois ela pode rodar como uma tarefa em segundo plano) e pode usar todos os núcleos de sua máquina, fazendo melhor uso dos seus recursos. Assim, você deve escolher programação mais fácil ou melhor utilização dos recursos? Felizmente, você não precisa tomar esta decisão, pois a Microsoft criou diversas maneiras de minimizar as dificuldades da programação assíncrona.

Modelos de Programação Paralela em Microsoft .NET*

Programação assíncrona não é novidade no Microsoft .NET: ela está aí desde a primeira versão, em 2001. A partir daí ela evoluiu, facilitando o uso deste paradigma pelos desenvolvedores. O Asynchronous Programming Model (APM – Modelo de Programação Assíncrona) é o modelo mais velho em .NET e está disponível desde a versão 1.0. Pelo fato de ser difícil de ser implementado, a Microsoft introduziu um novo modelo no .NET 2.0: o Event-Based Asynchronous Pattern (EAP – Padrão Assíncrono Baseado a Eventos). Não vou discutir estes modelos, mas verifique os links na seção “Para Mais Informações”, se houver interesse. O EAP simplificou as coisas, mas não foi suficiente. Assim, no >NET 4.0, a Microsoft implementou um novo modelo: a Task Parallel Library (TPL – Biblioteca de Tarefas Paralelas).

A Task Parallel Library

A TPL é um enorme avanço em relação aos modelos anteriores. Ela simplifica o processamento paralelo e faz melhor uso dos recursos do sistema. Se você precisa usar processamento paralelo em seus programas, a TPL é o caminho.

Para efeito de comparação, eu vou criar um programa síncrono que calcula os números primos ente 2 e 10.000.000. O programa mostra quantos números primos ele pode encontrar e o tempo requerido para isso:

GitHub - Exemplo de código de programa síncrono

Este não é o melhor algoritmo para encontrar números primos mas pode mostrar as diferenças entre as diferentes abordagens. Em minha máquina (com um processador Intel® Core™ i7 3.4 GHz), este programa executa em aproximadamente 3 segundos. Usarei o Intel® VTune™ Amplifier para analisar a execução. Este é um programa pago, mas uma versão de testes por 30 dias está disponível (veja a seção “Para Mais Informações” para um link).

Quando executo a análise Basic Hotspots na versão síncrona do programa, obtenho os resultados da Figura 3.


Figura 3. Análise do VTune™ para a versão síncrona do programa que encontra números primos

Aqui você pode ver que o programa levou 3.369 segundos para executar, a maior parte gasta no método IsPrimeNumber (3.127s) e usa apenas uma CPU. Este programa não faz bom uso dos recursos.

ATPL introduz o conceito de Task, que representa uma operação assíncrona. Com a TPL, você pode criar Tasks implicitamente ou explicitamente. Para criar uma Task implicitamente você pode usar a classe Parallel – uma classe estática que tem os métodos For, ForEach e Invoke. For e ForEach permitem que loops sejam executados em paralelo; Invoke permite enfileirar diversas ações em paralelo.

Esta classe facilita a conversão da versão síncrona do programa em uma versão paralela:

GitHub – Exemplo de código de programa paralela

O processamento é quebrado em 10 partes e usei o Parallel.For para executar cada parte. Ao final do processamento, as quantidades de primos encontradas em cada parte são somadas e mostradas. O código é muito semelhante à versão síncrona. Ao analisar com o VTune Amplifier, obtenho os resultados da Figura 4:


Figura 4. Análise do VTune para a versão paralela do programa que encontra números primos

O programa executa em 1 segundo e os oito processadores da máquina são usados. Aqui tenho o melhor dos dois mundos: uso eficiente dos recursos e facilidade de uso.

Você também poderia criar as Tasks explicitamente e usá-las no programa com a classe Task. Você pode criar uma nova Task e usar o método Start para iniciar a execução ou então usar os métodos Task.Run ou Task.Factory.StartNew, que criam e executam uma Task em um único passo. Você pode criar um programa semelhante ao programa paralelo, usando a classe Task com um programa como esse:

GitHub – Exemplo de código utilizando a classe Task

Task.WaitAll espera todas as Tasks acabarem. O programa só continua quando isso acontecer. Se você analisar a execução com o VTune Amplifier, obterá um resultado semelhante à versão paralela.

Parallel Linq

Parallel Linq (PLINQ) é uma implementação paralela da linguagem de query LINQ. Com o PLINQ você pode transformar suas queries LINQ em versões paralelas simplesmente usando o método de extensão AsParallel. Por exemplo esta mudança simples na versão síncrona melhora muito a performance:

GitHub – Exemplo de código utilizando PLINQ

Adicionando AsParallel a Enumerable.Range muda a versão sequencial para uma versão paralela da query. Se você executar esta versão, verá uma grande melhoria em VTune Amplifier (Figura 5).


Figura 5. Análise do VTune para a versão PLINQ

Com esta mudança simples, o programa executa em 1 segundo e usa todos os oito processadores. Entretanto, há um porém: a posição de AsParallel interfere no paralelismo da operação. Se você mudar a linha para:

return Enumerable.Range(minimum, count).Where(IsPrimeNumber).AsParallel().ToList();

... você não verá nenhuma melhoria pois o método IsPrimeNumber, que usa a maior parte do tempo de execução não será executado em paralelo.

Programação Async

O C# versão 5.0 introduziu duas novas palavras-chave: async e await. Pode não parecer muito, mas esta inclusão é um enorme avanço. Estas palavras-chave são muito importantes para o processamento assíncrono em C#. Quando você usa processamento paralelo, algumas vezes você deve embaralhar a sequência de processamento completamente. O Processamento Async restaura a sequência do seu código.

Quando você usa a palavra-chave async, você pode escrever seu código da mesma maneira que escreve código síncrono. O compilador se encarrega de toda a complexidade e libera você para o que você faz melhor: escrever a lógica do programa.

Para escrever um método async você deve seguir estas diretrizes:

  • A assinatura do método deve ter a palavra-chave async.
  • Por convenção, no nome do método deve terminar com Async (isto não é requerido, mas é uma boa prática).
  • O método deve retornar Task, Task<T> ou void.

Para usar este método, você deve esperar seu retorno usando a palavra-chave await. Seguindo estas diretrizes, quando o compilador encontra um método que pode ser “awaited”, ele inicia sua execução em segundo plano e continua a execução de outras tarefas. Quando o método está completo, a execução retorna na instrução seguinte à chamada do método. O programa para calcular os números primos fica assim:

GitHub – Exemplo de código utilizando Async

Note que eu criei um novo método: ProcessPrimesAsync. Quando você usa await em um método, ele deve ser marcado como async, e a função Main não pode ser marcada como async. Por isso eu criei este método, que retorna void. Quando Main executa este método, sem a palavra-chave await, ele inicia a execução mas não espera terminar. Assim, adicionando Console.ReadLine eu evito que o programa termine antes de executar a contagem dos números primos. O restante do programa é semelhante à versão síncrona.

A variável primes não é um Task<List<int>>, como você poderia imaginar à primeira vista (este é o tipo de retorno da função GetPrimeNumbersAsync), mas é um List<int>. Este é um truque do compilador para que você não tenha que manipular Task ao chamar um método async. Com a palavra-chave await, o compilador chama o método, libera os recursos até que o método esteja terminado e, quando o método retorna, transforma o resultado de Task<List<int>> em <List<int>. Você não deve retornar Task<T> no método async, e sim o valor normal, como em um método síncrono.

Se você executar o programa, verá que ele não executa mais rápido que a versão síncrona pois ele tem apenas uma Task. Para fazer com que execute mais rápido, devemos criar múltiplas Tasks e sincroniza-las. Você pode fazer isso com esta mudança:

GitHub – Exemplo de código utilizando Async paralelamente

Com este novo método, o programa cria 10 Tasks mas não espera por elas. Ele espera nesta linha:

var results = await Task.WhenAll(primes);

A variável results é uma matriz de List. Não precisamos mais usar Task aqui. Quando executo esta versão no VTune Amplifier, ele mostra que todas as Tasks rodam em paralelo (veja a Figura 6).


Figura 6. Análise do VTune para a versão Paralela Async do programa

As palavras-chave async e await trazem uma grande melhoria ao processamento assíncrono em C#, mas esta mudança envolve mais que eu mostrei neste artigo. Você pode também cancelar tarefas, mostrar progresso da execução, gerenciar as exceções de código e coordenar tarefas.

Conclusões

Há muitas maneiras de criar um programa que executa em paralelo com C#. Com processadores de múltiplos núcleos, não há desculpas para criar programas com apenas uma linha de execução: você não estará usando os recursos do sistema e penalizará seus usuários com atrasos desnecessários.

Os aprimoramentos da linguagem C# com as palavras-chaves async e await restauram a ordem sequencial do código, enquanto usam os recursos do sistema de maneira eficiente. Ainda há alguns pontos a se preocupar, como concorrência ou sincronização de tarefas, mas estes são menores, comparado ao trabalho necessário para a criação de um bom programa que usa processamento paralelo. Se você aprender e usar as técnicas descritas aqui e iniciar a criação de programas paralelos, fará melhor uso dos recursos do sistema e terá usuários mais contentes.

Para Mais Informações

Sobre o Autor

Bruno Sonnino é um Microsoft Most Valuable Professional (MVP) no Brasil. Ele é desenvolvedor, consultor e autor, tendo escrito cinco livros de Delphi publicados pela Pearson Education Brazil e muitos artigos para revistas e websites brasileiros e americanos.

有关编译器优化的更完整信息,请参阅优化通知