C# - Apresentando a programação paralela - Task Parallel Library

 Hoje vou apresentar os conceitos básicos da programação paralela na linguagem C#.

Tanto a programação assíncrona quanto a paralela não são novas na plataforma .NET.

O modelo da programação assíncrona ou Asynchronous Programming Model (APM) é o modelo mais antigo e está disponível desde a versão 1.0.

Já o modelo de programação em paralelo ou Task Parallel Library (TPL) foi introduzido na versão 4.0 da plataforma .NET

Como uma forma de tentar melhorar o primeiro modelo.  Assim a TPL é uma grande melhoria em relação aos modelos anteriores, ela simplifica o processamento paralelo e faz melhor uso dos recursos do sistema e com a TPL podemos implementar a Programação Paralela em C# .NET de maneira muito fácil.

Na versão 5.0 da linguagem C# foram introduzidas as palavras async e await de forma a facilitar a programação assíncrona, e, usando async e await você pode escrever o código assíncrono da mesma maneira que escreve o seu código síncrono e o compilador cuida de toda a complexidade e libera você para fazer escrever o seu código.

A primeira coisa a fazer é destacar a diferença entre Programação Paralela e Programação Assíncrona, elas possuem conceitos relacionados mas não são a mesma coisa.

Programação paralela

A programação paralela é usada para dividir uma tarefa em diversas partes e executar estas partes de forma simultânea.

Como exemplo você pode cantar e tomar banho ao mesmo tempo e assim esta realizando estas tarefas em paralelo.

Além disso podemos ter dois tipos de paralelismo :

  1. Paralelismo de dados -  Onde temos uma coleção de valores e desejamos usar a mesma operação em cada um dos elementos da coleção.  Ex: Filtrar os elementos de um array
     
  2. Paralelismo de tarefas - Onde Temos um conjunto de tarefas independentes que desejamos realizar em paralelo. Ex: Enviar um email e um arquivo de texto ao usuário

Programação assíncrona

A programação assíncrona permite gerenciar as threads dos processos de forma eficiente evitando que uma thread seja bloqueada enquanto aguarda o processamento de outra thread

Aqui temos dois conceitos importantes : threads e processos.

Os processos significam qualquer programa em execução e as threads são linhas ou sequências de execução de código que podem ser executadas independentemente umas das outras.  Dessa forma uma thread é a menor unidade de tarefa que pode ser executada por um sistema operacional e é um segmento de um processo enquanto que o processo pode ter múltiplas threads.

A programação assíncrona ocorre quando uma tarefa é executada, e você pode alternar para uma tarefa diferente sem esperar que a tarefa atual seja concluída e você faz isso sem interromper ou bloquear a tarefa.

Imagine que você tivesse que fazer um lanche e lavar suas roupas na máquina de lavar.  Você poderia colocar suas roupas na máquina de lavar e, sem esperar essa tarefa ser concluída, você poderia ir fazer o lanche.  Aqui, você executou essas duas tarefas de forma assíncrona.

Dessa forma a programação assíncrona nos ajuda a alcançar a concorrência.

Task Parallel Library ou TPL

A Task Parallel Library (TPL) é um conjunto de tipos públicos e APIs presentes nos namespaces System.Threading e System.Threading.Tasks.

Para realizar tarefas simples usando programação paralela basta usar o método Invoke da classe Parallel do namespace System.Threading passando uma instância do delegate Action para cada método que desejamos executar.

Sintaxe : Parallel.Invoke( Action[])

Assim basta fornecer um conjunto de delegates Action, cada um dos quais envolve um método que você deseja executar.

No exemplo a seguir usamos o método Invoke para para processar os métodos ExibirDias e ExibirMeses em paralelo :

System.Threading

...

Parallel.Invoke(

   new Action(exibirDias),

   new Action(exibirMeses)

);

...

Para realiza iterações em paralelo temos dois métodos disponíveis : Parallel.For e Parallel.Foreach.

Usando o Parallel.For

Este método executa um loop for no qual as iterações podem ser executadas em paralelo.

Exemplo:

System.Threading.Tasks

...
 Parallel.For(0, 10, i => Console.WriteLine(i + "\t"));

...

Nesta sobrecarga do método Parallel.For estamos usando um valor inteiro inicial inclusivo (0) , um valor inteiro final exclusivo (10) e uma Action que representa o código a ser executado em paralelo.

Este código é equivalente a um laço for:   for (int i = 0; i < 10; i++) {}

No código abaixo temos um exemplo que compara a execução deste dois tipos de laço for:

using System;
using System.Threading;
using System.Threading.Tasks;
namespace ParallelFor
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Pressione ENTER para iniciar");
            Console.ReadLine();
            ProcessarLaco();
            Console.WriteLine("\n\n");
            Parallel.For(0, 11, i => Console.WriteLine(i + "\t"));
            Console.ReadLine();
        }
        private static void ProcessarLaco()
        {
            for (int i = 0; i < 11; i++)
            {
                Console.WriteLine(i + " Thread : = " +
                    Thread.CurrentThread.ManagedThreadId + "\t");
                Thread.Sleep(500);
            }
        }
    }
}

