2009-10-08

Criação de Threads

Teoria

Em cada instante na nossa máquina existem vários processos a correr em simultâneo. Não importa se temos um único processador/core ou mais que um processador/core. O sistema operativo faz com que os processos corram em simultâneo, ou melhor, com que os processos deêm a impressão de correr em simultâneo. Isto permite que eu escreva este post enquanto ouço música, entre outras coisas.

Esta possibilidade de fazer mais que uma coisa em simultâneo é bastante poderosa e bastante útil. Vejamos por exemplo, um programa para descompactar ficheiros ZIP. Esta operação pode demorar bastante tempo e o utilizador pode decidir cancelar a operação a meio. Para permitir isto o interface tem de continuar a responder ao input para que o utilizador possa ordenar o cancelamento.

Uma solução seria correr a descompactação num processo separado, usando algo como o fork() do Linux ou o CreateProcess() do Windows. Depois bastaria terminar esse processo quando o utilizador decidisse cancelar.

No entanto, existe uma melhor solução: threads. Uma thread é uma espécie de processo dentro de um processo. Cada processo começa com uma única thread, mas pode criar novas threads, as quais executam “em simultâneo”. As principais diferenças entre processos e threads são as seguintes:

  • Cada processo tem o seu próprio espaço de endereçamento (address space), enquanto as threads de um processo partilham o address space do processo;
  • Processos comunicam somente através de primitivas do sistema operativo (ficheiros, pipes, ), enquanto as threads podem comunicar usando os recursos que partilham (memória, p.e);
  • Trocas de contexto entre threads do mesmo processo são mais rápidas do que trocas de contexto entre processos;

Cada thread tem a sua própria stack e o seu conjunto de registos mas a heap é partilhada entre as threads de um processo.

Em sistemas do tipo UNIX, as threads e os processos têm mais ou menos o mesmo “custo”. A principal diferença de performance deve-se às trocas de address space. Em sistemas do tipo Windows NT, as threads são muito menos pesadas que os processos. Por isso é que é bastante comum em UNIX usar-se fork() enquanto em Windows é muito mais comum usar-se threads.

A API de threads em Java

Em Java, uma thread é representada por uma classe chamada apropriadamente Thread (java.lang.Thread). Esta classe implementa o interface java.lang.Runnable, que expõe apenas um método:

public interface Runnable {
void run();
}

Como veremos mais abaixo, nunca iremos chamar o método run() directamente.

Podemos instanciar uma Thread de várias formas:

  1. Usando o constructor Thread(Runnable target) e passando uma instância de Runnable que a Thread pode executar chamando o método run():
    Runnable someRunnable = ... // arranjar um Runnable algures
    Thread worker = new Thread(someRunnable);
  2. Derivando uma subclasse de Thread, reimplementando o método run() para executar o código pretendido, e instanciando essa subclasse:
    class MyThread extends Thread {
    @Override
    public void run() {
    // ...
    }
    }

    // Algures...

    Thread worker = new MyThread();
  3. Usando uma anonymous inner class que seja uma subclasse de Thread:
    Thread worker = new Thread {
    public void run() {
    // ...
    }
    };

A primeira opção é preferível quando podemos obter uma instância de Runnable a partir de outras fontes. A segunda opção é útil quando a Thread necessita de manter algum estado, ou quando é demasiado complexa para usar a terceira opção. Esta última é recomendável quando o código da Thread é simples, porque não há estado (ou há muito pouco estado), ou porque já está implementado noutro(s) método(s).

Depois de instanciar uma Thread é necessário invocar o método start() para que a Thread comece a executar. Este metódo retorna imediatamente, e o método run() da Thread começa a executar, em paralelo.

Thread worker = new Thread {
{
System.out.println("Hello from another thread!");
}
};

worker.start();
System.out.println("Hello from the main thread!");

Se quisermos esperar que uma Thread termine antes de prosseguir podemos usar o método join(), que bloqueia até que essa Thread termine. Isto é útil quando necessitámos do “resultado” de uma Thread. No entanto, join() não devolve nenhum valor. Se quisermos que a Thread devolva um resultado temos de usar outros mecanismos. A melhor solução seria usar um java.concurrency.Future.

No entanto, os objectivos das aulas implicam o uso de threads directamente, por isso necessitamos de outra solução. Podemos, por exemplo, criar uma subclasse de Thread com um método getResult() que depois de calcular o resultado, disponibiliza-o a partir desse método:

ThreadWithResult<int> worker = new ThreadWithResult<int>(...);

worker.start();
// Agora a thread principal vai fazer outras coisas
// até precisar do resultado da worker thread:
worker.join();
// E agora vamos obter o resultado:
int result = worker.getResult();

Aqui surgem “problemas” por causa das checked exceptions. Há quem goste, mas para mim é a principal razão para não usar/gostar de Java.

Como join() lança java.lang.InterruptedException temos de a capturar (com try-catch) ou declará-la no cabeçalho da nossa função (com throws). Esta excepção é lançada se interrompermos a thread com interrupt(). Quando nós interrompemos a thread sabemos o que fazer nesta situação, por isso devemos usar try-catch e agir adequadamente no catch. Se não interrompermos a thread (que será o caso mais comum), esta excepção não será lançada, por isso a solução é… algo muito feio:

try {
worker.join();
} catch(InterruptedException e) {
e.printStackTrace();
}

