Simplificando o uso de Threads
Nesse artigo, veremos o que são threads, como são implementadas em Java e algumas de suas principais aplicações. Veremos também como se comunicar com threads que não estão executando e como esperar o encerramento de uma thread.
Nesse artigo, veremos o que são threads, como são implementadas em Java e algumas de suas principais aplicações. Veremos também como se comunicar com threads que não estão executando e como esperar o encerramento de uma thread.
Threads são a maneira mais popular de se escrever programas concorrentes, e são uma tecnologia central em Java. Seu uso pode tornar os programas mais rápidos e eficientes, além de melhorar a usabilidade.
Guia do artigo:
- Como threads funcionam
- Recebendo e retornando dados nas threads
- Esperando o fim da thread
- Aplicações
- Paralelização de algoritmos pesados
- Aproveitamento de tempo gasto com entrada e saída
- Usabilidade em interfaces gráficas
Programas com trechos de algoritmos muito demorados podem aproveitar melhor vários núcleos de processadores, cada vez mais populares atualmente, se utilizarem threads. Por sua vez, programas que atendem várias requisições mas executam muitas operações de entrada/saída deixam de desperdiçar tempo de entrada/saída com espera, podendo executar ações paralelamente enquanto aguardam o resultado das E/S. Além disso, softwares interativos que executam algoritmos pesados podem usar threads para poderem atender a solicitações de seus usuários mesmo quando ocupados.
Programas de computador são, geralmente, uma série de pequenos comandos executados em série: o processador executa um comando por vez, um atrás do outro, em uma ordem definida. Entretanto, nem sempre essa é a forma mais eficiente de se executar programas. Um programa pode desperdiçar muito tempo esperando o resultado de um comando que independe do processador; o computador pode ter mais de um núcleo processador; um algoritmo pode ser separado em partes que podem ser executadas paralelamente etc. Nesses casos, pode ser interessante paralelizar o programa, isto é, fazer partes do programa rodarem ao mesmo tempo – ou ao menos aparentemente ao mesmo tempo. Uma maneira muito prática e popular de se fazer isso é utilizando threads.
Como threads funcionam
A Figura 1 representa um trecho de um programa em Java. Apesar de serem trechos de um só código, há conjuntos de comandos que dependem uns dos outros, mas que não influenciam o resultado de outras instruções. Por exemplo, este código pode ser dividido em três blocos independentes, que na figura são separados por linhas brancas. Embora estejam em uma ordem definida, não importa se o primeiro bloco é executado antes ou depois do segundo ou do terceiro: eles poderiam ter suas posições trocadas sem problemas. Na verdade, como os comandos não influenciam outros blocos, nada impede que se execute o primeiro comando do primeiro bloco seguido pelo primeiro comando do segundo bloco e assim por diante, alternando comandos de um e de outro bloco.
Cada bloco do programa é independente: altera variáveis que não são usadas por nenhum outro trecho. Esse programa tem a forma ideal para ser paralelizado com threads. Para isso, os blocos devem ser convertidos em métodos e passados para uma classe específica, que os faria rodar em paralelo. Depois, o programa pode executar outras tarefas – mas, se em algum momento depender do resultado de uma das operações paralelas, deve esperar que a linha de execução da operação necessária termine. A Figura 2 apresenta uma representação gráfica deste programa.
Se o programa da Figura 2 for executado em um computador com mais de um processador, ou com um processador com mais de um núcleo, rodará mais rápido que sua versão linear da Figura 1, pois cada uma das partes pode ser executada em um núcleo, enquanto na versão linear apenas uma instrução pode ser executada por vez.
Caso o programa rode em um processador com apenas um núcleo, o computador executará trechos de cada thread alternadamente – então nenhuma instrução será, de fato, executada ao mesmo tempo que outra. Entretanto, mesmo neste caso o uso de threads pode ser vantajoso: se o sistema operacional for multitarefa, o programa multithread terá mais tempo de execução que um programa linear. Ademais, certos comandos podem demorar para obter uma resposta, deixando o processador ocioso: é o caso de ler informações do teclado, do disco ou da rede. Enquanto as respostas desses comandos não vêm, outras threads do programa podem se executados.
threads em Java
Java já foi pensada para trabalhar da melhor maneira possível com threads. Existem construções básicas da linguagem para lidar com essa tecnologia, além de várias bibliotecas para auxiliar.
Existem duas maneiras básicas de se usar threads em Java, e ambas dependem de uma classe, java.lang.Thread. Uma maneira é estender a classe Thread, sobrescrevendo seu método run(). Assim, ao chamar o método start() dessas threads o método run() é executado em paralelo.
Outra maneira é criar uma classe que implemente a interface Runnable. Essas classes também implementam o método run(), e suas instâncias podem ser passadas como parâmetro para o método start() das threads. O uso da classe Runnable é interessante para reaproveitar o código do método run(), que pode ser invocado em outros lugares, sem precisar lidar com threads; além disso, existem outras classes e frameworks que utilizam implementações de Runnable, de modo que a classe pode ser reaproveitada. Entretanto, provavelmente a maneira mais simples de trabalhar com threads seja criar uma classe que herde de Thread e sobrescreva o método run(), e esse é o modo que veremos.
Por exemplo, considere a Listagem 1, um programa que executa três operações muito lentas: três laços de cinco bilhões de iterações. As operações são executadas uma depois da outra, e o resultado é:
Operação demorada demorou 4 segundos
Operação demorada demorou 9 segundos
Operação demorada demorou 14 segundos
Na Listagem 2, a parte demorada do algoritmo foi extraída para a classe ThreadComOperacaoLenta. Note que ela estende de Thread, logo, ThreadComOperacaoLenta é uma thread. Nessa classe, sobrescrevemos o método run(), e colocamos dentro dele o código que queremos que a thread execute em paralelo.
Feito isso, é possível criar threads de modo a executar os três laços da Listagem 1 em paralelo. Observe a Listagem 3. Nela, criamos três instâncias de ThreadComOperacaoLenta, e em seguida as executamos, chamando seu método start(). Observe que não chamamos o método run(); é o método start() que cuidará para que ele seja invocado:
Operação demorada demorou 8 segundos
Operação demorada demorou 8 segundos
Operação demorada demorou 8 segundos
Dessa vez, os loops foram executados lado a lado. Por isso, o primeiro laço, que antes demorava menos, levou mais tempo para terminar agora. Em compensação, os demais laços terminaram mais rapidamente, e a resposta do último laço veio bem mais rápida. Além disso, se você executar esse programa, notará que todo ele levou apenas oito segundos para executar, enquanto o anterior demorou quatorze segundos.
O programa paralelizado foi mais rápido porque a máquina em que ele rodou possui um processador com mais de um núcleo: enquanto uma operação era executada em um núcleo, outra operação, de outro laço, era executada em paralelo. Entretanto, mesmo que a máquina tivesse apenas um núcleo de processamento, o programa com threads provavelmente seria mais veloz: a maior parte dos sistemas operacionais modernos é multitarefa – isto é, executa vários programas em paralelo, executando um pequeno pedaço de um, depois de outro e assim por diante. Utilizando threads, o programa paralelizado pode usar mais vezes o processador que um programa comum. Se ele rodasse sozinho, provavelmente seria mais lento; entretanto, na prática os programas quase sempre rodarão lado a lado com outros. Assim, “ganhar” mais “turnos” pode ser bem vantajoso.
package br.com.javamagazine.thread;
import java.util.Date;
public class SlowOperations {
public static void main(String[] args) {
// Pega o momento em que começou a execução, em milissegundos.
Date momentoDoComeco = new Date();
long Comeco = momentoDoComeco.getTime();
for (int i = 0; i < 3; i++) {
// A operação muito lenta.
for (long j = 0; j < 5000000000L; j++);
// Pega o momento em que a atual operação encerrou, e quanto tempo se passou.
Date momentEnded = new Date();
long segundosGastos = (momentEnded.getTime()-Comeco)/1000;
// Imprime resultado.
System.out.println("Operação demorada demorou "+segundosGastos+" segundos");
}
}
}
package br.com.javamagazine.thread;
import java.util.Date;
public class ThreadComOperacaoLenta extends Thread {
@Override
public void run() {
Date momentoDoComeco = new Date();
long Comeco = momentoDoComeco.getTime();
// A operação muito lenta.
for (long j = 0; j < 5000000000L; j++);
// Pega o momento em que a atual operação encerrou, e quanto
// tempo se passou.
Date momentEnded = new Date();
long segundosGastos = (momentEnded.getTime()-Comeco)/1000;
// Imprime resultado.
System.out.println("Operação demorada demorou "+segundosGastos+" segundos");
}
}
package br.com.javamagazine.thread;
public class ThreadedSlowOperations {
public static void main(String[] args) throws InterruptedException {
Thread operacaoLenta1 = new ThreadComOperacaoLenta();
Thread operacaoLenta2 = new ThreadComOperacaoLenta();
Thread operacaoLenta3 = new ThreadComOperacaoLenta();
operacaoLenta1.start();
operacaoLenta2.start();
operacaoLenta3.start();
}
}
Recebendo e retornando dados nas threads
Threads como ThreadComOperacaoLenta são classes e, portanto, podem ter atributos, métodos, receber parâmetros nos construtores etc. Por exemplo, é possível fazer uma versão da classe da Listagem 2 que apenas execute os laços e retorne o tempo gasto pela thread. Além disso, o número de iterações agora será passado para a thread, não codificado nela.
Para passar o número de iterações para a thread, basta criar um atributo e um construtor que o inicie, como abaixo:
private long numeroDeIteracoes;
public ThreadSoComLoop(long numeroDeIteracoes) {
this.numeroDeIteracoes = numeroDeIteracoes;
}
Além disso, a classe deverá informar o tempo gasto na execução do loop. A maneira mais simples de fazer isso é criar um atributo que armazene esse valor, e um método que permita acessá-lo.
private long segundosGastos;
public long getSegundosGastos() {
return segundosGastos;
}
Agora, basta alterar o método run(). O código inicial para pegar o momento em que o laço se inicia se mantém o mesmo:
Date momentoDoComeco = new Date();
long Comeco = momentoDoComeco.getTime();
O laço, porém, será diferente: ao invés de se colocar o valor 5000000000L na marra, colocaremos, no seu lugar, o atributo numeroDeIteracoes:
for (long j = 0; j < numeroDeIteracoes; j++);
Por fim, basta calcular o tempo gasto no laço, mas, ao invés de imprimir o tempo, vamos apenas armazená-lo no atributo segundosGastos:
Date momentEnded = new Date();
segundosGastos = (momentEnded.getTime()-Comeco)/1000;
Como o método run() dessa thread apenas executa o loop, vamos chamá-la de ThreadSoComLoop. A classe completa pode ser vista na Listagem 4.
Ao criar essas threads, é necessário passar o número de iterações. Ao final da execução das mesmas, o tempo gasto na execução é guardado no campo segundosGastos. Assim, para saber quanto tempo cada thread levou para terminar, basta imprimir o valor retornado pelo método getSegundosGastos(). Dessa forma, a princípio, poderia se tentar algo como:
ThreadSoComLoop operacaoLenta1 = new ThreadSoComLoop(5000000000L);
ThreadSoComLoop operacaoLenta2 = new ThreadSoComLoop(5000000000L);
ThreadSoComLoop operacaoLenta3 = new ThreadSoComLoop(5000000000L);
operacaoLenta1.start();
operacaoLenta2.start();
operacaoLenta3.start();
System.out.println("Operação demorada demorou "+operacaoLenta1.getSegundosGastos()+" segundos");
System.out.println("Operação demorada demorou "+operacaoLenta2.getSegundosGastos()+" segundos");
System.out.println("Operação demorada demorou "+operacaoLenta3.getSegundosGastos()+" segundos");
Entretanto, esse programa não se comporta como poderia se esperar. O resultado impresso é
Operação demorada demorou 0 segundos
Operação demorada demorou 0 segundos
Operação demorada demorou 0 segundos
Além disso, se você rodar esse código, notará que as linhas são impressas muito antes do programa terminar!
package br.com.javamagazine.thread;
import java.util.Date;
public class ThreadSoComLoop extends Thread {
private long segundosGastos;
private long numeroDeIteracoes;
public ThreadSoComLoop(long numeroDeIteracoes) {
this.numeroDeIteracoes = numeroDeIteracoes;
}
@Override
public void run() {
Date momentoDoComeco = new Date();
long Comeco = momentoDoComeco.getTime();
for (long j = 0; j < numeroDeIteracoes; j++);
Date momentEnded = new Date();
segundosGastos = (momentEnded.getTime()-Comeco)/1000;
}
public long getSegundosGastos() {
return segundosGastos;
}
}
Esperando o fim da thread
Isso acontece porque, como as threads são executadas em paralelo, assim que a linha operacaoLenta3.start(); é executada, o código da thread roda à parte, e o programa continua nas próximas linhas sem esperar as threads terminarem. A Figura 3 representa graficamente esta situação. Se quisermos imprimir o tempo que cada thread consumiu, temos de esperar elas terminarem para recuperar o valor. Para isso, depois de iniciarmos as threads, chamamos o método join() delas. Esse método interromperá o programa principal até que a thread termine. Assim, logo após a linha operacaoLenta3.start(); devem vir as seguintes linhas:
operacaoLenta1.join();
operacaoLenta2.join();
operacaoLenta3.join();
O programa, ao final, pode ser visto na Listagem 5. Rodando, obtemos o seguinte resultado:
Operação demorada demorou 7 segundos
Operação demorada demorou 7 segundos
Operação demorada demorou 7 segundos
O que ocorre? Vamos imaginar o cenário no qual a thread operacaoLenta2 termina primeiro, depois a operacaoLenta1 e por fim a operacaoLenta3. Quando chamamos operacaoLenta1.join() o programa vai esperar a operacaoLenta1 terminar sua execução. Nesse meio tempo, operacaoLenta2 termina. Quando operacaoLenta1 termina, o método operacaoLenta1.join() encerra e operacaoLenta2.join() é chamado. Só que operacaoLenta2 já terminou; nesse caso, o método join() não faz nada, apenas retorna. Assim, operacaoLenta3.join() suspenderá a execução do programa principal até a terceira thread terminar. Quando ela acabar sua execução, o programa voltará a executar normalmente.
package br.com.javamagazine.thread;
public class OnlyLoopOperations {
public static void main(String[] args) throws InterruptedException {
ThreadSoComLoop operacaoLenta1 = new ThreadSoComLoop(5000000000L);
ThreadSoComLoop operacaoLenta2 = new ThreadSoComLoop(5000000000L);
ThreadSoComLoop operacaoLenta3 = new ThreadSoComLoop(5000000000L);
operacaoLenta1.start();
operacaoLenta2.start();
operacaoLenta3.start();
operacaoLenta1.join();
operacaoLenta2.join();
operacaoLenta3.join();
System.out.println("Operação demorada demorou "+operacaoLenta1.getSegundosGastos()+" segundos");
System.out.println("Operação demorada demorou "+operacaoLenta2.getSegundosGastos()+" segundos");
System.out.println("Operação demorada demorou "+operacaoLenta3.getSegundosGastos()+" segundos");
}
}
Aplicações
Agora que você conhece um pouco de como threads funcionam, em que contextos tem-se algo a ganhar com elas?
Paralelização de algoritmos pesados
Um exemplo do uso de threads é, como vimos, escrever algoritmos que rodem concorrentemente. O exemplo apresentado é simplório, apenas um laço que nada opera, mas há vários casos em que a paralelização por thread vale a pena. Um exemplo são operações sobre matrizes, como produtos de matrizes e cálculo de determinantes. Várias aplicações científicas e jogos precisam fazer cálculos com matrizes enormes; assim, é comum que sejam escritas de maneira concorrente e rodem em máquinas com mais de um núcleo, ou em clusters (conjunto de vários computadores que se comportam como um só).
Outro exemplo é o processamento de sequências de genes. Genomas de várias espécies vêm sendo mapeados, mas o processamento dos dados coletados envolve algoritmos pesadíssimos que rodam por dias, meses ou mesmo anos. É possível, porém, implementar os algoritmos de modo a processar partes diferentes de um genoma em processadores diferentes, viabilizando o uso de mais de uma máquina e acelerando pesquisas.
Aproveitamento de tempo gasto com entrada e saída
Uma razão mais prosaica para se utilizar threads é aproveitar o tempo que seria desperdiçado executando operações de entrada e saída (E/S). As operações de E/S – sejam leitura de dados em disco, impressão na tela, transmissão de dados por rede ou leitura de teclado – são muito lentas, geralmente milhares ou milhões de vezes mais lentas que o processador. Ademais, o processador não é usado enquanto o resultado da operação de E/S é esperado. Enquanto um programa escreve algo no disco, poderia ter executado milhões de instruções!
Em aplicativos pessoais ou de escritório, essas perdas não são necessariamente importantes – mesmo que a operação de E/S seja milhões de vezes mais lenta que seu processador, teriam de ser milhões de vezes ainda mais lentas para serem notadas. Essas perdas, porém, fazem muita diferença se muito trabalho deve ser feito, e um exemplo clássico são servidores HTTP.
Ao acessar uma página da Web, o navegador faz uma requisição a um servidor HTTP. Para o servidor, essa requisição é uma operação de E/S; enquanto o servidor espera pelo resultado completo da requisição, poderia processar outra requisição já recebida. Quando se considera grandes sites que recebem milhares de acessos por segundo, fica claro que o tempo gasto com E/S não pode ser desperdiçado. Assim, enquanto uma thread espera o fim de uma operação de E/S, ela é automaticamente interrompida e outra começa a ser executada. Mesmo se houver apenas um processador no computador, o ganho é significativo.
Outra situação semelhante é logging, o procedimento de imprimir mensagens durante a execução de um programa. Como logging é uma operação de saída, pode atrasar bastante programas. Considere o programa da Listagem 6. Executando-o, o resultado foi:
Milissegundos gastos: 2
O programa gastou apenas dois milissegundos para executar o loop. Agora, considere que o laço foi alterado para conter um System.out.println() informando essa iteração, como abaixo:
for (int i = 0; i < 10000000; i++) {
System.out.println("Na " + i + "a iteração");
}
Medindo o tempo – depois de muitas linhas – vemos que o loop é muito mais demorado, mais de quarenta mil vezes mais lento:
Milissegundos gastos: 87486
A maior parte do tempo é desperdiçada para impressão. Apesar disso, é muito comum colocar esse tipo de código em algoritmos, para facilitar a depuração ou permitir auditoria. Uma solução para não se perder tempo é delegar essa tarefa a outra thread. Infelizmente isto não é algo trivial de implementar; felizmente, porém, não é necessário: várias bibliotecas, como o log4j, fornecem esse serviço.
package br.com.javamagazine.thread;
import java.util.Date;
public class Loop {
public static void main(String[] args) {
Date momentoDoComeco = new Date();
long Comeco = momentoDoComeco.getTime();
for (int i = 0; i < 10000000; i++);
Date momentEnded = new Date();
long milisegundosGastos = momentEnded.getTime()-Comeco;
System.out.println("Milissegundos gastos: " + milisegundosGastos);
}
}
Usabilidade em interfaces gráficas
Considere um aplicativo de desktop que tenha de executar algum processamento pesado – por exemplo, um editor com um grande texto aberto, no qual se substituirá um trecho que aparece muito por outro. Ao clicar no botão “Substituir todos” o programa começaria a substituir os trechos e, enquanto não terminasse, pararia de responder aos comandos do usuário. Deste modo, não seria possível interagir com a aplicação até o processamento terminar. O usuário poderia pensar que o programa teria travado etc. Novamente, podemos solucionar isso com threads. Bastaria que o processamento demorado fosse executado em uma thread separada, de modo que o programa pudesse responder ao usuário ao mesmo tempo em que a operação é executada.
Esses são apenas uns poucos exemplos mais simples do uso de threads. Entretanto, mesmo esses exemplos exigem uma compreensão ainda maior de threads, incluindo ferramentas como locking e problemas como condições de corrida ou deadlocks. Esses problemas ocorrem quando se passa ou se recupera informações de threads enquanto elas estão em execução. Só essas questões precisam de artigos inteiros! Por ora, aprendemos como trabalhar com threads e nos comunicar com elas antes e depois de serem executadas.
Conclusões
Threads são ferramentas muito poderosas para tornar os programas de computadores mais úteis, rápidos e eficazes. Com a popularização dos processadores multicore, o uso dessa ferramenta tem se tornado ainda mais comum. Nesse artigo, aprendemos alguns conceitos básicos sobre como threads são usadas em Java, além de algumas aplicações comuns. Entretanto, a maioria das aplicações de threads exige ainda conhecimento de problemas sutis e complicados, inerentes à programação concorrente.
Documentação da Sun sobre a classe java.lang.Thread.
Confira também
Artigos relacionados
-
Artigo
-
Artigo
-
Artigo
-
Artigo
-
Artigo