Programação Paralela com Java

Veja nesse artigo como é possível desenvolver um aplicativo Java que crie Threads, agilizando o processamento e otimizando o desempenho da CPU utilizando todos seus núcleos.

Nos computadores, o processamento paralelo é o processamento de instruções do programa, dividindo-as entre vários processadores/núcleos com o objetivo de executar um programa em menos tempo, conforme mostra a Figura 1.

Figura 1. Exemplo de distribuição de dados entre os núcleos

Nos primeiros computadores, apenas um programa executava de cada vez. A tecnologia evoluiu permitindo que vários programas executassem “ao mesmo tempo”. Mas isso somente se tornou possível devido ao avanço dos Sistemas Operacionais que passaram a escalonar as tarefas, decidindo quem e quando receberia um tempo para executar algo na CPU. Este processo ocorre tão rápido que aos nossos olhos parece que tudo roda ao mesmo tempo, mas é impossível se estamos trabalhando em um ambiente com apenas um processador ou núcleo.

As Threads são segmentos de execução de um programa. A Máquina virtual Java (JVM) permite que um aplicativo possa ter várias threads em execução simultânea. Elas são um segmento independente da execução de um programa, podendo ser executadas simultaneamente, ou de forma assíncrona ou síncrona, como podemos ver na Figura 2.

Figura 2. Divisão de tarefas entre as CPUs

Com multithreading adquirimos vários benefícios, tais como:

Existem basicamente duas maneiras de se criar threads em Java:

Na Listagem 1 está um exemplo de como criar uma Thread para que execute uma sub tarefa em paralelo.

