Utilizando Threads - parte 1

Para entender o funcionamento de uma thread é necessário analisar, inicialmente, um processo. A maioria dos sistemas de hoje são baseados em computadores com apenas um processador que executam várias tarefas simultâneas.

Para entender o funcionamento de uma thread é necessário analisar, inicialmente, um processo. A maioria dos sistemas de hoje são baseados em computadores com apenas um processador que executam várias tarefas simultâneas. Ou seja, vários processos que compartilham do uso da CPU tomando certas fatias de tempo para execução. A esta capacidade é denominado o termo multiprocessamento. Teoricamente existe uma grande proteção para que um processo não afete a execução de outro, modificando, por exemplo, a área de dados do outro processo, a menos que haja um mecanismo de comunicação entre os processos (IPC – Inter Process Communication). Este alto grau de isolamento reduz os desagradáveis GPFs (General Protection Fault), pois o sistema se torna mais robusto.

Em contrapartida, o início de cada processo é bastante custoso, em termos de uso de memória e desempenho, e o mecanismo de troca de mensagens entre os processos é mais complexo e mais lento, se comparado a um único programa acessando a própria base de dados. Uma solução encontrada foi o uso de threads, (também conhecidas por linhas de execução). A thread pode ser vista como um subprocesso de um processo, que permite compartilhar a sua área de dados com o programa ou outras threads.

O início de execução de uma thread é muito mais rápido do que um processo, e o acesso a sua área de dados funciona como um único programa. Existem basicamente duas abordagens para a implementação das threads na JVM: utilização de mecanismos nativos de operação do S.O., e a implementação completa da operação thread na JVM. A diferença básica é que as threads com mecanismos nativos do S.O. são mais rápidas. Em contrapartida a implementada pela JVM tem independência completa de plataforma. Basicamente, em ambos os casos, a operação das mesmas é obtida através de uma fatia de tempo fornecida pelo S.O. ou pela JVM. Isto cria um paralelismo virtual, como pode ser observado na figura abaixo, que representa a execução de três threads.

Estado de uma thread

A execução de uma thread pode passar por quatro estados: novo, executável, bloqueado e encerrado.

A thread está no estado de novo, quando é criada. Ou seja, quando é alocada área de memória para ela através do operador new.Ao ser criada, a thread passa a ser registrada dentro da JVM, para que a mesma posso ser executada.

A thread está no estado de executável, quando for ativada. O processo de ativação é originado pelo método start(). É importante frisar que uma thread executável não está necessariamente sendo executada, pois quem determina o tempo de sua execução é a JVM ou o S.O.

A thread está no estado de bloqueado, quando for desativada. Para desativar uma thread é necessário que ocorra uma das quatro operações a seguir:

  1. Foi chamado o método sleep(long tempo) da thread;
  2. Foi chamado o método suspend() da thread (método deprecado)
  3. A trhead chamou o método wait();
  4. A thread chamou uma operação de I/O que bloqueia a CPU;

Para a thread sair do estado de bloqueado e voltar para o estado de executável, uma das seguintes operações deve ocorrer, em oposição as ações acima:

A thread está no estado de encerrado, quando encerrar a sua execução. Isto pode acorrer pelo término do método run(), ou pela chamada explícita do método stop().

Começando a trabalhar com threads

Para entender o uso de uma thread, está apresentado a seguir, um programa que fica indefinidamente imprimindo um contador na saída padrão (SemThread.java).

public class SemThread { public static void main(String[] args) { int i = 0; while(true) System.out.println(“Número: ”+ i++); } }

Aparentemente este programa ocupa completamente a CPU, e é o que realmente ocorre em S.O.s corporativos (ex.:Windows 3x). Porém em S.O. preemptivos (ex.: Windows NT, Solaris, Windows 98, OS/2, etc), o próprio S.O. se encarrega de gerenciar a ocupação da CPU, o que permite rodar outros processos, mesmo que um processo não retorne o controle para o S.O.. Como implementar o programa SemThread, permitindo que outros processos compartilhem a CPU? A solução é utilizar threads, como pode ser observado na classe SimplesThread. Existem duas abordagens para uma classe ser uma thread:

  1. Implementar a interface Runnable;
  2. Ser derivada da classe Thread;

