capa_ Processamento Paralelo e a nova API Fork/Join Desenvolva código que faz uso de diferentes implementações da interface ExecutorService e entenda como funciona a nova API Fork/Join. A linguagem Java oferece uma rica API que pode facilitar o desenvolvimento de software que faz uso de processamento paralelo. Em sua versão mais atual é introduzida uma nova implementação para a interface ExecutorService, chamada ForkJoinPool. Neste artigo estudaremos os recursos da linguagem desde suas primeiras versões até a sua versão mais atual. Resolveremos dois problemas (ordenação e média móvel) de formas diferentes e faremos comparações entre as soluções propostas, ilustrando o uso de diferentes recursos oferecidos pela linguagem, com maior ênfase na nova API Fork/Join. Neste artigo faremos uma breve viagem desde as primeiras versões da linguagem Java até a sua versão mais atual, descrevendo os principais recursos oferecidos em cada versão da linguagem. Em seguida, aplicamos os recursos descritos para solucionar dois problemas interessantes. Procuramos tornar o entendimento do artigo mais sólido oferecendo diferentes soluções para cada problema. Falar de processamento paralelo sem falar de possíveis ganhos de desempenho é improvável. Assim, durante o artigo, procuramos comentar sobre resultados obtidos comparando o tempo de execução de cada solução. Runnable, Thread e seus problemas Nas primeiras versões do Java, criava-se programas multithreaded utilizando-se a classe Thread e a interface Runnable. Escrever programas utilizando esta API básica é simples. Uma classe que implementa a interface Runnable representa uma unidade executável. A interface Runnable define um método chamado run, que é onde devemos colocar o código que deve ser executado por uma thread diferente da thread principal do programa. Com um objeto Runnable em mãos, criamos um objeto da classe Thread e indicamos que ele deve executar o método run do objeto Runnable que criamos. Assim temos / 6
Rodrigo Bossini rod.bossini@gmail.com Atualmente cursa mestrado em Ciência da Computação pelo Instituto de Matemática e Estatística da Universidade de São Paulo. É professor do Centro Universitário FIEO, ministrando disciplinas como Teoria da Computação e Estruturas de Dados. Atua como tutor virtual do curso de Sistemas de Informação da Universidade Federal de São Carlos. Possui as certificações SCJP e SCWCD. Graduado em Ciência da Computação pelo Centro Universitário FIEO. algo parecido com os trechos de código mostrados nas Listagens 1 e 2. Listagem 1. Uma classe que representa uma tarefa a ser executada implementa a classe Runnable. class MinhaUnidadeExecutavel implements Runnable{ @Override public void run() { System.out.println( Código a ser executado pela nova thread. ); Listagem 2. Criando uma nova thread e iniciando a mesma. O construtor recebe uma instância de nossa classe que implementa Runnable. A nova thread executa o método run, implementado pela nossa classe. Thread t = new Thread(new MinhaUnidadeExecutavel()); t.start(); Na Listagem 2, a thread referenciada por t executará o método run do objeto que passamos ao construir o objeto Thread. A partir da linha t.start() existem pelo menos duas threads candidatas a execução em nosso programa: a thread principal (que executa o método main) e a thread referenciada por t. A própria classe Thread implementa a interface Runnable, assim também temos a opção de criar uma classe que herda da classe Thread diretamente e sobrescreve seu método run. Essa forma em geral não é recomendada já que estaríamos desperdiçando a possibilidade de herança e estaríamos criando objetos que passam no teste é um Thread, mas que em geral não representam de fato uma Thread. Esta API básica é simples de usar, mas pode dar dor de cabeça. Suponha que temos uma lista de clientes para os quais desejamos executar alguma atividade e que gostaríamos de executar a atividade de cada cliente em uma thread separada. Utilizando essa API simples, teríamos algo como o trecho de código mostrado na Listagem 3. Listagem 3. Temos uma lista de clientes para os quais desejamos executar uma tarefa específica. Executar as tarefas em threads separadas é bastante simples. Collection <Cliente> clientes = getcolecao(); for (Cliente cliente: clientes){ //Tarefa implementa Runnable e seu método run contém o código a ser executado para cada cliente new Thread(new Tarefa(cliente)).start(); O exemplo de código da Listagem 3 pode dar dor de cabeça por algumas razões. A coleção de clientes pode ser arbitrariamente grande. E se tivermos um milhão de clientes? Estaríamos criando um milhão de threads, uma para cada cliente. Instanciar threads é um processo caro. Cada uma dessas threads poderia estar tentando acessar uma base de dados. Já pensou em escrever um programa que abre um milhão de conexões com o SGBD simultaneamente? O que gostaríamos de verdade é criar um número limitado de threads e reutilizá-las. Infelizmente, threads somente podem ser iniciadas uma vez. Não poderíamos, por exemplo, instanciar uma única thread e iniciá-la duas vezes, uma para cada cliente. O pacote java.util.concurrent do Java 5 O Java 5 introduziu o pacote java.util.concurrent que, entre outras coisas, oferece algo conhecido como pool de threads. Um pool de threads encapsula um conjunto de threads trabalhadoras e uma fila de tarefas a serem executadas. Submetemos tarefas a serem executadas a um pool de threads. As mesmas são enfileiradas e executadas por uma das threads trabalhadoras do pool que estiver disponível. Seu funcionamento se assemelha muito ao paradigma conhecido como produtor-consumidor. Dessa forma, um pool de threads pode criar um número limitado de threads e reutilizá-las para inúmeras tarefas. As principais interfaces do pacote java.util.concurrent para se trabalhar com pools de threads são as seguintes: Executor: define apenas um método chamado execute que recebe um objeto Runnable e executa seu método run, possivelmente em uma nova thread. A documentação oficial permite que a mesma thread que chama o método execute execute o método run do objeto Runnable recebido. ExecutorService: estende a interface Executor e define métodos que permitem, entre outras coisas, rastrear o progresso de atividades submetidas e cancelar a execução das mesmas. Callable: um pool de threads pode executar objetos Runnable, mas objetos Runnable não podem retornar resultados ou lançar exceções checadas. A interface Callable é similar à interface Runnable, definindo um método call que é parecido com o método run de Runnable, mas que pode retornar um valor e lançar exceções. O pacote java.util.concurrent oferece algumas implementações para a interface ExecutorService. 7 \
As mesmas podem ser obtidas utilizando-se métodos estáticos da classe Executors, como a seguir: Executors.newFixedThreadPool: retorna uma implementação de ExecutorService que representa um pool de threads com número de threads trabalhadoras fixo. Quando há mais tarefas a serem executadas do que threads trabalhadoras disponíveis, as tarefas são enfileiradas. Threads podem ficar ociosas caso não haja tarefas a executar. Executors.newCachedThreadPool: retorna uma implementação de ExecutorService que representa um pool de threads que cria e destrói threads trabalhadoras sob demanda. Quando tarefas são submetidas elas são executadas por threads disponíveis. Caso não haja thread disponível, uma nova é criada para cada nova tarefa submetida. Threads ociosas são destruídas após certo tempo de ociosidade. Executors.newScheduledThreadPool: retorna uma implementação de ExecutorService (na verdade de ScheduleExecutorService, que é uma interface que estende ExecutorService e define métodos relacionados ao agendamento de tarefas) que permite agendar tarefas para serem executadas periodicamente por uma das threads trabalhadoras ou para serem executadas uma vez após um intervalo determinado. Nova implementação de ExecutorService no Java 7 e as classes RecursiveAction e RecursiveTask Uma nova implementação para a interface ExecutorService é introduzida na versão 7 do Java: ForkJoinPool. O que há de novo nesta implementação em relação às implementações oferecidas no Java 5? A classe ForkJoinPool implementa um algoritmo de roubo de trabalho (work-stealing), o que significa que threads ociosas podem eventualmente roubar trabalho de outras threads que estiverem ocupadas. Quando trabalhamos com um ForkJoinPool, idealmente temos tarefas que exibem uma estrutura recursiva: elas podem ser definidas em função de versões menores delas mesmas. Suponha que temos um problema que pode ser definido em função de versões menores dele mesmo e que versões pequenas o suficiente desse problema têm solução já conhecida, que não precisa de cálculo algum. Por exemplo, o problema do cálculo do fatorial de um número pode ser definido em função de versões menores dele mesmo. Basta observar que n! é igual a n * (n-1)!. Ou seja, se sabemos calcular (n-1)! (uma versão menor do problema original) então sabemos calcular n! também. Além disso, a solução para n! quando n=0 ou n=1 é conhecida. Essa observação é importante, pois nossas soluções recursivas devem sempre convergir para um caso simples de se resolver, o que é similar à condição de parada de um loop. Na verdade, todo problema que pode ser resolvido de modo iterativo pode ser resolvido de modo recursivo também (embora nem sempre uma solução recursiva seja tão eficiente quanto uma solução iterativa). Até mesmo o problema da impressão de uma sequência de n números inteiros pode ser definido em função de versões menores dele mesmo. Para imprimir uma lista de inteiros recursivamente, observamos que se a lista contém mais de um elemento, basta fazermos a impressão do primeiro elemento da lista e então fazer a impressão da lista que começa do número seguinte em diante. Caso a lista tenha um número só, basta fazer sua impressão e terminar, sem a necessidade de uma chamada recursiva. Utilizar a API Fork/Join para solucionar problemas recursivamente e fazendo uso de processamento paralelo é bastante simples. Um ForkJoinPool pode trabalhar com objetos Runnable e Callable. Além disso, o Java 7 introduz a classe abstrata ForkJoinTask cujas extensões podem ser executadas por um ForkJoinPool. São oferecidas duas extensões para a classe ForkJoinTask: RecursiveAction e RecursiveTask. Se desejarmos executar uma atividade em um ForkJoinPool usando uma dessas classes, devemos criar uma subclasse que herde da classe desejada e implementar o método abstrato compute, cujo funcionamento é similar aos métodos run e call das interfaces Runnable e Callable. Ou seja, ao submetermos um objeto ForkJoinTask a um ForkJoinPool, o método compute será executado por uma das threads do pool (ao menos teoricamente, já que oficialmente a atividade pode ser executada até mesmo pela thread que submeteu a atividade ao pool). O método compute de uma classe que herda de RecursiveAction não tem retorno algum (assim como o método run de Runnable) e o método compute de uma classe que herda de RecursiveTask pode ter um retorno (assim como o método call de Callable). As classes RecursiveAction e RecursiveTask fornecem métodos que tornam bem simples soluções para problemas com estrutura recursiva. Em especial, a classe abstrata ForkJoinTask (que é herdada por ambas RecursiveAction e RecursiveTask) define métodos que permitem que uma tarefa tenha acesso ao pool de threads a que a thread que a está executando pertence. Assim, o código da nossa tarefa pode submeter subtarefas diretamente ao pool de threads sendo utilizado, que eventualmente serão executadas por diferentes threads trabalhadoras. Solução de problemas Vamos agora aplicar os recursos disponíveis na linguagem Java para solucionar dois problemas diferentes. Primeiro iremos solucionar o conhecido problema de ordenação de uma coleção de números. Implementaremos o algoritmo de ordenação Quick- Sort. Faremos isso de duas formas diferentes: na primeira, usamos somente os recursos disponíveis antes / 8
da versão 7 do Java. Depois disso, implementaremos uma solução para o mesmo problema utilizando as classes ForkJoinPool e RecursiveAction do Java 7. No segundo problema, faremos o cálculo da média móvel de uma coleção de valores, obtendo o valor máximo das médias. Faremos duas implementações para esse problema. Uma delas será puramente sequencial não usaremos processamento paralelo. Em seguida, implementaremos uma nova solução para o mesmo problema utilizando a classe RecursiveTask (que permite o retorno de um valor) do Java 7. Ao final, faremos uma comparação entre os tempos de execução de cada solução. Problema 1: Ordenação Primeiro, mostramos uma implementação utilizando somente recursos disponíveis antes da versão 7 do Java. Depois mostramos uma solução utilizando as classes ForkJoinPool e RecursiveAction, ambas disponíveis a partir do Java 7. O algoritmo QuickSort A implementação que faremos do algoritmo de ordenação QuickSort baseia-se inteiramente no pseudocódigo oferecido em [1]. O algoritmo de ordenação QuickSort baseia-se na seguinte ideia: suponha que desejamos ordenar uma lista de números armazenada em um vetor. Um dos números do vetor a ser ordenado é escolhido arbitrariamente (esse número é, geralmente, chamado de pivô adotamos esse nome aqui também). Os números no vetor são organizados de modo que o número escolhido como pivô fique em um índice q tal que todos os números nos índices entre 0 e q -1 sejam menores ou iguais ao pivô e todos os números a partir do índice q+1 sejam maiores do que o pivô. Por exemplo, considere o vetor mostrado na figura 1. Figura 1. Uma coleção de números armazenada em um vetor. 1 5 6 13 3 10 11 8 Caso o número 8 seja escolhido como pivô, por exemplo, após a organização do vetor espera-se que ele esteja como a figura 2. Figura 2. O mesmo vetor após a escolha do pivô. Neste exemplo escolhemos o número 8 como pivô e organizamos o vetor de modo que todos os números menores ou iguais ao pivô fiquem à sua esquerda e o restante fique à sua direita. 1 5 6 3 8 13 10 11 Todos os números menores ou iguais a 8 aparecem à esquerda e o restante aparece à direita. Repare que se fosse escolhido o 13 (1) como pivô, teríamos números somente à esquerda (direita) do pivô. A escolha do pivô tem impacto na complexidade computacional do algoritmo QuickSort, mas este é um tema que não será discutido aqui. Para mais informações, veja [1]. Com o vetor organizado como mostrado na figura 2, observa-se o seguinte: para concluir a ordenação do vetor, basta aplicar o mesmo procedimento ao subvetor [1..q-1] e ao subvetor [q+1..n-1]. Ou seja, aplicamos o algoritmo QuickSort para ordenar os subvetores à esquerda e à direita do pivô. Se esses vetores forem pequenos o suficiente (vazios ou contendo um único elemento) eles já podem ser considerados ordenados e não há nada o que fazer. Caso contrário, executamos o mesmo procedimento (escolhendo um novo pivô em cada um dos subvetores e organizando-os adequadamente). No final, o vetor completo estará ordenado. Vamos implementar uma classe que contém os métodos necessários para a implementação do QuickSort. Esta mesma classe será utilizada em ambos os exemplos que faremos. Inicialmente, criamos uma classe (que chamaremos de QuickSort) que contém três variáveis de instâncias: o vetor a ser ordenado e dois inteiros que indicam os índices inicial e final entre os quais desejamos que seja feita a ordenação no vetor. Esta classe aparece na Listagem 4. É óbvio que desejamos que o vetor completo seja ordenado. A utilidade desses dois índices ficará mais clara quando fizermos chamadas recursivas para ordenar os subvetores à esquerda e à direita do pivô. Listagem 4. A classe QuickSort e suas variáveis de instância. class QuickSort{ double [] vetor; int inicio; int fim; Escrevemos agora um método que chamaremos de partição. Esse método é o responsável pela escolha do pivô e pela organização do vetor, garantindo que valores menores ou iguais ao pivô aparecem à sua esquerda e todos os demais aparecem à sua direita. O método retorna o índice do vetor em que o pivô ficou armazenado após essa organização. Sua implementação aparece na Listagem 5. Listagem 5. A implementação do método partição. Fazemos a escolha do pivô e organizamos o vetor adequadamente, retornando o índice em que o pivô ficou armazenado. int particao(){ double pivo = vetor[fim]; int i = inicio - 1; for (int j = inicio; j < fim; j++){ if (vetor[j] <= pivo){ i++; 9 \
troca(i,j); troca(i+1, fim); return i + 1; O método particao faz uso de outro método chamado troca. O método troca recebe dois inteiros que representam índices no vetor e faz a troca dos valores contidos nesses índices. Sua implementação é dada na Listagem 6. Listagem 6. O método troca recebe dois parâmetros inteiros i e j e faz a troca dos valores contidos nas posições i e j do vetor. void troca(int i, int j){ double temp = vetor[i]; vetor[i] = vetor[j]; vetor[j] = temp; Adicionamos também um construtor que nos permite passar valores para as variáveis de instância, conforme mostra a Listagem 7. Listagem 7. Um construtor para a classe QuickSort que permite a passagem de valores para suas variáveis de instância. QuickSort(double [] vetor, int inicio, int fim){ this.vetor = vetor; this.inicio = inicio; this.fim = fim; Implementação utilizando recursos anteriores ao Java 7 Vamos escrever uma classe que emprega os recursos anteriores ao Java 7 para fazer a execução do algoritmo QuickSort em paralelo. Inicialmente, é feita a partição do vetor e em seguida submetemos ao pool de threads duas novas atividades: uma para ordenar o subvetor à esquerda do pivô e outra para ordenar o subvetor à direita do pivô. Essas duas atividades possivelmente serão executadas por threads diferentes do pool de threads. Chamaremos nossa classe de QSJava5. Ela tem uma variável de instância do tipo QuickSort e uma referência a um Executor- Service. Veremos que essa variável será importante quando tentarmos submeter subproblemas (a ordenação dos vetores à esquerda e à direita do pool) ao pool. Essa variável não será necessária na solução que desenvolvermos na seção utilizando recursos do Java 7, pois uma thread trabalhadora de um ForkJoinPool tem acesso ao pool a que pertence, o que não ocorre com uma thread pertencente a uma implementação simples de ExecutorService. Nossa classe implementa a interface Callable de modo que suas instâncias possam ser submetidas a um ExecutorService para execução. Poderíamos ter usado a interface Runnable também. Optamos por Callable, pois o método call de Callable permite o lançamento de exceção (o que pode acontecer ao chamar o método get de Future). Utilizando Runnable teríamos que envolver a chamada ao método get de Future em um bloco try/catch. O método call de Callable retorna um valor. Neste caso, não desejamos retorno algum, já que o resultado final (o vetor ordenado) estará armazenado no próprio vetor inicial, para o qual já temos uma referência. A Listagem 8 mostra a classe QSJava5 e suas variáveis de instância. Listagem 8. A classe QSJava5 e suas variáveis de instância. class QSJava5 implements Callable <Void>{ ExecutorService pool; QuickSort quicksort; Escrevemos um construtor que nos permita passar valores para as variáveis de instância da classe, conforme mostra a Listagem 9. Listagem 9. Um construtor para a classe QSJava5 que nos permite passar valores para suas variáveis de instância. Um objeto do tipo QuickSort é construído com valores adequados. QSJava5(double [] vetor, int inicio, int fim, ExecutorService pool ) { this.quicksort = new QuickSort(vetor, inicio, fim); this.pool = pool; E agora a parte principal. Sobrescrevemos o método call de Callable. O método verifica se o vetor a ser ordenado contém pelo menos dois elementos. Nesse caso, o início é estritamente menor do que fim. O pivô é escolhido e o vetor é organizado pelo método partição, cujo retorno é armazenado na variável meio. Neste momento, desejamos submeter ao pool duas novas atividades. Uma delas deve ordenar o subvetor [inicio..meio-1] e a outra deve ordenar o subvetor [meio+1..fim]. Essa é a razão pela qual precisamos de uma variável ExecutorService na nossa classe. Precisamos de acesso ao pool para fazer a chamada ao método submit, indicando as novas atividades a serem executadas. Essas atividades serão, possivelmente, executadas por diferentes threads do pool de threads. Além disso, antes que possamos considerar o vetor completamente ordenado, precisamos ter certeza de que os dois subvetores mencionados / 10
foram ordenados. Essa é a razão pela qual precisamos chamar o método get nos dois objetos Future que obtivemos ao submeter às subtarefas ao pool. O método get é bloqueante e só retorna quando a atividade associada àquele Future tiver sido executada completamente. A implementação do método call é mostrada na Listagem 10. Listagem 10. Implementação do método call. Caso o vetor tenha pelo menos dois elementos, aplicamos o método partição para selecionar o pivô e organizá- -lo adequadamente. Após isso, submetemos ao pool de threads duas novas tarefas: ordenar o subvetor [inicio.. meio-1] e ordenar o subvetor [meio+1]. As chamadas ao método get da interface Future são bloqueantes. Garantimos que o vetor somente será considerado completamente ordenado depois que os subvetores à direita e à esquerda do pivô estejam ordenados, ou seja, quando as chamadas ao método get retornarem. @Override public Void call() throws Exception { if (quicksort.inicio < quicksort.fim){ int meio = quicksort.particao(); Future <?> f1 = pool.submit( new QSJava5(quickSort.vetor,quickSort.inicio, meio - 1,pool)); Future <?> f2 = pool.submit( new QSJava5(quickSort.vetor, meio + 1, quicksort.fim,pool)); f1.get(); f2.get(); return null; Agora escrevemos um código cliente para testar nossa aplicação. Obtemos uma instância de ExecutorService usando o método estático Executors. newcachedthreadpool. Essa escolha é fundamental para o correto funcionamento de nosso algoritmo. Se escolhêssemos um pool com número fixo de threads, correríamos o risco de a execução nunca terminar. Por exemplo, suponha que utilizamos um pool de threads com uma única thread trabalhadora. A princípio, submetemos a tarefa completa ao pool, que obviamente será executada pela única thread trabalhadora existente. O que acontece quando submetermos as duas subtarefas ao pool? Essa única thread ficará esperando pelo resultado e nunca obterá, pois ela é a única que poderia fazer o processamento. Nossa escolha garante o funcionamento do algoritmo, mas tem um problema: um número arbitrário de threads pode ser adicionado ao pool até que o problema seja resolvido. Para vetores muito grandes, é possível que nosso programa termine com um indesejado OutOf- MemoryError. Testamos nossa implementação com o código mostrado na Listagem 11. Listagem 11. Código que testa nossa implementação paralela de QuickSort utilizando a API anterior ao Java 7. Geramos um vetor com números aleatórios e submetemos o problema inicial (ordenar o vetor a partir do índice 0 até o índice vetor.length 1 inclusive) ao pool de threads. A chamada ao método get é importante para garantir que a thread principal somente prossiga em sua execução após o problema ter sido resolvido por completo. Ao final, imprimimos o tempo gasto em milissegundos e fechamos o pool de threads. public static void main(string[] args)throws Exception{ Random random = new Random(); int n = 2000; double [] vetor = new double [n]; for (int i = 0; i < n; i++){ vetor[i] = random.nextdouble(); ExecutorService pool = Executors.newCachedThreadPool(); long inicio = System.currentTimeMillis(); Future <?> result = pool.submit(new QSJava5(vetor, 0, vetor.length - 1,pool)); result.get(); long fim = System.currentTimeMillis(); System.out.println((fim - inicio)); pool.shutdownnow(); Implementação utilizando recursos do Java 7 (RecursiveAction): Inicialmente, criaremos uma classe que herda de RecursiveAction e a chamaremos de QSJava7. Essa classe também precisa de uma variável de instância do tipo QuickSort, que oferece as implementações dos métodos partição e troca. Na seção anterior, implementamos o método call de Callable, que é o método executado pelas threads trabalhadoras do pool. Aqui, nossa classe herda de RecursiveAction e deve implementar o método compute, que é similar ao método call de Callable, mas não tem retorno algum. Sua implementação aparece na Listagem 12. Listagem 12. Implementação da classe QSJava7. Ela herda de RecursiveAction. Um construtor permite a passagem de valores para sua variável de instância. class QSJava7 extends RecursiveAction{ QuickSort quicksort; QSJava7(double [] vetor, int inicio, int fim){ quicksort = new QuickSort(vetor,inicio,fim); A classe QSJava7 representa uma atividade que pode ser executada em um ForkJoinPool. Na Listagem 13, fazemos a implementação do método abstrato 11 \
compute herdado de RecursiveAction. Listagem 13. Implementação do método compute herdado de RecursiveAction. Este método será executado por uma thread trabalhadora de um ForkJoinPool. @Override protected void compute() { if (quicksort.inicio < quicksort.fim){ int meio = quicksort.particao(); invokeall(new QSJava7( quicksort.vetor,quicksort.inicio, meio 1), new QSJava7(quickSort.vetor, meio + 1, quicksort.fim)); Graças ao método invokeall (herdado de RecursiveAction), não precisamos de uma referência ao pool de threads em que nossas atividades são executadas. Presume-se que a thread que executa o método invokeall seja uma thread trabalhadora pertencente a um pool de threads e ela deve ter uma referência ao pool a que pertence (a classe ForkJoinWorkerThread, que herda de Thread e representa threads trabalhadoras de um ForkJoinPool tem um método chamado getpool), de modo que possa submeter novas tarefas. Chamar o método invokeall a partir de uma thread que não pertença a um ForkJoinPool pode gerar uma RuntimeException. Em nossos testes, chamamos invokeall a partir da thread principal do programa, o que gerou uma ClassCastException. Internamente, o método tenta fazer um casting da thread que executa o método para o tipo ForkJoinWorkerThread. Na Listagem 14 mostramos um código cliente para testar nossa implementação. Listagem 14. Código cliente para testar nossa implementação que utiliza a API Fork/Join. Um ForkJoinPool é instanciado e submetemos uma instância de QsJava7 a ele. Obtemos um tempo de execução estimado e imprimimos o resultado na tela. public static void main(string[] args)throws Exception{ Random random = new Random(); int n = 2000; double [] vetor = new double [n]; for (int i = 0; i < n; i++){ vetor[i] = random.nextdouble(); ForkJoinPool poolforkjoin = new ForkJoinPool(); long inicio = System.currentTimeMillis(); poolforkjoin.invoke(new QSJava7(vetor, 0, vetor.length - 1)); long fim = System.currentTimeMillis(); System.out.println((fim - inicio)); Novamente geramos um vetor de números aleatórios para ser ordenado. Instanciamos um ForkJoin- Pool (repare que o tipo da variável de referência precisa ser ForkJoinPool também, para que possamos chamar o método invoke que recebe uma ForkJoin- Task) utilizando seu construtor padrão. O construtor cria um pool com o número de threads igual ao número de processadores disponíveis, obtido com Runtime.availableProcessors(). Uma versão sobrecarregada recebe um inteiro que indica o número de threads desejado. Esse número pode ser no máximo 32767. Passar um valor maior do que esse pode gerar uma RuntimeException (mais especificamente uma IllegalArgumentException). A chamada ao método invoke é bloqueante. A thread que o executa somente prossegue em sua execução após o término da atividade submetida ao pool. A documentação oficial sugere que se utilize esta API para problemas em que não existe necessidade de sincronização e memória compartilhada. Na nossa solução, apesar de todas as threads estarem atuando sobre o mesmo vetor, não existe memória compartilhada ou necessidade de sincronização. Primeiro aplicamos o particionamento (que atua sobre o vetor inteiro) e depois que ele foi particionado é que outras threads começam a atuar sobre intervalos diferentes do mesmo vetor. Para este problema, optamos pelo uso de RecursiveAction pela seguinte razão: apesar de necessitarmos de um resultado final (o vetor ordenado) não precisamos que ele seja retornado. As threads operam sobre o vetor original e o resultado final fica armazenado no próprio vetor, para o qual já temos uma referência. Problema 1 Conclusões A nova API Fork/Join torna simples elaborar soluções para problemas com estrutura recursiva. As medidas de tempos de execução realizadas não devem ser consideradas conclusivas. Em nossos testes, em geral, o código que usa a API Fork/Join sempre leva vantagem. Apesar disso, devemos atentar para o fato de que a implementação utilizando recursos do Java 5 faz uso de um pool de threads que criar threads arbitrariamente, o que é um processo custoso e certamente tem um impacto importante no tempo de execução do programa. Após a execução do programa que utiliza a API Fork/Join, pode ser interessante executar o método getstealcount de ForkJoinPool, que retorna uma estimativa do número de vezes em que houve roubo de trabalho entre as threads trabalhadoras do pool. Problema 2 Média móvel Nesta seção, introduzimos um novo problema: o cálculo da média móvel. Mostramos como implementar uma versão que não usa paralelismo e em seguida oferecemos uma possível implementação utilizando a API Fork/Join do Java 7. Fazemos também comparações quanto ao tempo de execução das duas versões. / 12
Média móvel A média móvel é muito utilizada em estatística e a motivação para essa seção surgiu de um estudo que estamos fazendo sobre a variação de preços de determinadas ações na bolsa de valores de São Paulo, onde calculamos o coeficiente correlacional (uma medida estatística que indica possíveis relações lineares entre variáveis aleatórias) entre essa medida e algumas outras variáveis. Suponha que temos um vetor de números reais (que chamaremos valores ) e um número inteiro representando um intervalo de interesse (que chamaremos janela ). O cálculo da média móvel consiste em calcular um novo vetor (que chamaremos resultados ) em que em cada posição i seja armazenado o valor da média aritmética das janelas posições anteriores a i do vetor valores. Por exemplo, considere o vetor valores mostrado na figura 3 e um valor de janela igual a 2. Figura 3. Vetor valores. 1 2 3 4 5 6 7 8 Neste caso, nosso vetor resultados consistiria dos valores mostrados na figura 4. Figura 4. Vetor resultados. 1.5 2.5 3.5 4.5 5.5 6.5 7.5 Como janela=2, não existem valores suficientes para o cálculo dos valores das duas primeiras posições do vetor resultados. A partir dali, cada posição contém a média aritmética das duas posições anteriores do vetor valores. Por exemplo, o valor 1.5 é a média aritmética dos valores 1 e 2. O valor 2.5 é a média aritmética dos valores 2 e 3. E assim por diante. O vetor resultados representa a média móvel dos valores contidos no vetor valores, para um intervalo de duas posições. Por simplicidade, em nossa implementação, as posições do vetor resultados para as quais não há dados suficientes para o cálculo serão mantidas vazias. Neste exemplo, além de calcular a média móvel, também vamos obter o valor máximo contido no vetor resultados. Fazemos isso com o intuito de ilustrar o uso da classe RecursiveTask, cujo método compute permite o retorno de um valor. Vamos implementar uma classe que chamaremos de MediaMovel. Essa classe contém as funcionalidades e dados que são comuns a ambas as implementações que faremos. Suas variáveis de instância são as seguintes. Dois vetores de double, um para os valores iniciais e outro para armazenar a média móvel. Duas variáveis inteiras, i e janela. A variável i representa o índice do vetor resultados para o qual desejamos calcular o valor. Ou seja, cada instância de MediaMovel será responsável pelo cálculo de uma das posições do vetor resultados. Organizamos o código dessa forma para tornar fácil a implementação da versão que utiliza a API Fork/Join. A classe MediaMovel com suas variáveis de instância aparece na Listagem 15. Listagem 15. A classe MediaMovel e suas variáveis de instância. class MediaMovel{ Double [] valores; Double [] resultados; int i; int janela; Além disso, a classe MediaMovel contém um método chamado calculamedia que faz o cálculo da média adequada para a posição i do vetor resultados. Sua implementação é mostrada na Listagem 16. Listagem 16. Implementação do método calculamedia. Seu objetivo é calcular o valor adequado para a posição i do vetor resultados e retornar esse valor. Double calculamedia(){ double resultado = 0; for (int j = i - 1; j >= i - janela; j--){ resultado += this.valores[j]; return (resultado / janela); Implementação simples Nesta seção mostramos uma possível implementação para o cálculo da média móvel. Esta implementação é puramente sequencial, não utilizamos processamento paralelo em momento algum. Na seção seguinte faremos uma implementação paralela utilizando a API Fork/Join para então comparar o tempo de execução de ambas. Criaremos uma classe chamada MediaMovelSerial que contém uma variável de instância do tipo MediaMovel e um método que chamamos calcula. Após o término do método calcula, o vetor resultados contém os valores que compõem a média móvel do vetor valores. Além disso, o método calcula retorna um valor real, que é o valor máximo contido no vetor resultados. Primeiramente calculamos o primeiro valor do vetor resultados e supomos que ele é o maior (afinal até então é o único). Usamos então um laço para calcular os valores restantes, atualizando o valor máximo até então a cada iteração, se necessário. Sua implementação é dada na Listagem 17. Listagem 17. A classe MediaMovelSerial faz uso de objetos da classe MediaMovel para fazer o cálculo de cada posição do vetor resultados. O cálculo de cada posição é feito em sequência, sem utilizar processamento paralelo. Ao final, o método calcula retorna o valor máximo contido no vetor de médias. 13 \
class MediaMovelSerial{ MediaMovel mediamovel; public Double calcula(){ Double maximo = this.mediamovel.calculamedia(); this.mediamovel.resultados[this. mediamovel.janela] = maximo; for (int k = this.mediamovel.janela + 1; k < this.mediamovel.resultados.length; k++){ this.mediamovel.resultados[k] = new MediaMovel(this.mediaMovel.valores,this.mediaMovel.resultados,k,this. mediamovel.janela).calculamedia(); maximo = Math.max(maximo, this.mediamovel.resultados[k]); return maximo; MediaMovelSerial(Double [] valores, Double [] resultados, int i, int janela){ this.mediamovel = new MediaMovel(valores, resultados, i, janela); Na Listagem 18 mostramos como um código cliente poderia fazer uso dessa classe. Ao final, verificamos quanto tempo em milissegundos demorou a execução. Listagem 18. Utilizando a classe MediaMovelSerial e verificando o tempo de execução para um vetor com valores aleatórios. public static void main(string[] args) { int janela = 2; Random random = new Random(); int n = 1000; Double [] valores = new Double[n]; for (int i = 0; i < n; i++){ valores[i] = random.nextdouble(); Double [] resultados = new Double[ valores.length + 1]; MediaMovelSerial m = new MediaMovelSerial(valores, resultados, janela, janela); long inicio = System.currentTimeMillis(); Double r = m.calcula(); long fim = System.currentTimeMillis(); System.out.println(fim - inicio); Implementação com Fork/Join Nesta seção, fazemos uso da classe MediaMovel desenvolvida para implementar um programa que calcula a média móvel de um conjunto de valores e retorna o valor máximo das médias, utilizando a API Fork/Join disponível a partir da versão 7 do Java. Para utilizar a API Fork/Join é interessante que se faça uma definição recursiva do problema que pretendemos resolver. Ou seja, como podemos definir o problema do cálculo da média móvel (e obtenção do valor máximo) em função de versões menores do mesmo problema. Vamos definir o problema da seguinte forma. Se a posição i do vetor de médias for a última posição do vetor, então quer dizer que basta fazer o cálculo da média móvel para esta posição e retornar o valor obtido, já que sendo o único ele deve ser o máximo. Caso contrário, precisamos calcular a média móvel da posição i e também do subvetor que começa no índice i+1 em diante. Utilizando esta estrutura desenvolveremos uma solução que submete ao pool de threads uma instância que resolve o problema para o subvetor [i+1..n] e devolve o valor máximo contido neste subvetor. Com este resultado em mãos, calculamos o valor para a posição i e verificamos qual o valor máximo, o valor na posição i ou o valor máximo contido no subvetor [i+1..n]. Escrevemos uma classe que chamaremos Media- MovelParalelo e que herda de RecursiveTask, pois desejamos submeter instâncias dessa classe a um ForkJoinPool e queremos obter um resultado ao final da execução. Nossa classe tem uma instância de MediaMovel e sobrescreve o método compute de RecursiveTask. No método compute, primeiro verificamos se o cálculo atual está sendo feito para a última posição do vetor de médias. Em caso positivo, simplesmente fazemos o cálculo, armazenando o valor obtido na posição adequada e retornamos esse mesmo valor. Caso contrário, criamos uma nova instância de MediaMovelParalelo que será submetida ao pool de threads, e deverá resolver o problema a partir do índice i + 1 do vetor de médias. A chamada m.fork() submete uma nova tarefa ao pool de threads. O método compute do objeto referenciado por m será eventualmente executado por uma das threads trabalhadoras do pool de threads. Essa chamada somente pode ser realizada por uma thread que pertença a um ForkJoin- Pool. É possível verificar isso se utilizando o método inforkjoinpool, herdado da classe RecursiveTask. A seguir, fazemos o cálculo para a posição i do vetor e finalmente fazemos uma chamada m.join(), que utilizamos para obter o resultado calculado pelo método compute do objeto referenciado por m. A chamada ao método join é bloqueante ela somente retorna uma vez que o método compute do objeto sobre o qual chamamos o método termina. Seu retorno é o valor calculado pelo método compute. Verificamos qual o valor maior aquele que calculamos para a posição i ou o valor máximo armazenado no subvetor[i+1..n] e retornamos esse valor. A Listagem 19 mostra a classe MediaMovelParalelo. Listagem 19. Implementação da classe MediaMovelParalelo que faz uso da API Fork/Join do Java 7 para resolver o problema utilizando processamento paralelo. / 14
class MediaMovelParalelo extends RecursiveTask<Double> { MediaMovel mediamovel; @Override protected Double compute() { if (this.mediamovel.i == this.mediamovel. resultados.length - 1){ this.mediamovel.resultados[this.mediamovel.i] = this.mediamovel.calculamedia(); return this.mediamovel. resultados[this.mediamovel.i]; MediaMovelParalelo m = new MediaMovelParalelo( mediamovel.valores,mediamovel. resultados,mediamovel.i + 1, mediamovel.janela); m.fork(); this.mediamovel.resultados[this.mediamovel.i] = this.mediamovel.calculamedia(); return Math.max( this.mediamovel. resultados[this.mediamovel.i], m.join()); Para utilizar esta classe, vamos gerar um vetor com valores aleatórios e instanciar um ForkJoinPool para submeter uma instância de MediaMovelParalelo. Essa primeira instância recebe como valor i o próprio valor de janela, já que antes da primeira posição em que há uma média, existem janela posições para as quais não há dados suficientes para o cálculo. Calculamos também o tempo total gasto em milissegundos para a execução do programa. Este código de teste é mostrado na Listagem 20. Listagem 20. Testando a classe MediaMovelParalelo. public static void main(string[] args) { int janela = 2; Random random = new Random(); int n = 1000; Double [] valores = new Double[n]; for (int i = 0; i < n; i++){ valores[i] = random.nextdouble(); Double [] resultados = new Double[ valores.length + 1]; ForkJoinPool pool = new ForkJoinPool(); MediaMovelParalelo m = new MediaMovelParalelo( valores, resultados, janela,janela); long inicio = System.currentTimeMillis(); Double r = pool.invoke(m); long fim = System.currentTimeMillis(); System.out.println(fim inicio)); A chamada ao método invoke de ForkJoinPool é bloqueante. O valor final somente será atribuído à variável r em main depois que o cálculo tiver sido feito por completo. Problema 2: Conclusões A solução desenvolvida na seção anterior mostra o uso dos principais recursos da API Fork/Join. A classe RecursiveTask fornece um mecanismo que torna muito fácil a obtenção de resultados calculados por threads trabalhadoras de um ForkJoinPool. Apesar de teoricamente esperarmos um tempo de execução menor para a implementação utilizando processamento paralelo quando comparado com o tempo do programa sequencial, nossos resultados na prática mostraram sempre uma leve vantagem para a implementação sequencial. Isso está relacionado ao fato de não haver o que geralmente se chama de tail call optimization [3]. Assim, nossa solução tem como principal intuito demonstrar o uso da classe RecursiveTask. Observamos também o fato de a solução sequencial poder ser melhorada, pois tendo o valor da média móvel do vetor na posição i-1 em mãos, podemos utilizá-lo para o cálculo na posição i, sem a necessidade de fazer o cálculo completo. Considerações finais Com este artigo esperamos ter ilustrado o uso das principais classes do pacote java.util.concurrent. Em especial, esperamos ter tornado claro como a nova API Fork/Join do Java 7 pode ser usada para a solução de problemas para os quais encontramos uma definição recursiva. Quando desejamos submeter novas tarefas a partir de uma thread que pertence ao pool para o qual desejamos fazer a submissão, a implementação ForkJoinPool oferece mecanismos não presentes em versões anteriores ao Java 7, que tornam simples essa submissão. Nossos testes de desempenho mostram que nem sempre uma implementação paralela leva vantagem sobre uma implementação sequencial. Em um próximo artigo pretendemos estudar com mais detalhes o funcionamento do algoritmo de roubo de trabalho implementado pela classe ForkJoinPool. Como funciona esse roubo de trabalho? Será que as tarefas são pré-alocadas para as threads trabalhadoras? Será que uma thread muito ocupada tem mecanismos para decidir dividir seu trabalho e permitir que outras threads a ajudem? /referências > [1] CORMEN, Thomas H; LEISERSON, Charles E.; RIVEST, Ronald L.; STEIN, Clifford. Introduction to Algorithms. 3 ed. Massachusetts Institute of Technology, 2009. > [2] Oracle and/or its affiliates. Java Platform, Standard Edition 7. 2012. Disponível em: <http://docs.oracle.com/ javase/7/docs/api/java/util/concurrent/package-summary. html>. Acesso em: 15 de Set. de 2012. > [3] http://en.wikipedia.org/wiki/tail_call Acesso em: 02 de out. de 2012. /agradecimentos > Agradeço à Luciene Rinaldi (lucienerinaldi@gmail.com) pela sua valorosa ajuda ao revisar o trabalho. 15 \