new Thread(new Runnable() { @Override public void run() { //aqui seu codigo para executar em paralelo } }).start();
Listagem 1. Exemplo de thread implementando a interface Runnable

Estendendo a classe Thread

Na programação Java, threads são instâncias da classe Thread, ou de alguma outra que a estendeu. Além de serem objetos, threads Java podem executar códigos. Ou seja, assim como na Listagem 1, para criarmos uma nova thread, basta criar um novo objeto da classe Thread (new Thread) e chamar o método start() deste objeto.

A forma clássica de se criar uma thread é estendendo a classe Thread, assim como na Listagem 2, onde temos a classe Tarefa que estende a classe Thread, que por sua vez implementa a interface Runnable. Neste caso sobrescrevemos o método run da nova classe, que fica encarregado de executar nossa tarefa.

public class Tarefa extends Thread { private final long valorInicial; private final long valorFinal; private long total = 0; //método construtor que receberá os parametros da tarefa; public Tarefa(int valorInicial, int valorFinal) { this.valorInicial = valorInicial; this.valorFinal = valorFinal; } //método que retorna o total calculado public long getTotal() { return total; } /* Este método se faz necessário para que possamos dar start() na Thread e iniciar a tarefa em paralelo */ @Override public void run() { for (long i = valorInicial; i <= valorFinal; i++) { total += i; } } }
Listagem 2. Classe Tarefa estendendo a classe Thread

Para testarmos o paralelismo em nosso programa, criamos a classe ExemploUso com o método main para executar o programa, conforme a Listagem 3. Por uma questão de melhor identificação, toda Thread tem um nome, mesmo não sendo fornecido.

public class ExemploUso { public static void main(String[] args) { //cria 3 tarefas Tarefa t1 = new Tarefa(0, 1000); t1.setName("Tarefa1"); Tarefa t2 = new Tarefa(1001, 2000); t2.setName("Tarefa2"); Tarefa t3 = new Tarefa(2001, 3000); t3.setName("Tarefa3"); //inicia a execução paralela das 3 tarefas, iniciando 3 novas threads no programa t1.start(); t2.start(); t3.start(); //aguarda a finalização das tarefas try { t1.join(); t2.join(); t3.join(); } catch (InterruptedException ex) { ex.printStackTrace(); } //Exibimos o somatório dos totalizadores de cada Thread System.out.println("Total: " + (t1.getTotal() + t2.getTotal() + t3.getTotal())); } }
Listagem 3. Classe ExemploUso

Em resumo, este programa realiza o somatório de todos os valores em um determinado intervalo. Em um programa sequencial, se inicia no primeiro valor e a mesma thread vai até seu último valor.

Neste caso onde usamos multithreading, criamos três threads, subdividindo as tarefas, onde cada uma recebe um intervalo de valores para somar. No final cada thread fornece seu somatório para totalizar com as somas das outras threads. Depois de darmos início às threads, temos que esperar as mesmas terminarem seu processamento para depois pegarmos o total de cada uma. Isso é possível através da função join() da thread. A ideia disto é “Dividir para conquistar”.

Este exemplo foi uma forma simples e prática de paralelizarmos tarefas em um programa, mas de forma estática, pois a quantidade de threads está prefixada. Seguindo a teoria da programação concorrente, podemos tornar tudo isso dinâmico, criando a quantidade de threads ideal para o número de núcleos de processamento existentes no ambiente no qual está sendo executado o programa. O código a seguir mostra como conseguir esta informação:

int nucleos = Runtime.getRuntime().availableProcessors();

Isto é essencial quando estamos falando em otimizar o desempenho, pois precisamos aproveitar ao máximo os recursos de processamento disponíveis. Criar menos threads do que a quantidade de núcleos disponíveis gera desperdício. Criar threads exageradamente a mais causa perda de performance, porque nem todas poderão executar simultaneamente.

Portanto, na Listagem 4 temos uma classe estendendo Thread que ao dar start, procura os números primos entre um determinado intervalo. Já na Listagem 5 temos a aplicação prática desta classe, lendo a quantidade de núcleos disponíveis no sistema, criando o número de threads e dividindo a tarefa entre elas, porém agora com um único intervalo de valores. Cada thread criada receberá uma faixa de valores para calcular, determinada pelo cálculo de distribuição de trabalho.

import java.util.Collection; public class CalculaPrimos extends Thread { private final int valorInicial; private final int valorFinal; private final Collection<Long> primos; public CalculaPrimos(int valorInicial, int valorFinal, Collection<Long> primos) { this.valorInicial = valorInicial; this.valorFinal = valorFinal; this.primos = primos; } //tarefa a realizar: procurar numeros primos no intervalo recebido @Override public void run() { for (long ate = valorInicial; ate <= valorFinal; ate++) { int primo = 0; for (int i = 2; i < ate; i++) { if ((ate % i) == 0) { primo++; break; } } if (primo == 0) { synchronized (primos) { primos.add(ate); } } } //ao final do trabalho printa o nome de quem terminou System.out.println(this.getName() + " terminou!"); } }
Listagem 4. Classe que calcula números primos, estendendo Thread
import java.util.ArrayList; import java.util.Collection; public class ExemploUso2 { public static void main(String[] args) { //armazena o tempo inicial long ti = System.currentTimeMillis(); //armazena a quantidade de nucleos de processamento disponiveis int numThreads = Runtime.getRuntime().availableProcessors(); //intervalo de busca predeterminado int valorInicial = 1; int valorFinal = 1000000; //lista para armazenar os numeros primos encontrados pelas threads Collection<Long> primos = new ArrayList<>(); //lista de threads Collection<CalculaPrimos> threads = new ArrayList<>(); int trabalho = valorFinal / valorInicial; //cria threads conforme a quantidade de nucleos for (int i = 1; i <= numThreads; i++) { //trab é a quantidade de valores que cada thread irá calcular int trab = Math.round(trabalho / numThreads); //calcula o valor inicial e final do intervalo de cada thread int fim = trab * i; int ini = (fim - trab) + 1; //cria a thread com a classe CalculaPrimos que estende da classe Thread CalculaPrimos thread = new CalculaPrimos(ini, fim, primos); //define um nome para a thread thread.setName("Thread "+i); threads.add(thread); } //percorre as threads criadas iniciando-as for (CalculaPrimos cp : threads) { cp.start(); } //aguarda todas as threads finalizarem o processamento for (CalculaPrimos cp : threads) { try { cp.join(); } catch (InterruptedException ex) { ex.printStackTrace(); } } //imprime os numeros primos encontrados por todas as threads for (Long primo : primos) { System.out.println(primo); } //calcula e imprime o tempo total gasto System.out.println("tempo: " + (System.currentTimeMillis() - ti)); } }
Listagem 5. Classe ExemploUso2

Um detalhe importante o qual vale a pena ressaltar é que neste exemplo cada thread recebe a mesma lista para que adicione os números primos encontrados, e isto gera a famosa concorrência de dados, onde tenho várias threads concorrendo pelo mesmo objeto. Este é um fator que se bem trabalhado nos trará bons resultados, mas do contrário, pode ser a ruína do projeto. Para evitar tais problemas de concorrência, no método run() da Listagem 5, o uso do objeto primos foi sincronizado, para que somente uma thread por vez o acesse. Claro que tal prática degrada um pouco a performance, devido a manipulação do lock que as threads devem fazer antes e depois de acessarem o objeto, mas tudo tem seu custo e este é um que vale a pena se pagar considerando o benefício obtido.

O ambiente no qual foi desenvolvido este exemplo, possui quatro núcleos e este algoritmo executou em uma média de tempo de 63 segundos, neste período, enquanto todas as threads estiverem executando, a CPU deve estar em 100% de operação. Já o algoritmo singlethread da Listagem 6 executou em 147 segundos e consumindo apenas 25% da CPU. Geralmente o tempo gasto não diminui proporcionalmente à quantidade de núcleos utilizados, variando muito conforme a tarefa a ser processada e tempo total consumido, pois este processo se mostra mais eficiente a medida em que submetemos o algoritmo a rotinas mais exaustivas.

Também é possível observar no console, conforme cada thread vai finalizando seu trabalho, ela o informa com seu nome. Da mesma maneira, também é possível observar que conforme cada thread vai finalizando, o uso da CPU vai diminuindo. Neste caso não é coincidência que as threads vão finalizando na mesma ordem que foram criadas, pois as primeiras threads tem relativamente menos trabalho que as posteriores, já que receberam números menores para calcular, logo as primeiras terminam sua tarefa antes das posteriores.

Outro fator importante a ser considerado é de que não podemos sempre contar que cada nova thread executará em um núcleo diferente. Por exemplo, temos quatro núcleos disponíveis e criamos quatro threads para fazerem um processamento oneroso, isso não quer dizer que cada uma delas necessariamente estará sendo executada por um núcleo diferente.

import java.util.ArrayList; import java.util.Collection; public class ExemploUso3 { public static void main(String[] args) { //armazena o tempo inicial long ti = System.currentTimeMillis(); int valorInicial = 1; int valorFinal = 1000000; //lista para armazenar os numeros primos encontrados pelas threads Collection<Long> primos = new ArrayList<>(); //percorre o intervalo buscano os numeros primos for (long ate = valorInicial; ate <= valorFinal; ate++) { int primo = 0; for (int i = 2; i < ate; i++) { if ((ate % i) == 0) { primo++; break; } } if (primo == 0) { synchronized (primos) { primos.add(ate); } } } //imprime os numeros primos encontrados por todas as threads for (Long primo : primos) { System.out.println(primo); } //calcula e imprime o tempo total gasto System.out.println("tempo: "+(System.currentTimeMillis()-ti)); } }
Listagem 6. Classe singlethread buscando números primos

Implementando a interface Runnable

A outra forma de criar uma Thread é implementando a interface Runnable em uma classe. Conforme a Oracle, a interface Runnable deve ser implementado por qualquer classe cujas instâncias destinam-se a ser executadas por uma Thread. A classe deve definir um método sem argumentos chamado run. Esta é a forma mais recomendada pelos profissionais Java, por ser, na maioria dos casos, mais fácil de manipular o que e aonde será executado. Além disso, esta prática torna mais fácil fixar o número de threads simultâneas, bem como a reutilização daquelas que estão inativas.

Essa interface é projetada para fornecer um protocolo comum para objetos que desejam executar código enquanto estão ativos. Por exemplo, Runnable é implementado pela classe Thread. Estar ativo significa simplesmente que uma thread foi iniciada e ainda não foi interrompida.

Além disso, Runnable fornece os meios para uma classe estar ativa sem precisar estender Thread. Uma classe que implementa Runnable pode ser executada sem estender Thread, apenas por instanciar uma Thread, passando um Runnable como parâmetro. Na maioria dos casos, a interface Runnable é indicada se você só necessita substituir o método run() e não há outros métodos da Thread. Isto é importante porque as classes não devem ser subclasses, a menos que o programador tem a intenção de modificar ou melhorar o comportamento fundamental delas.

Na Listagem 7 temos o exemplo de uma classe implementando a interface Runnable. Para não confundir a explicação, a lógica empregada é a mesma.

import java.util.Collection; public class CalculaPrimos2 implements Runnable{ private final int valorInicial; private final int valorFinal; private final Collection<Long> primos; public CalculaPrimos2(int valorInicial, int valorFinal, Collection<Long> primos) { this.valorInicial = valorInicial; this.valorFinal = valorFinal; this.primos = primos; } //tarefa a realizar: procurar numeros primos no intervalo recebido @Override public void run() { for (long ate = valorInicial; ate <= valorFinal; ate++) { int primo = 0; for (int i = 2; i < ate; i++) { if ((ate % i) == 0) { primo++; break; } } if (primo == 0) { synchronized (primos) { primos.add(ate); } } } //ao final do trabalho printa o nome de quem terminou System.out.println(Thread.currentThread().getName() + " terminou!"); } }
Listagem 7. Classe implementando a interface Runnable

O exemplo de uso também é pouco afetado, a não ser no ponto onde as threads são efetivamente criadas, conforme a Listagem 8. Agora passamos a criar as threads a partir da própria classe Thread e apenas informamos como um parâmetro nossa tarefa, que por sua vez, implementa a interface Runnable.

import java.util.ArrayList; import java.util.Collection; public class ExemploUso4 { public static void main(String[] args) { //armazena o tempo inicial long ti = System.currentTimeMillis(); //armazena a quantidade de nucleos de processamento disponiveis int numThreads = Runtime.getRuntime().availableProcessors(); //intervalo de busca predeterminado int valorInicial = 1; int valorFinal = 1000000; //lista para armazenar os numeros primos encontrados pelas threads Collection<Long> primos = new ArrayList<>(); //lista de threads Collection<Thread> threads = new ArrayList<>(); int trabalho = valorFinal/valorInicial; //cria threads conforme a quantidade de nucleos for (int i = 1; i <= numThreads; i++) { //trab é a quantidade de valores que cada thread irá calcular int trab = Math.round(trabalho / numThreads); //calcula o valor inicial e final do intervalo de cada thread int fim = trab * i; int ini = (fim - trab) + 1; //cria a thread passando por parametro um objeto da classe CalculaPrimos2 que implementa Runnable Thread thread = new Thread(new CalculaPrimos2(ini, fim, primos)); //define um nome para a thread thread.setName("Thread "+i); threads.add(thread); } //percorre as threads criadas iniciando-as for (Thread th : threads) { th.start(); } //aguarda todas as threads finalizarem o processamento for (Thread th : threads) { try { th.join(); } catch (InterruptedException ex) { ex.printStackTrace(); } } //imprime os numeros primos encontrados por todas as threads for (Long primo : primos) { System.out.println(primo); } //calcula e imprime o tempo total gasto System.out.println("tempo: "+(System.currentTimeMillis()-ti)); } }
Listagem 8. Classe ExemploUso4

O efeito dos exemplos estendendo a classe Thread ou implementando a interface Runnable é o mesmo, as diferenças consistem na aplicabilidade do código, cabendo a cada desenvolvedor escolher o qual mais se encaixa em seu contexto.

Nestes exemplos é possível observar o ganho de desempenho utilizando threads, porém existem outros casos onde o uso de threads se faz necessário, como por exemplo em interfaces gráficas Swing. Não é possível deixar um JFrame processando algum código e atualizando as informações da interface gráfica ao mesmo tempo, pois se trata de apenas uma thread que está fazendo isso. Portanto, nestas situações é necessário que se coloque o código a processar em uma outra thread para que a thread do JFrame fique livre para atualizar os componentes gráficos.

O paralelismo sem dúvidas é uma ótima prática, que está se dispersando por todos os softwares do mercado, tendo em vista que atualmente qualquer computador, até mesmo celular tem mais de um núcleo de processamento. E nosso dever como desenvolvedores é explorar isso, mas cientes do quão perigoso pode ser.

Visando otimizar ainda mais o desempenho, em alguns casos é possível alocar os núcleos da GPU para que realizem certas tarefas, mas isso é assunto para um próximo artigo.

Ebook exclusivo
Dê um upgrade no início da sua jornada. Crie sua conta grátis e baixe o e-book

Artigos relacionados