Usando o Parallel.Foreach

O método Parallel.Foreach executa uma operação foreach no qual as iterações podem ser executadas em paralelo.

Assim vamos supor que temos uma lista de strings com nomes de frutas :

List<string> frutas = new List<string>();
  frutas.Add("Maça");
  frutas.Add("Banana");
  frutas.Add("Abacaxi");
  frutas.Add("Melancia");
  frutas.Add("Pêra");
  frutas.Add("Uva");
  frutas.Add("Figo");

Para percorrer de forma síncrona esta lista podemos usar um laço foreach na abordagem tradicional da linguagem C# :

foreach (string fruta in frutas)
{
   WriteLine($"Fruta : {fruta}, Thread Id= {Thread.CurrentThread.ManagedThreadId} \t");
}

Aqui, neste iterador foreach, apenas uma thread será usada para cada um dos itens a ser processado, então enquanto o primeiro item está sendo processado, os outros estão aguardando.

Se tivermos um computador com um processador com 5 threads disponíveis podemos acelerar o processando usando o método  Parallel.Foreach :

Parallel.ForEach(frutas, fruta =>
  {
     WriteLine($"Fruta : {fruta}, Thread Id= {Thread.CurrentThread.ManagedThreadId} \t");
  }
);

Neste código o foreach do Parallel descobre e utiliza todos as threads disponíveis do seu processador para que a ação seja executada paralelamente, então cada thread vai processar um item da lista ao mesmo tempo.

Considerando que levaria 1 minuto para cada processamento, o foreach padrão levaria um total de 4 minutos, enquanto que usando Parallel.ForEach levaria 1 minuto considerando 4 itens e um processador com 4 threads.

Veja o código completo:

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using static System.Console;
namespace ParallelForeach
{
    internal class Program
    {
        private static void Main(string[] args)
        {
            List<string> frutas = new List<string>();
            frutas.Add("Maça");
            frutas.Add("Banana");
            frutas.Add("Abacaxi");
            frutas.Add("Melancia");
            frutas.Add("Pêra");
            frutas.Add("Uva");
            frutas.Add("Figo");
            frutas.Add("Pêssego");
            frutas.Add("Laranja");
            frutas.Add("Kiwi");
            frutas.Add("Manga");
            frutas.Add("Melão");
            frutas.Add("Morango");
            Console.WriteLine("Pressione ENTER para iniciar");
            Console.ReadLine();
            WriteLine("Exibindo a lista usando laço foreach \n");
            foreach (string fruta in frutas)
            {
                WriteLine($"Fruta : {fruta}, Thread Id= {Thread.CurrentThread.ManagedThreadId} \t");
            }
            WriteLine("\n\nExibindo a lista usando Parallel.ForEach");
            Parallel.ForEach(frutas, fruta =>
            {
                WriteLine($"Fruta : {fruta}, Thread Id= {Thread.CurrentThread.ManagedThreadId} \t");
            }
           );
            Read();
        }
    }
}

Agora, tanto o Parallel.For como o Parallel.ForEach possuem várias sobrecargas que permitem parar ou interromper a execução do loop,
monitorar o estado do circuito em outros segmentos, manter o estado de segmento local, finalizar objetos na thread-local, controlar o grau de concorrência, e assim por diante.

Dessa forma a programação paralela fornece um grande poder de processamento mas devemos ter cautela ao usar esse recurso.

Quando usar e quando não usar a programação paralela

Geralmente devemos usar este recurso quando tivermos muitas operações que dependam de processamento de CPU (CPU-Bound)  e que possam ser executadas de maneira paralela quando houver mais de uma ocorrência de item a ser processado.

Mas utilizar o Parallel.ForEach ou Parallel.For em operações/iterações simples que não utilizam muitos recursos não garantia de ser mais rápido do que um foreach padrão. 

Na verdade o custo de alocar threads da maneira que a classe Parallel faz pode causar uma "overhead" que pode deixar seu código mais lento. Então o "peso" da operação a ser executada dentro do foreach é o que vai ditar se compensa ou não a utilização da forma paralela.

Outro detalhe importante é que a programação paralela não é indicada para o ambiente web.

Como no ambiente web temos um ambiente de alta demanda onde cada thread tratar um request não seria recomendável ocupar várias threads para processar uma única requisição.

No próximo artigo veremos como executar Task usando TaskFactory.StartNew e usar os métodos Wait, WaitAll e WatiAny.

"Bendito o Deus e Pai de nosso Senhor Jesus Cristo, o qual nos abençoou com todas as bênçãos espirituais nos lugares celestiais em Cristo;  Como também nos elegeu nele antes da fundação do mundo, para que fôssemos santos e irrepreensíveis diante dele em amor;"
Efésios 1:3,4

Referências:


José Carlos Macoratti