Programação Paralela e Distribuída em Java

Propaganda
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.
Download