Programação Paralela e Distribuída em Java Lucilene Baêta Ferrão, Reinaldo Silva Fortes Universidade Presidente Antonio Carlos (UNIPAC) Barbacena – MG – Brasil [email protected], [email protected] Resumo. Este artigo descreve conceitos de programação paralela e distribuída, citando as vantagens e desvantagens dos principais padrões de bibliotecas disponíveis para este tipo de programação e expondo os recursos oferecidos pela linguagem de programação Java para facilitar a implementação desta classe de software. 1. Introdução A maioria dos sistemas operacionais hoje no mercado dá suporte à multitarefa, ou seja, o computador é capaz de executar diversas tarefas ao mesmo tempo. Cada um desses sistemas tem o seu tipo de multitarefa, preemptiva ou não, com ou sem multiprocessamento, e as atuais linguagens de programação, incluindo C e C++, não incluem recursos embutidos para expressar operações paralelas e precisam fazer chamadas às primitivas de multithreading do Sistema Operacional, exceto Java, que tem o suporte a multitarefa e distribuição embutidos na linguagem. A programação paralela pode ocorrer de forma distribuída, aumentando ainda mais a eficiência no processamento. Este tipo de programação, que em geral é uma tarefa difícil, torna-se consideravelmente mais simples com os diversos recursos oferecidos por Java. O objetivo deste trabalho é descrever conceitos de programação paralela e distribuída e expor os benefícios que este tipo de programação pode trazer, destacando os recursos que a linguagem de programação Java oferece para facilitar a implementação desta classe de software. Este artigo está organizado da seguinte forma: seção 2, onde é feita uma abordagem sobre programação paralela, inclusive dos conceitos de threads, multithreading e sincronização de threads; seção 3, que define programação distribuída, explicando a comunicação entre processos e as principais APIs de comunicação: Sockets e RPCs; seção 4, que descreve a programação paralela distribuída, citando os padrões de bibliotecas para este tipo de programação: PVM, MPI e HPF; seção 5, onde é feita uma abordagem de recursos oferecidos por Java para facilitar a programação paralela e distribuída; seção 6, que trata da API Java RMI e da IDL Java; seção 7, que faz uma abordagem sobre CORBA; e considerações finais. 2. Programação Paralela A Programação Paralela é uma estratégia que consiste na execução simultânea de partes distintas de uma mesma aplicação, minimizando tempo na obtenção de resultados de tarefas grandes e complexas [Cenapad 2005], [Siqueira 2005]. Para a execução de tarefas de forma paralela, os dados podem ser decompostos em pequenas tarefas, gerando diversos programas menores que serão distribuídos entre os processadores para execução simultânea, então se tem a chamada decomposição funcional; ou pode ocorrer a decomposição dos dados em grupos, que por sua vez serão distribuídos entre os processadores que executarão simultaneamente um mesmo programa, então se tem a chamada decomposição de domínio [Jacinto 2005]. O paralelismo pode ocorrer com memória compartilhada, que se caracteriza pela presença de mais de um processador compartilhando recursos de memória e disco de um mesmo computador e neste caso o sistema operacional é o responsável pela maior parte da complexidade, inclusive pelo controle de concorrência; ou com memória distribuída, onde quase não há compartilhamento de recursos entre processadores, e a aplicação é responsável por coordenar as tarefas e a coerência entre os diversos nós (computadores independentes com memória e disco próprios) do sistema. Todo programa tem seu fluxo de execução. Uma forma de programação paralela é usar mais de um fluxo de execução dentro de um mesmo programa, esse recurso é conhecido como multithreading. 2.1.Threads e Multithreading Uma thread (também denominada contexto de execução ou processo peso-leve) é um fluxo seqüencial único de execução dentro de um programa, que permite executar tarefas isoladamente. Definindo mais de uma thread em um mesmo programa, as tarefas que elas contêm podem ser executadas de maneira simultânea e independente umas das outras. Esse recurso é chamado multithreading, ou multiescalonamento e possibilita a realização de múltiplas atividades em paralelo, proporcionando melhoras evidentes no desempenho, principalmente de tarefas mais complexas. É eficaz em computadores com um único processador, no entanto é possível construir computadores ou sistemas de computadores com muitos processadores que trabalham em paralelo, maximizando ainda mais o desempenho [Deitel 2003], [Theóphilo 2004]. Em um ambiente monoprocessado, há, na realidade, uma simulação do paralelismo, onde alguma entidade (no caso de Java essa entidade é a Java Virtual Machine) fica responsável por escalonar o processador, que é único, para as várias threads do processo. O sistema operacional de qualquer plataforma multitarefa faz esse mesmo escalonamento, porém com processos ao invés de threads. Já em um ambiente multiprocessado, as várias threads podem ser escalonadas para diferentes processadores e nesse caso se tem uma aplicação verdadeiramente paralela, com as várias threads atuando ao mesmo tempo [Theóphilo 2004]. A programação multithreading permite uma utilização mais efetiva do processador em ambientes monoprocessados, além de permitir o paralelismo real em ambientes multiprocessados. Mas há um problema na utilização de várias threads dentro de uma mesma área de processo compartilhando acesso a dados. Pode, por exemplo, ocorrer de uma thread estar lendo o valor de uma variável enquanto outra a está atualizando, ou duas threads tentarem incrementar a mesma variável ao mesmo tempo, e muitos outros casos que podem gerar inconsistência dos dados devido ao acesso múltiplo e paralelo das threads. Para controlar isso, é necessária uma correta sincronização de acesso aos dados. 2.1.1. Sincronização Quando threads são disparadas dentro de uma mesma aplicação, é necessário sincronizá-las para evitar que dados compartilhados se tornem inconsistentes ou então que essas threads atuem em momentos errados. A sincronização trata da coordenação da execução das tarefas que estão sendo executadas em paralelo num determinado instante. Este processo, normalmente, implica na maioria dos custos e problemas em processamento paralelo [Jacinto 2005]. Existem duas formas de sincronização: de competição e de cooperação. Sincronização de competição ocorre quando duas ou mais threads competem pelo mesmo recurso compartilhado e por isso precisam se comunicar de alguma forma para que os dados não se tornem inconsistentes devido à concorrência das threads no acesso aos mesmos. Já a sincronização de cooperação ocorre quando o aspecto mais importante de duas ou mais threads não é a competição por um recurso, mas sim a comunicação entre elas para que uma atue num momento específico que depende de uma ação ou estado da outra [Theóphilo 2004]. 3. Programação Distribuída Programação Distribuída consiste na execução de aplicações cooperantes em computadores diferentes interligados por uma rede local, internet, ou por outra rede pública ou privada. É uma forma de programação geralmente muito utilizada em Sistemas Multimídia e em Computação Móvel [Siqueira 2005]. Para que as aplicações sejam realizadas em diferentes computadores, é necessário e fundamental que os computadores estejam conectados a uma rede qualquer que possibilite a comunicação entre essas aplicações. 3.1. Comunicação Entre Processos Processos e threads interagem para trabalhar conjuntamente em um sistema, seguindo protocolos de comunicação para que possam entender uns aos outros e assim trocar dados, como mostra a figura1 [Reis 2005]: Figura 1. Comunicação entre processos ou threads. Os protocolos estabelecem caminhos virtuais entre os processos ou threads. Duas entidades precisam usar os mesmos protocolos para trocar informações. A comunicação entre os processos pode acontecer de duas formas: aquela na qual o processo que envia a mensagem não retorna à execução normal enquanto não recebe um sinal de confirmação de entrega da mensagem ao destinatário, denominada Comunicação Síncrona, e aquela em que o processo que envia a mensagem não espera sinal algum, denominada Comunicação Assíncrona [Jacinto 2005], [Siqueira 2005]. Aplicações distribuídas usam os serviços de comunicação providos pela rede através de interfaces de programação (APIs). As funções presentes nestas APIs, possibilitam o estabelecimento de conexões, o envio e a recepção de dados através da rede de comunicação. As principais APIs de comunicação são conhecidas como sockets e RPCs [Albuquerque 2005], [Siqueira 2005]. 3.1.1. Sockets Socket representa um ponto de conexão em uma rede TCP/IP. Para dois computadores trocarem informações, cada um utiliza um socket. Um computador é o Servidor, que abre o socket e escuta a espera de conexões, e o outro é o Cliente, que chama o socket do Servidor para iniciar a conexão [Siqueira 2005]. Cada computador em uma rede TCP/IP possui endereço único (endereço IP) e portas que representam conexões individuais com esse endereço. Quando o socket é criado, deve ser associado com uma porta específica. Para estabelecer uma conexão, o cliente precisa conhecer o endereço IP da máquina e o número da porta em que o servidor executa [Albuquerque 2005]. Existem dois modos de operação: orientado a conexão (usam protocolo TCP – Transport Control Protocol) e sem conexão (usam protocolo UDP – User Datagram Protocol). Nos sockets orientados a conexão, esta deve ser estabelecida antes do envio dos dados e terminada ao final da comunicação, os dados chegam na mesma ordem que foram enviados. Já nos sockets sem conexão (baseados em datagramas), a entrega não é garantida e os dados podem chegar em ordem diferente da ordem em que foram enviados [Albuquerque 2005], [Siqueira 2005]. 3.1.2. Suportes de RPC RPC - Remote Procedure Call (Chamada Remota de Procedimento) é uma API de mais alto nível, através da qual o programador não precisa se preocupar com uma variedade de detalhes presentes na comunicação através de APIs como sockets. As RPCs possibilitam que as aplicações chamem procedimentos executados remotamente como se estivessem chamando procedimentos executados localmente. Os procedimentos são executados em máquinas remotas sem que o programador precise se envolver com os detalhes do processo de comunicação [Albuquerque 2005]. A RPC permite que um programa procedural (isto é, um programa escrito em linguagem de programação procedural) chame uma função que reside em outro computador tão convenientemente como se essa função fosse parte do mesmo programa que executa no mesmo computador. Um objetivo da RPC foi permitir aos programadores se concentrar nas tarefas exigidas de um aplicativo chamando funções e, ao mesmo tempo, tornar transparente para o programador o mecanismo que permite que as partes do aplicativo se comuniquem através de uma rede. A RPC realiza todas as funções de rede e ordenação dos dados (isto é, empacotamento de argumentos de função e valores de retorno para transmissão através de uma rede) [Deitel 2003]. Um programa com chamadas a procedimentos remotos é inicialmente analisado por um gerador de código responsável por substituir as chamadas aos procedimentos remotos por chamadas a procedimentos locais conhecidos como procedimentos stub. Estes procedimentos escondem do restante da aplicação, a complexidade envolvida na comunicação através da rede [Albuquerque 2005]. 4. Programação Paralela e Distribuída Baseados na idéia de paralelismo surgiram sistemas paralelos e distribuídos, sistemas compostos por várias máquinas interligadas por meio de algum tipo de rede para processar paralelamente uma determinada aplicação [Marzola 2005]. Existem diversas classes de problemas que exigem computação de alto desempenho e uma parcela significativa dessas aplicações pode ser programada de forma a subdividir a tarefa global em tarefas menores ou sub-tarefas, que mediante coordenação, podem ser alocadas cada uma a um processador distinto que executa em paralelo com os demais. É necessária certa coordenação e comunicação entre as subtarefas, comunicação esta que representa um custo em termos de desempenho. Na maioria dos casos, a transformação de uma aplicação seqüencial em paralela demanda a reprogramação da aplicação. A aplicação é dividida em sub-tarefas, que serão instanciadas em processadores remotos e cada uma delas deve receber dados de entrada e produzir dados de saída. Pode ser necessária uma interação entre as subtarefas durante o processamento. Em um multiprocessador, a comunicação entre tarefas é facilitada por uma memória física comum, de alta velocidade, mas que pode sofrer com diversos processadores competindo pelo mesmo recurso. Já em um sistema distribuído, a memória física se encontra distribuída entre os computadores, e a comunicação entre as sub-tarefas envolve, no mais baixo nível, a troca de mensagens através da rede [Barcellos 2005]. Para atender a essa demanda de programação, surgiram algumas bibliotecas de funções com o propósito de facilitar o desenvolvimento de aplicações paralelas e distribuídas. 4.1. Padrões de Bibliotecas para Uso do Paralelismo com Memória Distribuída O paralelismo com memória distribuída é composto de vários computadores ligados por intermédio de uma rede, mas, além da rede de comunicação, é necessária uma camada de software que possa gerenciar o uso paralelo das máquinas. Para tanto existem bibliotecas especializadas no tratamento da comunicação entre processos e na sincronização de processos concorrentes [Marzola 2005]. Essas bibliotecas fornecem aos programadores uma interface de programação (API) com funções de troca de mensagens, conversão de dados e, em alguns casos, gerência de processos remotos. Sua implementação é baseada em uma biblioteca a ser ligada ao aplicativo em questão, e um “substrato de comunicação”, ou seja, um processo servidor que roda em cada máquina e atende a requisições locais ou remotas solicitadas via biblioteca. Essa abordagem apresenta limitações: primeiro, o usuário deve lidar explicitamente com a existência de diferentes arquiteturas, convertendo dados entre diferentes representações (número de bytes e ordem dos mesmos); segundo, o usuário deve “empacotar” em mensagens os dados a serem transmitidos, transformando dados estruturados em vetores de bytes, cuidando aspectos como tamanho de vetores; terceiro, o paradigma explorado por esses pacotes é o imperativo, não sendo conhecidas soluções de alto desempenho baseadas em orientação a objetos [Barcellos 2005]. 4.1.1. PVM (Parallel Virtual Machine) O PVM é uma biblioteca que permite que um conjunto de computadores heterogêneos (diferentes tipos de máquinas) ou não, conectados a uma rede UNIX, possa ser visto como uma máquina virtual paralela com memória distribuída, ou seja, como um único recurso computacional [Marzola 2005]. O PVM se baseia em duas primitivas básicas envie mensagem e receba mensagem, de fácil utilização, mas não tão poderoso quando comparado com o MPI [Cenapad 2005]. 4.1.2. MPI (Message-Passing Interface) O MPI é um padrão utilizado para estabelecer comunicação, via troca de mensagens, entre processadores de sistemas de memória distribuída. A comunicação é realizada por chamadas explícitas às rotinas de comunicação do MPI, contidas em um programa escrito, pelo usuário, em linguagens C ou FORTRAN. O MPI tem opções mais avançadas que o PVM, como envio de mensagens broadcast (para todos os hosts do cluster) e multicast (para um grupo específico de hosts), assim como melhor controle sobre o tratamento que cada mensagem terá ao ser recebida por outro ponto do cluster. 4.1.3. HPF (High Performance Fortran) O HPF é uma extensão do FORTRAN 77 e 90, especialmente orientado ao paralelismo de dados que permite execução paralela com memória distribuída, automaticamente detectada pelo compilador e trabalha em vários tipos de arquiteturas para paralelismo. O HPF representa uma opção à programação paralela através dos sistemas de “message-passing” (como MPI e PVM). Esses sistemas de passagem de mensagem são considerados opções eficientes, possibilitando o desenvolvimento de programas com o melhor desempenho. Mas os sistemas de “message-passing” são apontados como principal obstáculo à difusão e ampla aceitação da programação paralela, dadas as dificuldades de programação através deles. Escrever programas paralelos através desses sistemas tende a envolver tarefas que se tornam bastante complicadas, consomem muito tempo de programação e propiciam em muito a ocorrência de erros. Costuma-se comparar os programas com “message-passing” à programação em linguagem de máquina. 5. Programação Paralela Distribuída em Java A busca pela exploração do paralelismo implícito, em contrapartida às alternativas anteriormente citadas, que usam construções específicas para tal fim, vem ao encontro da reutilização do software seqüencial já desenvolvido, bem como da facilidade de programação, uma vez que o compilador ou o ambiente de execução controla os aspectos relacionados à distribuição e ao particionamento. Essa busca já ocorre em vários paradigmas como os da programação declarativa (lógica e funcional), bem como orientada a objetos e abordagens multiparadigmas (que mesclam características de paradigmas básicos). As linguagens orientadas a objeto, em particular, possuem a facilidade de suportar mecanismos de distribuição e permitir a aplicação de técnicas que paralelizem programas existentes sem exigir alterações significativas na sintaxe. Isto se deve ao fato de os objetos serem, por definição, entidades autônomas [Barcellos 2005], [Deitel 2003]. O sucesso desta linguagem se deve principalmente a sua portabilidade, pois embora Java execute em apenas uma arquitetura, aquela implementada pela Java Virtual Machine – JVM (Máquina Virtual Java), a grande maioria das arquiteturas de hardware e sistemas operacionais existentes, possui uma implementação da JVM. O compilador java gera o bytecode para a JVM, que pode ser interpretado pelo interpretador de bytecode disponível para várias plataformas. Como a JVM especifica exatamente a representação de cada tipo de dado, programas em Java podem ser escritos e compilados da mesma maneira em todas as plataformas. Um aplicativo em Java pode “migrar” de uma plataforma para outra sem necessitar recompilação: o bytecode executa sobre uma JVM que garante uma interface uniforme entre as duas plataformas. Cabe à implementação da JVM para a arquitetura em questão realizar quaisquer conversões que sejam necessárias. Em termos de processamento paralelo e distribuído, Java representa um grande avanço por diversas razões. Em primeiro lugar, como dito anteriormente, devido a sua portabilidade, já que um aplicativo distribuído pode ser escrito como um único programa fonte Java compilado e então distribuído entre os computadores participantes e essa distribuição pode ocorrer de maneira integrada com a World Wide Web - WWW. Em segundo, porque Java oferece suporte à programação paralela e distribuída, através de construções embutidas na linguagem (mais precisamente, através de classes que são definidas como parte da JVM). Por exemplo, Java possui classes para comunicação via sockets (TCP e UDP) e sobre esses canais de comunicação, Java implementa a serialização (conversão de dados em conjunto de bytes para transmissão através da rede) de tipos de dados básicos e permite a construção de serializadores para objetos complexos; a serialização libera o usuário de ter que converter dados estruturados em arrays de bytes para transmissão através de mensagens na rede. Java oferece mecanismos de alto nível para controle de concorrência como acesso sincronizado (mutuamente exclusivo) a métodos de um objeto. Por fim, e mais importante ainda, Java implementa eficientemente multithreading e acaba com o problema de incompatibilidade entre pacotes de threads [Barcellos 2005], [Brandi 2004], [Deitel 2003]. Além de todas as vantagens citadas, Java oferece diversas classes para produção de interfaces gráficas, em particular o AWT – Abstract Window Toolkit e utiliza-se de um conceito já explorado por Smalltalk, que é o de garbage collection (coleta automática de lixo). O garbage collector é um exemplo de thread paralela de baixa prioridade que tem a função de varrer a memória de tempos em tempos, liberando automaticamente os blocos que não estão sendo utilizados. Ele evita problemas como referências perdidas e avisos de falta de memória quando sabe-se que há memória disponível. Em outras linguagens, como em C++, todo bloco de memória alocado dinamicamente, deve ser liberado quando não for mais usado, e isso acarreta diversos problemas mesmo ao programador mais experiente, que precisa manter sempre um controle das áreas de memória alocadas para poder liberá-las em seguida. Em Java, os programadores estão liberados desta tarefa e não necessitam preocupar-se com o gerenciamento de memória. Por todas essas razões, não surpreende o sucesso de Java como linguagem de desenvolvimento de novas aplicações, em particular as que envolvem múltiplas tarefas que cooperam, seja em configurações monoprocessadas, multiprocessadas ou distribuídas. Mas apesar das inúmeras vantagens da linguagem, Java perde em desempenho, pelo fato de ser uma linguagem interpretada, já que o processo de interpretação diminui o desempenho e também por fazer uso do garbage collector que pode deixar o aplicativo um pouco mais lento por manter uma thread paralela que dura todo o tempo de execução do programa. Mas essa perda de desempenho na execução de determinada tarefa, é compensada pelo aumento do paralelismo, pela portabilidade e a facilidade de distribuição de processamento. Atualmente já são observados diversos esforços da comunidade científica para resolver problemas da linguagem Java. Novas versões da máquina virtual apresentam técnicas de compilação no momento da execução (Just In Time Compilation – JIT). Com o uso de JIT, observam-se melhoras no desempenho da linguagem. Outras linhas de pesquisas tentam melhorar a coleta automática de lixo em Java e o suporte nativo a threads paralelas. A portabilidade da linguagem Java oferece aos usuários potenciais a possibilidade de contar com um recurso de programação distribuído sem a necessidade de compor uma arquitetura especial. Tal ambiente não será, portanto, restrito a um único tipo de arquitetura. Com a crescente aceitação de Java, uma solução nesses moldes possui grande potencial de aplicação. 5.1. Threads em Java Muitas linguagens não têm multithreading predefinido (como C e C++) e devem, portanto, fazer chamadas as primitivas de multithreading do sistema operacional. Java inclui essas primitivas como parte da própria linguagem, tornando a programação mais simples [Barcellos 2005], [Deitel 2003]. As threads em Java permitem o tratamento de concorrência (synchronized) e o tratamento de sincronização (wait e notify), como será mostrado posteriormente. Em uma thread Java, o código que realmente executa a tarefa é colocado dentro do método run, o qual é responsável pelo comportamento que a thread vai ter quando estiver sendo executada e deve estar presente em todas as threads. Qualquer tarefa pode ser implementada neste método [Brandi 2004], [Deitel 2003]. O método run pode ser sobrescrito em uma subclasse de Thread ou implementado em um objeto de uma importante interface Java, a interface Runnable, ou seja, Java oferece duas formas de implementação de threads: implementando a interface java.lang.Runnable ou estendendo a classe java.lang.Thread. 5.1.1. Implementando a interface Runnable A classe Thread implementa a interface Runnable que por sua vez pode ser implementada pelo programador para gerar threads [Theóphilo 2004]. Quando se cria threads implementando a interface Runnable, cada thread inicia sua vida executando o método run no objeto Runnable que foi passado à thread. O método run pode conter qualquer código, mas precisa ser público, não usar argumentos, não ter valor de retorno e não gerar exceções. Qualquer classe que contém um método run com estas características pode declarar que implementa a interface Runnable. Uma instância desta classe é então um objeto que pode servir como destino de uma nova thread, no entanto não basta somente definir o método run, é preciso invocá-lo para que a thread seja “despertada” e comece a sua execução, e quem desperta a thread é a invocação do método start, o qual tem uma única responsabilidade, que é invocar o método run definido. O método start só pode ser invocado uma única vez durante o tempo de vida de uma thread e a partir do momento em que ele é invocado, a thread permanecerá sendo executada até que o método run se encerre [Brandi 2004]. Os passos necessários para criar threads implementando a interface Runnable são: • declarar uma classe multithread que implementa Runnable e portanto, possui um método run; • instanciar o objeto multithread; • instanciar um objeto Thread e passar o objeto multithread como parâmetro no construtor; • invocar o método start da classe Thread. 5.1.2. Estendendo a classe Thread A técnica de criar threads estendendo a classe Thread guarda muitas semelhanças com a técnica já apresentada de implementar a interface Runnable, sendo que o método run implementado pela classe Thread deve ser obrigatoriamente substituído (uma vez que ele não faz absolutamente nada). Os passos necessários para criar threads estendendo a classe Thread, são: • declarar uma classe multithread que estenda (seja subclasse) da classe Thread; • criar um objeto desta classe; • invocar o método start desta classe. 5.1.3. Estados de Threads Há qualquer momento, uma thread encontra-se em um dos vários estados de threads, como mostra a figura2: Figura 2. Ciclo de vida de uma thread. Se uma thread acaba de ser criada, está no estado de nascimento e permanece nesse estado até que o programa chame o método start, o que faz com que a thread passe para o estado pronta. A thread pronta de maior prioridade passa para o estado em execução quando o sistema aloca um processador para ela. Uma thread entra no estado morta quando seu método run completa ou termina por alguma razão, e acaba sendo descartada pelo sistema em algum momento. Uma maneira comum de uma thread entrar no estado bloqueada é quando ela emite uma solicitação de entrada/saída, neste caso, após a conclusão da E/S, a thread volta ao estado pronta, para em seguida voltar a executar. Mesmo havendo processador disponível, a thread bloqueada não pode utilizalo. Quando o programa chama o método sleep em uma thread em execução, essa thread entra no estado adormecida, de onde vai para o estado pronta logo que o tempo designado para dormir expira. Como uma thread bloqueada, a thread adormecida não pode utilizar um processador mesmo que haja um disponível. Se o programa chama o método interrupt para uma thread adormecida, ela sai do estado adormecida e torna-se pronta para ser executada. Quando uma thread em execução chama wait, ela entra num estado de espera pelo objeto particular para o qual wait foi chamado e torna-se pronta quando uma chamada para notify é emitida por outra thread associada com aquele objeto. Todas as threads no estado de espera por um objeto dado tornam-se prontas quando uma chamada para notifyAll é feita por outra thread associada com aquele objeto [Deitel 2003]. 5.1.4. Sincronização e Controle de Concorrência em Java Algumas bibliotecas que suportam threads oferecem sincronização através de semáforo, que é uma construção de baixo nível que permite a sincronização, mas tem o inconveniente de ser mais propensa a erros por parte do programador. Uma outra forma de prover sincronização é através do uso de uma construção de mais alto nível chamada monitor. Algumas bibliotecas oferecem ambas as construções, no caso de Java a própria linguagem incorpora o conceito de monitores através da palavra-chave synchronized. Todos os objetos em Java possuem implicitamente um monitor associado a cada um deles. Java usa monitores para controlar a concorrência entre threads, ou seja, é através de operações sobre esses monitores que a sincronização em Java ocorre [Theóphilo 2004]. Quando um método com a palavra-chave synchronized é chamado ele tenta obter o monitor do objeto que contém este método para que este objeto seja então bloqueado. Caso alguma outra thread já tenha adquirido este monitor antes, a thread corrente é posta para esperar numa fila até que o monitor esteja disponível para ela. Somente um método synchronized pode atuar sobre um objeto de cada vez. Nenhum outro método synchronized chamado por outra thread poderá começar a execução até que a thread corrente libere o monitor. Se uma outra thread chamar neste objeto um método sem a palavra-chave synchronized, ele será executado normalmente e não esperará por liberação nenhuma, pois os monitores apenas controlam códigos que contenham a palavra-chave synchronized. Quando o método termina de ser executado, o objeto é liberado e o monitor permite que a thread pronta de maior prioridade invoque um método synchronized para prosseguir. A thread que está sendo executada em um método synchronized pode determinar que ela não pode prosseguir, de modo que a thread chama wait voluntariamente e passa para o estado de espera, deixando de disputar o objeto monitor, que fica disponível para as outras threads. Assim que uma thread que estava executando um método synchronized termina ou satisfaz a condição pela qual a thread original estava esperando, ela notifica (notify) a thread em espera para que esta se torne novamente pronta e possa tentar readquirir o bloqueio sobre o objeto monitor e iniciar a execução. O notify atua como um sinal para a thread em espera, de que a condição esperada está satisfeita e ela pode entrar novamente no monitor. Se uma thread chama notifyAll, então todas as threads em espera passam a disputar o monitor [Deitel 2003], [Theóphilo 2004]. Se cada thread de um conjunto está à espera de um evento que uma outra thread deste mesmo conjunto pode provocar, diz-se que o conjunto está num deadlock. Usando a linguagem de programação Java, isso poderia ocorrer nos seguintes casos: • quando uma chamada a wait não tem uma correspondente a notify, pois assim, uma thread pode esperar eternamente. Mas já existem versões do método wait que recebem argumentos indicando um tempo de espera máximo e se a thread não é notificada dentro do período de tempo especificado, ela fica pronta para ser executada [Deitel 2003]; • quando um bloqueio que ocorre com a execução de um método synchronized não é liberado nunca. Mas quando ocorrem exceções, o mecanismo de exceção de Java coordena com o mecanismo de sincronização de Java para liberar bloqueios de sincronização adequados. É mantida uma lista de todas as threads que esperam para entrar no objeto monitor para executar métodos synchronized. É importante distinguir os casos entre as threads em espera. Na conclusão de um método synchronized, as threads externas que bloquearam porque o monitor estava ocupado podem prosseguir para entrar no objeto e as threads que invocaram wait explicitamente só podem prosseguir quando forem notificadas através de uma chamada feita por outra thread para notify ou notifyAll. Nos casos em que é aceitável que uma thread em espera prossiga, o escalonador seleciona a thread com a prioridade mais alta. A palavra-chave synchronized é poderosa, no entanto deve ser utilizada de maneira criteriosa. Isso porque se ela for usada displicentemente, pode atrasar a execução da aplicação de maneira desnecessária, pois todos os outros blocos com esta palavra-chave ficarão a espera para executar. Apenas acesso a código compartilhado deve ser posto em blocos synchronized [Theóphilo 2004]. 5.2. Sockets Os programas em Java podem usar os serviços providos pela rede através de uma variedade de classes presentes no ambiente de desenvolvimento. As chamadas a funções de acesso remoto (sockets) são suportadas em Java de forma que a elaboração de aplicativos baseados em arquiteturas cliente-servidor é facilmente obtida [Albuquerque 2005], [Deitel 2003],. O socket Datagrama envia datagramas a outros sockets sem criar conexão (UDP). A comunicação baseada em datagramas é feita, em Java, através das classes DatagramPacket e DatagramSocket. Para enviar um datagrama, cria-se um datagramPacket e em seguida usa-se o método send de um datagramSocket. Para receber um datagrama, usa-se o método receive de um datagramSocket e um datagramPacket para identificar uma área de memória para recepção dos dados. O socket Stream é conectado a outro socket estabelecendo uma conexão (TCP). A comunicação orientada a conexão é feita, em Java, através das classes ServerSocket e Socket. O servidor usa a classe ServerSocket para aguardar conexões a partir dos clientes. Quando ocorre a conexão, a comunicação é efetivada através de um objeto da classe Socket. Estas classes escondem a complexidade presente no estabelecimento de uma conexão e no envio de dados através da rede, facilitando muito o trabalho do programador. 6. Java RMI A chamada remota de procedimentos é provida em Java de forma muito mais eficiente que nas RPCs através da API denominada Remote Method Invocation (RMI). Java RMI permite que objetos Java executando no mesmo computador ou em computadores separados se comuniquem entre si via chamadas de método remoto. Essas chamadas são muito semelhantes àquelas que operam em objetos no mesmo programa. A RMI como foi dito anteriormente, está baseada na tecnologia semelhante para programação procedural, chamada de RPC. Uma desvantagem de RPC é que ela suporta um conjunto limitado de tipos de dados simples. Portanto, a RPC não é adequada para passar e retornar objetos Java. Outra desvantagem da RPC é que ela exige do programador aprender uma linguagem de definição de interface (Interface Definition Language – IDL) especial para descrever as funções que podem ser invocadas remotamente [Deitel 2003]. A RMI é implementação da RPC por Java para comunicação distribuída de um objeto Java com outro. Uma vez que um método de um objeto Java é registrado como sendo remotamente acessível, um cliente pode “pesquisar” (“lookup”) esse serviço e receber uma referência que permita ao cliente utilizar tal serviço (isto é, chamar o método). A sintaxe da chamada de método é idêntica àquela de uma chamada para um método de outro objeto no mesmo programa. Como com a RPC, a ordenação dos dados é tratada pela RMI. Entretanto, a RMI oferece transferência de objetos de tipos de dados complexos via o mecanismo de serialização de objeto. A classe ObjectOutputStream converte qualquer objeto declarado como Serializable em um fluxo de bytes que pode ser transmitido através de uma rede. A classe ObjectInputStream reconstrói o objeto original para utilizar no método receptor. O programador não precisa se preocupar com a transmissão dos dados sobre a rede. A RMI não exige do programador aprender uma IDL porque todo o código de rede é gerado diretamente a partir das classes existentes no programa. Além disso, uma vez que a RMI suporta somente uma linguagem, Java, nenhuma IDL “neutra com relação à linguagem” é requerida; as próprias interfaces de Java são suficientes. Para a comunicação com outras linguagens, pode-se utilizar a IDL Java (introduzida no Java 1.2). A IDL Java permite que aplicativos e applets (programas Java projetados para serem transportados pela internet e executados em navegadores da WWW) se comuniquem com objetos escritos em qualquer linguagem que suporte CORBA (Common Object Request Broker Architecture), em qualquer lugar na WWW. 6.1. IDL Java IDL Java é uma tecnologia para objetos distribuídos, ou seja, objetos em diferentes plataformas interagindo através de uma rede. A vantagem do IDL Java é sua independência, ele permite que objetos interajam independentemente de terem sido escritos em Java ou em alguma outra linguagem. O CORBA e os mapeadores IDL são resultado do trabalho de um consórcio de indústrias conhecido como OMG (Object Management Group). A Sun é o membro fundador da OMG [Breve 2005], [Deitel 2003]. Java IDL é baseado no CORBA, que é um padrão de indústria para o modelo de objetos distribuídos. Uma característica chave do CORBA é o IDL (Interface Definition Language). Cada linguagem que suporta CORBA tem seu próprio mapeador IDL. O IDL Java suporta este mapeamento para Java. Para suportar a interação entre objetos em programas separados, o Java IDL tem o ORB (Object Request Broker). O ORB é uma biblioteca de classes Java que permite a comunicação de baixo-nível entre aplicações Java IDL e outras aplicações que suportam CORBA. 7. CORBA Qualquer relação entre objetos distribuídos tem dois lados: o cliente e o servidor. O servidor tem uma interface remota e o cliente chama essa interface. Estas relações são comuns para a maioria dos padrões de objetos distribuídos, incluindo RMI e CORBA. Os termos cliente e servidor definem a interação no nível de objeto antes da interação no nível de aplicação. Qualquer aplicativo pode ser um servidor para alguns objetos e um cliente de outros objetos. Um único objeto pode ser cliente de uma interface fornecida por um objeto remoto e ao mesmo tempo implementar uma interface para ser chamada remotamente por outros aplicativos [Breve 2005]. No lado do cliente, o aplicativo inclui uma referencia para o objeto remoto. O objeto referenciado tem um método que espera ser chamado remotamente. Este método na verdade está dentro do ORB, portanto a chamada ativa as capacidades de conexão do ORB, o qual passa a chamada para o servidor. No lado do servidor, o ORB usa um código para traduzir a chamada remota em uma chamada de método do objeto local. Este código transforma a chamada e qualquer parâmetro dela para o formato específico e então chama o método. Quando o método retorna, a resposta passa por este código que transforma os resultados ou erros, e os manda de volta para os clientes através dos ORBs. Entre os ORBs, a comunicação ocorre através de um protocolo compartilhado, o IIOP – Internet Inter-Orb Protocol, o qual é baseado no protocolo TCP/IP, e define como os ORBs do CORBA transferem informações. Da mesma forma que o CORBA e o IDL, o padrão IIOP também é definido pelo OMG. Além dessas capacidades mais simples de objetos distribuídos, os ORBs compatíveis com CORBA podem fornecer um número de serviços opcionais definidos pelo OMG. Isto inclui serviços para procurar objetos pelo nome, manter objetos persistentes, suportar processamento de transações, habilitar comunicação, e muitas outras utilidades nos ambientes de computação distribuída de hoje. Muitos dos Java ORBs de outros fabricantes suportam algumas ou todas estas características adicionais. O ORB fornecido com o Java IDL suporta um destes serviços adicionais, a capacidade de procurar um objeto pelo nome. 8. Considerações Finais Implementações paralelas e distribuídas são relativamente complexas, sendo geralmente eficientes em problemas grandes e difíceis, podendo até mesmo ser desfavoráveis em problemas pequenos e fáceis. Os padrões de biblioteca disponíveis para este tipo de programação são baseados em sistemas de message-passing que são eficientes, porém envolvem tarefas complicadas, que consomem muito tempo de programação e propiciam em muito a ocorrência de erros, além de explorar o paradigma imperativo, não oferecendo soluções baseadas em orientação a objetos. Através do estudo dos recursos que a linguagem de programação Java oferece para facilitar a programação paralela e distribuída, nota-se que esta linguagem significa uma etapa importante para a simplificação deste tipo de programação. Baseada em C e C++, Java é uma linguagem familiar, orientada a objetos, relativamente fácil de programar e o mais importante, portável. Por essas razões, Java vem se tornando uma das mais importantes linguagens para aplicações paralelas e distribuídas, apresentando pequenas limitações relacionadas a desempenho, que se tornam irrelevantes quando comparadas aos benefícios que a linguagem oferece. Para um melhor entendimento sobre o funcionamento da programação paralela em Java, encontra-se disponível no livro de Deitel referenciado neste trabalho, um estudo de caso que se trata de uma simulação de um elevador usando múltiplas threads. E pode ser baixado do site http://www.guj.com.br/java.tutorial.artigo.37.4.guj o exemplo Mensageiro, um exemplo de aplicação “Hello World” usando RMI, para que se entenda como usar este recurso de Java. Os interessados na área poderiam fazer uso dos recursos de Java expostos neste trabalho para desenvolver uma aplicação paralela e distribuída para o “Crivo de Eratóstenes”, que é um processo para obter números primos menores do que um determinado número natural n. Referências Breve, F. A. (2005) “O que é Java IDL?”, http://www.portaljava.com.br, Setembro. Albuquerque, F. (2005) “Programação Distribuída Usando Java”, http://www.cic.unb.br/docentes/fernando/matdidatico/textosintro/texto04.pdf, Setembro. Marzola, V., Morselli, J. C. M. J. e Duarte, M. (2005) “PVM: Uma Abordagem Teórica para Iniciantes”, http://www.unimar.br/ciencias/volume8-3/resumo8-3/resumo8.htm, Setembro. Cenapad, Unicamp - São Paulo. (2005) “Como Utilizar o Ambiente Paralelo?”, http://www.cenapad.unicamp.br/diversos/guia/job_paralelo.shtml, Setembro. Jacinto, D. S. (2005) “Conceitos básicos para o desenvolvimento de algoritmos paralelos utilizando bibliotecas de passagem de mensagem”, http://www.slackwarebrasil.org/pt/documentacao/programacaoparalela.html, Setembro. Barcellos, A. M. P. (2005) “Processamento Paralelo e http://www.epopeia.com.br/index.php?meio=antigo&nt=33, Agosto. Distribuído”, Reis, R. Q. (2005) “Comunicação Distribuída”, http://www.cultura.ufpa.br/quites/teaching/2004/CERC, Agosto. Siqueira, F. (2005) “Programação http://www.inf.ufsc.br/~frank, Agosto. Paralela e Distribuída”, Theóphilo, A. (2004) “Threads em Java”, Curso de Extensão em Programação em Java – UNIPAC, Outubro. Brandi, V. J. (2004) “Introdução às Java Threads”, Curso de Extensão em Programação em Java – UNIPAC, Outubro. Deitel, H. M. e Deitel, P. J. (2003) “Java, Como Programar”, Ed. Bookman, Porto Alegre.