Neste exemplo a classe Escrita é derivada da classe Thread. No método run() da classe Escrita está contido o código necessário para implementar adequadamente o programa acima.

class Escrita extends Thread { private int i; public void run() { while(true) System.out.println(“Número :”+ i++); } } public class SimplesThread1 { public static void main(String[] args) { Escrita e = new Escrita(); //Cria o contexto de execução e.start(); //Ativa a thread } }

No exemplo SimplesThread2 a classe Escrita implementa a interface Runnable. Qualquer classe que implementar a interface Runnable deve ter a descrição do método run().

class Escrita implements Runnable { private int i; public void run() { while(true) System.out.println(“Número: ”+ i++); } } public class SimplesThread2 { public static void main(String[] args) { Escrita e = new Escrita(); //Cria o contexto de execução Thread t = new Thread(e); //Cria a linha de execução t.start(); //Ativa a thread } }

A classe SimplesThread2 cria o contexto de execução da thread no momento que cria uma instância de um objeto Runnable, que no caso é o objeto Escrita.

Escrita e = new Escrita(); //Poderia ser Runnable e = new Escrita();

Para criar uma linha de execução, basta criar a thread, fornecendo o contexto (o local onde há o método run da thread).

Thread t = new Thread(e);

O início da thread propriamente dito ocorrerá com o método start().

Métodos para trabalhar com Threads

A classe Thread dispõe de vários métodos. Abaixo segue uma descrição resumida de alguns destes:

