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.

Exemplo de distribuição de dados entre os núcleos
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.

Divisão de tarefas entre as CPUs
Figura 2. Divisão de tarefas entre as CPUs

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

  • Elas são levemente comparadas com processos;
  • Threads compartilham o mesmo espaço de endereço e, portanto, podem compartilhar dados e código;
  • Trocas de contexto entre threads são geralmente menos custosas do que entre processos;
  • Custos de intercomunicação entre as threads são relativamente menores que a dos processos;
  • Threads permitem diferentes tarefas a serem realizadas simultaneamente;
  • Cada Thread possui uma prioridade, que define se ela será executada antes ou depois de outra.

Existem basicamente duas maneiras de se criar threads em Java:

  • Estendendo a classe Thread (java.lang.Thread);
  • Implementando a interface Runnable (java.lang.Runnable).

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.