Ignorar excepções é má ideia, mas aqui somos forçados a fazê-lo pela linguagem. Mas isso não interessa muito para estes exercícios, por isso, vamos fazer assim. Nem vou dizer o quanto me irrita ter que fazer estas máfias para algo tão comum. E o código fica mesmo bonitinho.

E já chega de teoria e de ódio. Vamos lá resolver os exercícios.

Exercício 1

Vamos começar por implementar o código de cada uma das threads. É só escrever métodos que façam o que é pedido no exercício:

void incrementAndPrint() {
for(int i = 0; i < 1000; i++) {
// esperar um segundo...
System.out.println(i);
}
}

void repeatStdinToStdout() throws IOException {
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
String line;
while((line = in.readLine()).length() > 0) {
System.out.println(line);
}
}

Até aqui, nada de novo, mas falta a parte de esperar um segundo. Para isso vamos usar o método sleep() da classe Thread que faz com que a thread que está a executar espere (passivamente) um determinado período de tempo (em milissegundos):

void incrementAndPrint() {
for(int i = 0; i < 1000; i++) {
Thread.sleep(1000);
System.out.println(i);
}
}

É o equivalente à system call sleep em UNIX. Excepto… Pode lançar uma InterruptedException. Argggh…

void incrementAndPrint() {
for(int i = 0; i < 1000; i++) {
try {
Thread.sleep(1000);
} catch(InterruptedException e) {
}
System.out.println(i);
}
}

Agora temos de criar duas threads, uma para cada uma destas operações. Como o código de ambas é bastante simples vamos usar anonymous inner classes:

Thread counter = new Thread() {
public void run() {
for (int i = 0; i < 1000; i++) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println(i);
}
}
};
Thread repeater = new Thread() {
public void run() {
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
String line;
try {
while ((line = in.readLine()).length() > 0) {
System.out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
};

Aqui capturamos a java.io.IOException fora do ciclo, porque se ocorrer um erro de input/output o melhor é terminar, apresentando a excepção.

E agora falta… correr as threads:

counter.start();
repeater.start();

Já está. Conforme diz no enunciado agora esperamos implicitamente que as threads terminem, i.e., não fazemos nada porque um programa em Java termina quando todas as suas threads terminem (não é bem assim, mas para já serve…).

Exercício 2

Neste exercício temos de criar dez threads, mas todas elas a correr o mesmo código. É necessário manter estado partilhado entre elas: um contador. Vamos encapsular esse contador numa classe:

public class Counter {
private int counter = 0;

public int getCount() {
return counter;
}

public void increment() {
++counter;
}
}

Para quê este trabalho? Porque agora podemos criar uma subclasse de Thread que recebe este contador no constructor para depois incrementar. Desta forma, podemos criar dez instâncias dessa Thread de maneira que todas elas usem o mesmo contador:

public class Incrementer extends Thread {
private Counter counter;

public Incrementer(Counter counter) {
this.counter = counter;
}

@Override
public void run() {
for(int i = 0; i < 1000000; i++) {
counter.increment();
}
}
}

Agora na main() podemos simplesmente instanciar as dez threads assim:

Counter counter = new Counter();
Thread[] threads = new Thread[10];
for(int i = 0; i < 10; i++) {
threads[i] = new Incrementer(counter);
}

E para as iniciar:

for(int i = 0; i < 10; i++) {
threads[i].start();
}

Finalmente, esperar que todas elas terminem os incrementos e imprimir o resultado (será 10 milhões?):

for (int i = 0; i < 10; i++) {
try {
threads[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(counter.getCount());

Mas isto é tão simples…

Este código é bastante simples, mas aposto que o resultado não é 10 milhões. Na minha máquina obtenho valores perto de 7 milhões. Porquê?

Estamos na presença de um problema conhecido como race condition.

A operação de incremento é no fundo, uma sequência de três operações:

  1. O valor actual de counter é lido da memória;
  2. Esse valor é incrementado;
  3. O novo valor é escrito na memória;

O problema surge quando a execução de duas threads é agendada, por exemplo, desta forma:

Race condition

O que acontece é isto:

  1. A thread A lê o valor actual de counter (digamos, 41);
  2. A thread B lê o valor actual de counter (41);
  3. A thread A incrementa o valor lido (41+1 = 42);
  4. A thread A escreve o novo valor (counter = 42);
  5. A thread B incrementa o valor que leu em 2 (41+1 = 42);
  6. A thread B escreve este novo valor (counter = 42);

Acontece que apesar de terem executado dois incrementos, aparentemente counter só foi incrementado uma vez…

Este problema será resolvido no guião do capítulo 2: Exclusão Mútua.

6 commentários:

ASilva disse...

Comigo por vezes dá 10 milhoes. LOL =D

Já agora, bom guião. Parabéns.

Anónimo disse...

Muito boa explicação. Excelente trabalho ;)

Anónimo disse...

REI

Anónimo disse...

Parabens, tá 5 estrelas a explicação!

Anónimo disse...

Parabens, excelente post. Irá me ajudar a apresentar um trabalho na faculdade. ;D

Anónimo disse...

You really make it appear so easy with your presentation but I find this
matter to be actually one thing that I believe I'd never understand. It sort of feels too complicated and extremely wide for me. I am having a look forward on your subsequent publish, I will attempt to get the hold of it!

Check out my blog post ... cellulite treatment cream

Enviar um comentário