  1. Thread(...) – construtor da classe. Permite que seja instanciado um objeto do tipo Thread;
  2. void run() – Deve conter o código que se deseja executar, quando a thread estiver ativa;
  3. void start() – Inicia a thread. Ou seja, efetiva a chamada do método run();
  4. void stop() – encerra a thread;
  5. static void sleep(long tempo) – deixa thread corrente inativa por no mínimo tempo milisegundos e promove outra thread. Note que este método é de classe e, consequentemente, uma thread não pode fazer outra thread dormir por um tempo;
  6. static void yield() – Deixa a thread em execução temporariamente inativa e, quando possível, promove outra thread de mesma prioridade ou maior;
  7. void suspend() – Coloca a thread no final da fila de sua prioridade e a deixa inativa (método deprecado);
  8. void resume() – Habilita novamente a execução da thread. Este método deve ser executado por outra thread, já que a thread suspensa não está sendo executada (método deprecado);
  9. void interrupt() – envia o pedido de interrupção de execução de uma thread;
  10. static boolena interrupted() – Verifica se a thread atual está interrompida;
  11. void join() – Aguarda outra thread para encerrar;
  12. boolean isAlive() – retorna true caso uma thread estiver no estado executável ou bloqueado. Nos demais retorna false;
  13. void setPriority(int prioridade) – Define a prioridade de execução de uma thread. Os valores de prioridade estão entre 1 e 10;
  14. int getPriority() – verifica a prioridade de execução de uma thread;
  15. synchronized – mecanismo que permite ao programador controlar threads, para que as mesmas possam compartilhar a mesma base de dados sem causar conflitos;
  16. void wait() – Interrompe a thread corrente e coloca a mesma na fila de espera (do objeto compartilhado) e aguarda que a mesma seja notificada. Este método somente pode ser chamado dentro de um método de sincronizado;
  17. void notify() – Notifica a próxima thread, aguardando na fila;
  18. void notifyAll() – Notifica todas as threads.

Há também, vários métodos para trabalhar com agrupamentos de threads. A documentação necessária pode ser encontrada no JDK, no pacote Java.lang.ThreadGroup.

Entendendo melhor o uso de threads

O que acontece com a thread quando termina o método main? Porque o Garbage Collection não elimina a thread da memória, já que não há nenhuma referência para a mesma? O que ocorre é que o programa pode não ter uma referência explícita para a thread, mas implicitamente a thread está cadastrada na JV e continuará cadastrada enquanto não for encerrada. Desta forma, mesmo após executar o último comando do main, o programa permanece sendo executado. Para encerrá-lo, todas as referências implícitas do programa devem ser eliminadas. Este mesmo princípio ocorre para os componentes de uma interface gráfica, onde por exemplo, mesmo ao final do main um frame pode ficar ativo. Para visualizar melhor o uso de threads, o arquivo VariasThreads.java, apresenta um incremento da classe SimplesThread2. A classe Escrita passou a ter uma variável de instância que identifca a thread que está sendo executada.

class Escrita implements Runnable { private int i; private static int cont = 0; private int identificacao; public void run() { while(true) System.out.println(“Número (” + identificacao + “): ” + i++); } public Escrita() { cont++; identificacao = cont; } } public class VariasThreads { public static void main(String[] args) { Runnable r1 = new Escrita(); Runnable r2 = new Escrita(); New Thread(r1).start(); New Thread(r2).start(); } }
Listagem 1. NOME

A execução de uma thread nativa depende do S.O. Embora a linguagem Java seja totalmente portável, certos cuidados tem que ser tomados para que as threads cooperam adequadamente, independente da JVM. Na verdade, o que se espera é que uma thread, após ser executada, passe a promover outras threads, mantendo a ordem de prioridade entre as mesmas. Se isto não ocorrer, alguns S.O. poderão ter as demais threads paradas durante a execução da thread “xxxxx”. O programa VariasThreads2.java refaz a classe Escrita para, após exibir a mensagem na saída padrão, a thread ficar inativa por pelo menos 500 milisegundos. O método sleep(long tempo) faz com que a thread adormeça por tempo milisegundos e promove outras threads.

class Escrita implements Runnable { private int i; private static int cont = 0; private int identificacao; public void run() { while(true) System.out.println(“Número (” + identificacao + “): ” + i++); try { Thread.sleep(500); } catch(InterruptedException e) {} } public Escrita() { cont++; identificacao = cont; } } public class VariasThreads { public static void main(String[] args) { New Thread(new Escrita()).start(); New Thread(new Escrita()).start(); } }

Para analisar a segunda abordagem de implementação de threads, o programa MultiThread.java cria três threads com tempos de espera e nomes distintos. Para gerar um tempo de espera randômico foi utilizado o método Math.random().

class UmaThread extends Thread { private int delay; public UmaThread(String identifacacao, int delay) { super(identificacao); this.delay = delay; } public void run() { String identificação = this.getName(); try { sleep(delay); } catch(InterruptedException e) { System.out.println(“Thread: ” + identificacao + “ foi interrompida’); } System.out.prinln(“>>” + identificacao + “ ” + delay); } } public class MultiThread { public static void main(String[] args) { UmaThread t1,t2,t3; t1 = new UmaThread(“Primeira”, (int)(Math.random()*8000)); t2 = new UmaThread(“Segunda”, (int)(Math.random()*8000)); t3 = new UmaThread(“Terceira”, (int)(Math.random()*8000)); t1.start(); t2.start(); t3.start(); } }

O exemplo é semelhante a classe VariasThread2.java, porém, a classe UmaThread já é derivada da classe Thread. Assim, o método run() pode existir sem implementar a interface Runnable e o método sleep(long tempo) não precisa ser precedido da palavra Thread. Para armazenar a identificação da Thread foi utilizada as variáveis da própria classe Thread. Para obter o nome armazenado foi utilizado o método getName(); O leitor pode se perguntar qual o melhor método de realização de thread: utilizar a interface Runnable ou derivar da classe Thread.

Na verdade ambas implementações levam a resultados semelhantes. Todavia, devido a restrição de Java não ter herança múltipla, a abordagem de interfaces permite que a classe, além de implementar uma thread, seja derivada de outra classe. Prioridades de Threads

As threads sempre iniciam sua execução com a prioridade herdada da superclasse, que por default é igual a 5 (Thread.NORM_PRIORITY). Porém, o programador pode alterar a prioridade da thread como convier, dentro do range de prioridades de 1 (Thread.MIN_PRIORITY) e 10 (Thread.MAX_PRIORITY).

A JVM tem um dispositivo que escala as threads. Este dispositivo sempre tenta escalonar a thread de maior prioridade que esteja no estado executável. A thread escalonada passa a promover outras threads nos seguintes momentos:

Em alguns S.O., tais como o Solaris, threads nativas de mesma prioridade devem ser promovidas para poderem ser executadas. Isto pode ser efetuado pelo método yield().

A figura que segue mostra os dez níveis de prioridade que podem ser inseridas as threads. Dentro de cada nível de prioridade existe um conceito de fila, para que seja possível escalonar todas as threads de mesma prioridade, fornecendo os mesmos privilégios de escalonamento.

class Escrita extends Thread { private int i; Escrita(String identificacao) { Super(identificacao); } Escrita(String identificação, int prioridade) { super(identificacao); setPriority(prioridade); } public void run() { while(true) { System.out.println(getName() + i++); yield(); } } } public class TestePrioridade { publis static void main(String[] args) { int i = 0; new Escrita(“Menor”,4).start(); new Escrita(“Maior”,6).start(); new Escrita(“Default”,5).start(); } }

Neste programa pode ser observado que a thread de maior prioridade é executada muitas vezes mais que a thread de prioridade default, e a thread de prioridade baixa raramente executada.

Sincronismo de Threads

As threads se diferem dos processos por poderem ter áreas de dados comuns. Isto pode facilitar em muito a implementação de programas. Porém, pode causar alguns erros quando a mesma base de dados é alterada por mais de uma thread, em momentos inesperados. A solução para este problema é utilizar um mecanismo de sincronização que permita a thread acessar a base de dados compartilhada apenas quando os dados estiverem estáveis (outras threads não estejam manipulando estes dados).

Java adotou a palavra chave synchronized para informar que um determinado bloco deve estar síncrono com as demais threads. O sincronismo gera um bloco atômico (indivisível). Assim, este bloco passa a ser protegido, evitando que a thread atual seja interrompida por outra thread, independente da prioridade. O arquivo BancoSemSincronismo.java apresenta a simulação de operações de transferências bancárias. Este programa, extraído parcialmente de [COR 99], não se preocupa com detalhes de sincronismo entre as operações.

public class BancoSemSincronismo { public static void main(String[] args) { Banco b = new Banco(); for(int i=0; i < Banco.NUN_CONTAS; i++) { new Transferencias(b, i).start(); } } }

A classe BancoSemSincronismo cria um banco hipotético b e Banco.NUM_CONTAS contas bancárias para efetuar transferências randômicas. Ao criar as contas bancárias, está sendo criada as threads que efetuarão as transações bancárias.

A classe Banco, ao ser instanciada, cria um vetor de NUM_CONTAS, cada qual com VALOR_TOTAL (o valor inicial de cada conta bancária). O método teste() apresenta o valor total das transações efetuadas e o valor do dinheiro que está no banco. Como não há depósitos e/ou retiradas, a quantia total de dinheiro no banco deve, pelo menos teoricamente, permanecer inalterada.

O método transfere(int de, int para, int quantidade), efetua a transferência de quantidade de conta de para a conta para. Antes de efetuar a transferência, o método tem uma proteção para a conta nunca ficar com um valor negativo (devendo dinheiro). Caso isto ocorra, a thread adormece por 5 milisegundos. Observe o código:

while(conta[de] < quantidade) { try { Thread.sleep(5); } catch(InterruptedException e) {} }

O que ocorre na prática é que outra thread, em algum instante aleatório colocará a quantia necessária para que a transação possa ser efetivada. E quando isto ocorrer, o método decrementa a quantia da conta inicial e coloca a mesma quantia na conta do favorecido pela transferência.

conta[de] - = quantidade; conta[para] + = quantidade;

A cada 5000 transferências o programa exibe o valor total de dinheiro no banco. A classe completa pode ser observada abaixo.

class Banco { public static final int VALOR_TOTAL = 10000; //Total de cada conta public static final int NUN_CONTAS = 10; //Número de contas no banco private long conta[]; //Array que armazena o valor das contas private int transfere; //indica o número de transferências bancárias public Banco() { conta = new long[NUN_CONTAS]; for(int i = 0; i < NUN_CONTAS; i++) { conta[i] = VALOR_TOTAL; } tranfere = 0; teste(); } public void transfere(int de, int para, int quantidade) { while(conta[de] < quantidade) { try { Thread.sleep(5); } catch(Interruptedexception e) {} } conta[de] - = quantidade; conta[para] + = quantidade; transfere++; if(transfere % 5000 == 0) teste(); } public void teste() { long soma = 0; for(int i = 0; i < NUM_CONTAS; i++) soma + = conta[i]; System.out.println(“Transações: ” + transfere + “Soma: “ + soma); } }

A classe Transferência, derivada da classe Thread, tem na sua construção o banco e o número da conta para transferência bancária. Este classe é responsável pela trhead das transações bancárias.

O método run() calcula uma conta aleatória para a qual irá ser transferida uma quantia igualmente aleatória. A thread permanece adormecida por 1 milisegundo após ter sido efetuada a operação.

class Transferência extends Thread { private Banco db; private int de; public Transferência(Banco b, int i) { de = i; this.b = b; } public void run() { while(true) { int para = (int)(Banco.NUM_CONTAS * Math.random()); if(para = = de) para = para % Banco.NUN_CONTAS; int quantidade=1+(int)(Banco.VALOR_CONTAS * Math.random()) /2 b.transfere(de, para, quantidade); try { sleep(1); } catch(InterruptedException e) {} } } }

Observe um resultado obtido, a partir da execução do programa:

Transações Soma
0 10000
5000 97051
10000 97051
15000 96356
20000 96356
25000 97885
30000 99077

Através do resultado, pode-se que a soma total do dinheiro das contas passou a ser alterada, mesmo não ocorrendo nenhum depósito ou retirada. O que acontece é que a JVM, pode promover outra thread a qualquer instante, desde que tenha terminado a execução de uma bytcode (nunca durante a sua execução).

Cada instrução pode ser implementada por vários bytecodes; sendo assim, algumas instruções que deveriam ser atômicas podem ser divididas com uma razoável probabilidade. Este efeito ocorre no método transfere(int de, int para, int quantidade), mais especificamente nas instruções abaixo:

conta[de] - = quantidade; conta[para] + = quantidade;

Mas porquê este trecho de código deve ser atômico? Porque as threads alteram a mesma base de dados, o vetor conta é comum.

O método teste() consiste em outro trecho de código que também pode dar problema, mas apenas temporário, pois enquanto está sendo calculado o valor total de uma conta, a thread pode ser interrompida e a quantidade ser alterada por outra thread. Obviamente como esta operação ocorre a cada 5000 transações a probabilidade de erros é bem menor.

if(transfere % 5000 = = 0) Teste();

Para solucionar o problema de sincronismo, Java dispõe de um mecanismo realizado através da palavra chave sinchronyzed e dos métodos wait(), notify() ou notifyAll(). O arquivo BancoComSincronismo.java apresenta a nova versão sincronizada, como pode ser observado nos trechos de código que foram alterados.

public sinchronyzed void Transfere(int de, int para, int quantidade) { while(conta[de] < quantidade) { try { wait(): }catch(InterruptedException e) {} } conta[de] - = quantidade; conta[para] + = quantidade; transfere++; if(transfere % 5000 = = 0) teste(); notify(); }

Devido a palavra chave sinchronyzed, o método transfere(int de, int para, int quantidade) passa a ser atômico. Assim, nenhuma thread pode interromper a sua execução.

Quando a thread chega ao final do bloco protegido é evocado o método notify() para promover outra thread que será escalonada. Porém, para evitar que a proteção para valores negativos pare o processamento do programa, a thread chama o método wait(), que bloqueia a thread corrente (quebra a atomicidade) e promove outras threads. Estas novas threads colocarão valores suficientes para a thread atual sair do laço de proteção. É claro que este sistema deve ser bem projetado para não surgir situações de deadlocks; onde todas as threads ficam bloqueadas porque alguma não foi satisfeita e nenhuma outra thread está ativa para poder satisfazer as condições.

Artigos relacionados