Cada núcleo de processamento é também conhecido como core que na verdade é um processador à parte, podendo haver dois, quatro, seis e até oito dessas cores em um único chip de CPU. Este avanço incentivou o desenvolvimento da tecnologia multithread que torna o fluxo de execução de uma aplicação mais eficiente e dinâmico.
O objetivo deste artigo é apresentar o ambiente multithreading, suas qualidades, os problemas que podem advir através do seu uso e as ferramentas que o .NET fornece, juntamente com a linguagem C#, para a criação de aplicações que possam proporcionar uma nova experiência ao usuário.
Para que você possa entender o que é multithread, imagine uma montadora de automóveis. Agora imagine que nesta montadora haja somente um funcionário que seja responsável por planejar os custos, projetar o automóvel, organizar as peças, montar as peças, pintar o automóvel, instalar o motor, realizar a bateria de testes do automóvel e enviar o automóvel para a concessionária.
Quantos automóveis você acha que seriam produzidos por dia? Melhor, por ano? Com certeza não seriam muitos. Através desse cenário de produção surreal é que pode se fazer uma analogia com os processos de software que todos os dias são processados pela CPU.
Mas o que são processos e como são formados? Para que você possa entender melhor como funciona as threads, será importante abordar alguns conceitos básicos de processos e threads.
Processos e threads
Processo é um dos conceitos mais importantes de qualquer sistema operacional, pois representam programas em execução. Eles possuem um código executável, pilha de execução, estado de processo e prioridade do processo que é definida pelo sistema operacional.
Possuem também um valor no registrador Program Counter (PC) e um valor do apontador de pilha Stack Pointer (SP). Com estes recursos, o sistema operacional realiza operações concorrentes entre os processos realizando uma espécie de pseudoparalelismo, onde alterna entre os processos ativos, executando partes de cada um em um intervalo de tempo definido pelas prioridades de cada processo, sendo esta troca denominada troca de contexto ou context switching.
O sistema operacional não se perde porque possui endereços de cada intervalo de execução e dos próximos intervalos que serão executados, armazenados pelo registrador da CPU Program Counter. Os processos também podem ser classificados de acordo com o tipo de tarefa que realizam:
· Processos CPU-bound: Processos CPU-bound passam a maior parte do tempo utilizando recursos do processador para a execução de suas tarefas. Aplicações que realizam muitos cálculos por segundo como jogos ou softwares de informação geográfica precisam de maior poder de processamento e consequentemente possuem maiores prioridades de processamento;
· Processos I/O-bound: Processos I/O-bound não precisam de tanta atenção do processador como os processos CPU-bound, porque utilizam mais o disco em suas tarefas de gravação e leitura de dados.
Geralmente estes processos são bem mais lentos que os processos CPU-bound porque não dependem exclusivamente da CPU, tendo seu gargalo de desempenho nos discos. A tendência é que quanto maior for a velocidade do processador, mais gargalos de processos I/O irão surgir, pois o processador precisará aguardar o disco terminar a maior parte do trabalho para que o processo continue.
Essa diferença de desempenho é mostrada na Figura 1.
As threads são bem parecidas com os processos, mas possuem entre elas diferenças fundamentais. Cada processo serial (que não possui paralelismo) possui seu próprio espaço de endereçamento definido pelo S.O, tendo somente uma thread principal.
Porém há ocasiões em que um processo é custoso e encarregar somente uma thread de todo o trabalho acarreta em percas significantes de desempenho e eficiência do processador, portanto com a criação de múltiplas threads no mesmo processo (possuindo o mesmo espaço de endereçamento) é possível distribuir responsabilidades e partes de processamento do processo a cada thread e a mesma irá executar em paralelo sua parte.
A Figura 2 ilustra as diferenças entre aplicações seriais e multithread.
Ao final todo o trabalho realizado é reunido por cada thread e entregue como um só processo, sendo este procedimento chamado de fork-join, como mostrado na Figura 3.
Se voltarmos ao primeiro exemplo dado de um cenário de produção, fica evidente que seria necessária mais mão de obra em uma linha de produção para que mais carros fossem produzidos e entregues. Se transformarmos este cenário de produção em um processo de software, podemos afirmar que precisaríamos de mais threads para que o processo fosse feito mais rapidamente, por exemplo cinco pessoas pintariam os carros e 10 soldariam as peças, criando um ambiente multithread (fork). Ao final um resultado único seria entregue que é o automóvel pronto em si (join).
Multithreading em .NET
A partir da versão 4.0 do .NET, novos recursos de programação paralela e multithreading como a TPL (Task Parallel Library) representada principalmente pela classe Task e as cláusulas async e await (inseridas na versão 4.5), tornaram a programação paralela bem mais fácil e rápida. Neste artigo serão abordadas as principais classes de programação em threads e suas funcionalidades.
Serão mostradas as classes Thread, ThreadPool e Task para programação em threads. Também serão tratadas as funcionalidades PLINQ (Parallel Language Query), as cláusulas async e await para responsividade de aplicações.
Por último alguns recursos avançados de programação paralela devem ser abordados também, como semáforos, mutexes, monitors e coleções concorrentes.
Primeiro exemplo – A classe Thread
Para o nosso primeiro exemplo utilizando a linguagem C#, será mostrado um código que realiza uma tarefa entre três threads.
A thread main é a thread onde é executado o método de entrada Main, padrão na maioria das linguagens descendentes do C. Após são criadas duas threads; a primeira thread ficará responsável por apenas realizar exibir a mensagem: “Thread rodando”, onde será mostrado o seu identificador, porém esta mensagem será exibida em um loop infinito.
Para cancelá-la, uma segunda thread ficará responsável por interromper sua execução se o usuário digitar ‘C’. O código desse exemplo pode ser visto na Listagem 1.
Listagem 1. Primeiro exemplo utilizando a classe Thread
01 bool stop = false;
02
03 Thread thread = new Thread(() =>
04 {
05 while (!stop)
06 {
07 Console.WriteLine("Thread {0} rodando...", Thread.CurrentThread.ManagedThreadId);
08 Thread.Sleep(500);
09 }
10 Console.WriteLine("Thread {0} cancelada.", Thread.CurrentThread.ManagedThreadId);
11 });
12
13 var thread2 = new Thread((id) =>
14 {
15 var key = Console.ReadKey(true);
16 if (key.Key == ConsoleKey.C)
17 {
18 stop = true;
19 Console.WriteLine("Thread {0} cancelou a execucao da thread {1}",
20 Thread.CurrentThread.ManagedThreadId, (int) id);
21 }
22 });
23
24 thread.Start();
25 thread2.Start(thread.ManagedThreadId);
26
27 for (int i = 0; i< 100; i++)
28 {
29 Console.WriteLine("Main thread rodando...");
30 Thread.Sleep(500);
31 }
32
33 thread.Join();
34 thread2.Join();
Na linha 1 é declarada uma variável booleana stop que ficará responsável por terminar a execução do código executado pela thread1. A thread1 é declarada nas linhas 3 a 11. A classe Thread recebe como parâmetro do construtor, um delegate do tipo ThreadStart.
O delegate ThreadStart é uma referência de um método que não recebe valores como parâmetro e retorna um tipo void. A notação lambda é utilizada para escrever um método anônimo, que é representada pelo símbolo => (goes to), que indica ao compilador que o corpo do método está sendo iniciado.
Ao lado esquerdo são declarados os parâmetros do método anônimo e a direita é escrito o código do método. O método anônimo é como se fosse um método comum, porém não possui nome.
Na linha 5, o bloco while é controlado pela variável stop. Enquanto a mesma for falsa, este código será executado.
Mas onde que o valor de stop é alterado? Nas linhas 13 a 22 é declarada uma segunda thread, a thread2 que será responsável por cancelar a thread1.
Ela irá captar as teclas digitadas pelo usuário e irá testar se é a tecla C (linhas 15 e 16). Se for a tecla correta, a thread1 é cancelada, juntamente com a thread2, exibindo uma mensagem que mostra os ID’s da thread cancelada e da thread que cancelou.
Nas linhas 24 e 25 são iniciadas as threads. A thread2 recebe como argumento do método Start o ID da thread1 que será cancelada ao usuário pressionar C; este argumento é recebido pelo parâmetro id declarado na lista de argumentos do método anônimo da linha 13, que exibirá na mensagem o ID da thread cancelada.
Nas linhas 33 e 34 são invocados os métodos Join das respectivas threads, mas qual é a sua finalidade? Lembra-se da Figura 3? É aqui que o fork das execuções das threads são unidos à thread main. Se estas linhas fossem comentadas, havia a possibilidade de a thread main não esperar o fim das execuções das outras threads, sendo que a aplicação poderia terminar precocemente.
Aproveitando este código, pode-se mencionar algumas outras características do ambiente multithreading:
· Cada thread criada neste exemplo recebe um identificador e uma prioridade. Tal prioridade pode ser alterada pela propriedade Priority e o identificador recuperado pela propriedade ManagedThreadId;
· A thread não é iniciada automaticamente, mas cabe ao desenvolvedor iniciar sua execução;
· Ao ser criada, a thread é colocada em uma fila de execução de threads;
· Ao terminar o tempo de execução, o S.O. suspende a execução da thread, a coloca no final da fila de execuções e executa a próxima thread da fila (context switching);
·
Quando
uma thread deve esperar alguma ta ...