Nota: A simultaneidade é abordada de forma genérica aqui, já que ela pode se aplicar a qualquer
sistema. No entanto, ela é particularmente importante em sistemas que precisam reagir a eventos externos em
tempo real e que, freqüentemente, têm prazos apertados a serem cumpridos. Para lidar com as demandas
específicas desta classe de sistema, o Rational Unified Process (RUP) possui Extensões de Sistema
(Reativas) em Tempo Real. Para obter informações adicionais sobre este tópico, consulte Sistemas em Tempo Real.
|
A simultaneidade é a tendência de os fatos acontecerem ao mesmo tempo em um sistema. A simultaneidade é um fenômeno
natural, é claro. No mundo real, em um determinado momento, vários fatos acontecem simultaneamente. Quando projetamos o
software para monitorar e controlar sistemas do mundo real, devemos lidar com essa simultaneidade natural.
Quando lidamos com questões de simultaneidade em sistemas de software, há geralmente dois aspectos importantes: sermos
capazes de detectar e responder a eventos externos que estão ocorrendo aleatoriamente e assegurarmos que esses eventos
serão respondidos no intervalo de tempo mínimo exigido.
Se cada atividade simultânea fosse desenvolvida independentemente, de maneira realmente paralela, isso seria
relativamente simples: poderíamos simplesmente criar programas separados para lidar com cada atividade. Os desafios da
projeção de sistemas simultâneos surgem, na maioria das vezes, devido às interações que ocorrem entre as atividades
simultâneas. Quando as atividades simultâneas interagem, é necessário um pouco de coordenação.
Figura 1: Exemplo de simultaneidade no trabalho: atividades paralelas que não interagem apresentam questões
simples de simultaneidade. É quando as atividades paralelas interagem ou compartilham os mesmos recursos que as
questões de simultaneidade se tornam importantes.
O tráfego de veículos oferece uma analogia útil. Os streams de tráfego paralelos em rodovias diferentes e com pouca
interação trazem poucos problemas. Os streams paralelos em pistas adjacentes requerem um pouco de coordenação para que
se obtenha uma interação segura. No entanto, um tipo muito mais rigoroso de interação ocorre em um cruzamento, em que é
exigida uma coordenação cuidadosa.
Quando se fala em simultaneidade, algumas forças propulsoras são externas. Ou seja, elas são impostas pelas demandas do
ambiente. Em sistemas do mundo real, vários fatos acontecem simultaneamente e devem ser tratados 'em tempo real' pelo
software. Para fazer isso, muitos sistemas de software em tempo real devem ser "reativos". Eles devem responder a
eventos gerados externamente que podem ocorrer em momentos aleatórios, em ordem aleatória ou ambos.
Projetar um programa de procedimentos convencional para lidar com essas situações é extremamente complexo. Pode ser
muito mais simples dividir o sistema em elementos de software simultâneos para que eles lidem com cada um desses
eventos. A expressão-chave aqui é "pode ser", já que a complexidade também é afetada pelo grau de interação entre os
eventos.
Podem existir também razões internamente inspiradas para a simultaneidade [LEA97]. A
execução paralela de tarefas pode agilizar consideravelmente o trabalho computacional de um sistema se várias CPUs
estiverem disponíveis. Até mesmo em um único processador, a multitarefa pode agilizar muito as atividades, impedindo
que uma bloqueie a outra enquanto aguarda a E/S, por exemplo. Uma situação comum em que isso ocorre é durante a
inicialização de um sistema. Existem geralmente vários componentes e cada um deles requer tempo para que estejam
prontos para a operação. A execução seqüencial dessas operações pode ser terrivelmente lenta.
A capacidade de controle do sistema também pode ser aperfeiçoada pela simultaneidade. Por exemplo, uma função pode ser
iniciada, parada ou, de outro modo, influenciada no meio do fluxo por outras funções simultâneas - algo extremamente
difícil de realizar sem a existência de componentes simultâneos.
Com todos esses benefícios, por que não utilizamos uma programação simultânea em todas as situações?
A maioria dos computadores e das linguagens de programação é inerentemente seqüencial. Um procedimento ou processador
executa uma instrução em um determinado momento. Em um processador seqüencial único, a ilusão de simultaneidade deve
ser criada intercalando a execução de diferentes tarefas. As dificuldades nem estão tanto nos mecanismos utilizados
para realizar esse procedimento, mas na determinação de quando e como intercalar os segmentos de programa que podem
interagir uns com os outros.
Embora seja fácil obter a simultaneidade com vários processadores, as interações tornam-se mais complexas. Em primeiro
lugar, existe a questão da comunicação entre as tarefas executadas em diferentes processadores. Geralmente, há diversas
camadas de software envolvidas, que aumentam a complexidade e sobrecarregam o tempo de execução. O determinismo é
reduzido em sistemas de várias CPUs, já que os clocks e o tempo de execução podem ser diferentes e os componentes podem
falhar de modo independente.
Finalmente, os sistemas simultâneos podem ser mais difíceis de compreender pois não possuem um estado de sistema global
explícito. O estado de um sistema simultâneo é a agregação dos estados de seus componentes.
Como exemplo para ilustrar os conceitos que serão abordados, usaremos um sistema de elevador. Mais precisamente,
queremos nos referir a um sistema de computador projetado para controlar um grupo de elevadores em um determinado local
de um prédio. Obviamente, podem estar ocorrendo vários fatos simultaneamente em um grupo de elevadores - ou nenhum
fato! Em um determinado momento, alguém em qualquer andar pode estar solicitando um elevador, enquanto outras
solicitações poderão estar pendentes. Alguns elevadores poderão estar parados, ao passo que outros estarão
transportando passageiros, respondendo a uma chamada ou ambos. As portas devem abrir e fechar em momentos apropriados.
Os passageiros poderão estar obstruindo as portas, pressionando os botões de abertura ou fechamento de portas ou
selecionando os andares e, em seguida, mudando de idéia. Os visores precisam ser atualizados, os motores precisam ser
controlados, etc., tudo isso sob a supervisão do sistema de controle de elevadores. Em geral, esse é um bom modelo para
explorar os conceitos de simultaneidade e para o qual compartilhamos um grau razoavelmente comum de compreensão e um
vocabulário funcional.
Figura 2: Um cenário que envolve dois elevadores e cinco possíveis passageiros distribuídos em 11 andares.
Como os possíveis passageiros solicitam o sistema em diferentes momentos, ele tenta fornecer o melhor serviço global
selecionando elevadores que responderão às chamadas com base em seus estados atuais e nos tempos de resposta
projetados. Por exemplo, quando o primeiro passageiro, Andy, chama um elevador para descer, os dois estão ociosos e,
portanto, o que está mais próximo, o Elevador 2, responde, embora ele deva primeiro subir para chegar até Andy. Por
outro lado, poucos momentos depois, quando o segundo passageiro, Bob, solicita um elevador para subir, o Elevador 1, o
mais distante, responde, pois se sabe que o Elevador 2 descerá primeiro para um andar ainda não conhecido e somente
depois poderá responder a qualquer chamada vinda dos andares de cima.
Se o sistema de elevador tivesse apenas um elevador que precisasse atender somente a um passageiro por vez, poderíamos
nos ver tentados a achar que seria possível manipulá-lo com um programa seqüencial normal. Mesmo nesse caso "simples",
o programa exigiria várias ramificações para acomodar as diferentes condições. Por exemplo, se o passageiro nunca
entrasse no elevador e selecionasse um andar, deveríamos redefinir o elevador para permitir que ele respondesse a uma
outra chamada.
O requisito normal para manipular chamadas de vários passageiros possíveis e as solicitações de vários passageiros
exemplifica as forças propulsoras externas da simultaneidade que discutimos anteriormente. Como os possíveis
passageiros conduzem suas próprias vidas simultaneamente, eles solicitam o elevador em momentos aparentemente
aleatórios, não importando qual seja o estado do elevador. É extremamente difícil projetar um programa seqüencial que
possa responder a qualquer um desses eventos externos a qualquer momento e, ao mesmo tempo, continuar conduzindo o
elevador de acordo com as decisões passadas.
Para projetar sistemas simultâneos efetivamente, devemos ser capazes de raciocinar sobre a função da simultaneidade no
sistema e, para isso, precisamos de abstrações da própria simultaneidade.
Os blocos de construção fundamentais dos sistemas simultâneos são as "atividades", que agem de forma mais ou menos
independente umas das outras. Uma abstração gráfica útil para refletir sobre essas atividades são os "encadeamentos de
tempo" de Buhr. [BUH96] Nosso
cenário de elevador na Figura 3 utilizou, na verdade, uma forma deles. Cada atividade é representada como uma linha
pela qual a atividade viaja. Os pontos grandes representam o local em que uma atividade inicia ou aguarda um evento
antes de continuar. Uma atividade pode disparar outra para continuar, o que é representado na notação de thread de
tempo tocando o local de espera do outro thread de tempo.
Figura 3: Uma visualização dos encadeamentos de execução
Os blocos de construção básicos do software são os procedimentos e as estruturas de dados, mas eles sozinhos são
inadequados para uma reflexão sobre simultaneidade. Quando o processador executa um procedimento, ele segue um caminho
específico, dependendo das condições atuais. Esse caminho pode ser chamado de "encadeamento de execução" ou
"encadeamento de controle". Esse thread de controle pode assumir diferentes ramificações ou loops, dependendo das
condições existentes no momento e, em sistemas de tempo real, ele pode ficar parado por um período específico ou
aguardar um tempo programado para retomar a execução.
Do ponto de vista do designer do programa, o thread de execução é controlado pela lógica do programa e programado pelo
sistema operacional. Quando o designer do software opta por ter um procedimento chamando outros, o thread de execução
salta de um procedimento para outro, retornando para continuar de onde ele parou quando uma instrução de retorno é
encontrada.
Do ponto de vista da CPU, há somente um thread principal de execução que percorre todo o software, complementado por
curtos threads separados que são executados em resposta às interrupções de hardware. Como tudo é criado nesse modelo, é
importante que os designers o conheçam. Os designers de sistemas em tempo real, muito mais que os designers de outros
tipos de software, devem compreender detalhadamente como um sistema funciona. Esse modelo, no entanto, está em um nível
tão inferior de abstração que só pode representar uma granularidade de simultaneidade muito comum, a da CPU. Para
projetar sistemas complexos, será muito útil conseguir trabalhar em vários níveis de abstração. A abstração é,
obviamente, a criação de uma visão ou de um modelo que suprime os detalhes desnecessários, a fim de que possamos
enfocar perfeitamente o que é importante para o problema.
Para avançarmos um nível, normalmente pensamos no software em termos de camadas. No nível mais básico, o Sistema
Operacional (SO) é disposto em camadas entre o hardware e o software aplicativo. Ele fornece ao aplicativo serviços
baseados em hardware, como a memória, a temporização e a E/S, mas abstrai a CPU para criar uma máquina virtual que seja
independente da configuração real de hardware.
Para oferecer suporte à simultaneidade, um sistema deve fornecer vários threads de controle. A abstração de um thread
de controle pode ser implementada de várias maneiras pelo hardware e pelo software. Os mecanismos mais comuns são
variações de um destes itens [DEI84], [TAN86]:
-
Multiprocessamento - várias CPUs executando simultaneamente
-
Multitarefa - os sistemas operacionais simulam simultaneidade em uma única CPU
intercalando a execução de diferentes tarefas
-
Soluções baseadas em aplicativos - o software aplicativo assume a responsabilidade de
alternar entre as diferentes ramificações de código em tempos apropriados
Quando o sistema operacional é multitarefa, uma unidade comum de simultaneidade é o processo. Um processo é uma
entidade fornecida, suportada e gerenciada pelo sistema operacional cuja finalidade única é oferecer um ambiente em que
um programa possa ser executado. O processo fornece um espaço de memória para uso exclusivo do programa aplicativo, um
thread de execução para executá-lo e, talvez, alguns meios para enviar e receber mensagens de outros processos. Na
prática, o processo é uma CPU virtual para execução de uma parte simultânea de um aplicativo.
Cada processo tem três estados possíveis:
-
bloqueado - aguardando para receber alguma entrada ou o controle sobre algum recurso;
-
pronto - aguardando o sistema operacional conceder a ele a vez para que inicie a execução;
-
em execução - utilizando realmente a CPU.
Os processos geralmente recebem prioridades relativas. O kernel do sistema operacional determina qual processo será
executado em um determinado momento com base em seus estados, em suas prioridades e em uma política de programação. Na
verdade, os sistemas operacionais multitarefas compartilham um único thread de controle entre todos os seus processos.
Nota: Os termos 'tarefa' e 'processo' são geralmente utilizados de modo intercambiável. Infelizmente, o termo
'multitarefa' é geralmente usado para exprimir a capacidade de gerenciar vários processos de uma só vez, enquanto
'multiprocessamento' se refere a um sistema com vários processadores (CPUs). Adotamos essa convenção porque é a mais
comumente aceita. No entanto, utilizamos o termo 'tarefa' com moderação e, quando isso acontece, é para fazer uma
pequena distinção entre a unidade de trabalho que está sendo executada (a tarefa) e a entidade que fornece os recursos
e o ambiente para ela (o processo).
Dissemos antes que, do ponto de vista da CPU, existe somente um thread de execução. Assim como um programa aplicativo
pode saltar de um procedimento para outro chamando sub-rotinas, o sistema operacional pode transferir o controle de um
processo para outro, caso ocorra uma interrupção, um procedimento seja concluído ou ocorra qualquer outro evento.
Devido à proteção de memória oferecida por um processo, essa "alternância entre tarefas" pode acarretar uma sobrecarga
considerável. Além do mais, como a política de programação e os estados de processo têm pouco a oferecer do ponto de
vista do aplicativo, a intercalação de processos é geralmente um nível muito inferior de abstração para refletirmos
sobre o tipo de simultaneidade que é importante para o aplicativo.
Para refletirmos claramente sobre a simultaneidade, é importante fazer a distinção entre o conceito de um thread de
execução e o da alternância entre tarefas. Cada processo pode ser considerado como mantenedor de seu próprio thread de
execução. Quando o sistema operacional alterna entre processos, um thread de execução é temporariamente interrompido e
o outro inicia ou retoma de onde o primeiro parou.
Muitos sistemas operacionais, particularmente aqueles utilizados para aplicativos em tempo real, oferecem uma
alternativa "mais leve" aos processos: os "encadeamentos" ou "encadeamentos simples"
Os threads são uma forma de obter uma granularidade levemente mais fina de simultaneidade em um processo. Cada thread
pertence a um único processo e todos os threads de um processo compartilham um espaço de memória único e os outros
recursos controlados por esse processo.
Geralmente, cada thread recebe um procedimento para executar.
Nota: É lamentável que o termo 'encadeamentos' esteja sobrecarregado. Quando usamos a palavra 'thread'
propriamente, como fazemos aqui, estamos nos referindo a um 'thread físico' fornecido e gerenciado pelo sistema
operacional. Quando mencionamos um 'thread de execução', 'thread de controle' ou 'thread de tempo' como na discussão
anterior, queremos dizer uma abstração que não é necessariamente associada a um thread físico.
Obviamente, a existência de vários processadores oferece a oportunidade de uma execução verdadeiramente simultânea.
Muito freqüentemente, cada tarefa é atribuída de modo permanente a um processo em um processador específico, mas, em
algumas circunstâncias, as tarefas podem ser atribuídas de forma dinâmica ao próximo processador disponível. Talvez, a
maneira mais acessível de fazer isso seja utilizando um "multiprocessador simétrico". Em uma configuração de hardware
desse tipo, várias CPUs podem acessar a memória por meio de um barramento comum.
Os sistemas operacionais que oferecem suporte a multiprocessadores simétricos podem, de forma dinâmica, atribuir
threads a qualquer CPU disponível. Exemplos de sistemas operacionais que oferecem suporte a multiprocessadores
simétricos são o Solaris da SUN e o Windows NT da Microsoft.
Anteriormente, fizemos as afirmações aparentemente paradoxais de que a simultaneidade tanto aumenta como diminui a
complexidade do software. O software simultâneo oferece soluções mais simples para problemas complexos, basicamente
porque ele permite uma "separação de interesses" entre atividades simultâneas. Nesse sentido, a simultaneidade é apenas
mais uma ferramenta que aumenta a modularidade do software. Quando um sistema precisa executar atividades ou responder
a eventos predominantemente independentes, atribuí-los a componentes simultâneos individuais naturalmente simplifica o
design.
As complexidades adicionais associadas ao software simultâneo são quase que inteiramente provocadas por situações em
que essas atividades simultâneas são quase, mas não totalmente, independentes. O seja, as complexidades surgem de suas
interações. Do ponto de vista prático, as interações entre atividades assíncronas envolvem invariavelmente a
troca de algumas formas de sinais ou informações. As interações entre threads simultâneos de controle trazem à tona um
conjunto de questões que são exclusivas dos sistemas simultâneos e que devem ser tratadas para garantir que um sistema
se comportará corretamente.
Embora haja várias realizações específicas da IPC (comunicação entre processos) ou dos mecanismos de comunicação entre
threads, elas podem ser finalmente classificadas em duas categorias:
Em comunicação assíncrona, a atividade de envio redireciona suas informações independentemente de o receptor
estar ou não pronto para recebê-las. Após o envio das informações para seu destino, o emissor continuará realizando
suas tarefas normais. Se o receptor não estiver pronto para receber as informações, estas serão colocadas em alguma
fila onde o receptor possa recuperá-las posteriormente. O emissor e o receptor funcionam de modo assíncrono
reciprocamente e, conseqüentemente, não podem fazer suposições sobre o estado um do outro. A comunicação assíncrona é
geralmente chamada de transmissão de mensagens.
A comunicação síncrona inclui a sincronização entre o emissor e o receptor, além da troca de informações.
Durante a troca de informações, as duas atividades simultâneas se fundem executando, na verdade, um segmento
compartilhado de código. Depois, quando a comunicação é concluída, elas se dividem novamente. Portanto, durante esse
intervalo, elas ficam sincronizadas e livres de quaisquer conflitos entre elas. Se uma atividade (emissor ou receptor)
estiver pronta para se comunicar antes da outra, ela ficará suspensa até que a outra fique pronta também. Por esse
motivo, este modo de comunicação é, algumas vezes, chamado de rendezvous.
Um possível problema da comunicação síncrona é que, enquanto uma atividade aguarda a disponibilidade de seu par, ela
não é capaz de reagir a nenhum outro evento. Em vários sistemas em tempo real, isso nem sempre é aceitável, pois talvez
não seja possível garantir que uma resposta a uma situação importante chegará a tempo. Uma outra desvantagem é que esse
tipo de comunicação está propenso a conflito. Um conflito ocorre quando duas ou mais atividades estão envolvidas
em um círculo vicioso de espera uma da outra.
Quando existe a necessidade das interações entre atividades simultâneas, o designer deve optar entre um estilo síncrono
ou assíncrono. Por síncrono, queremos dizer que dois ou mais threads simultâneos de controle devem se encontrar em um
momento específico. Isso geralmente significa que um thread de controle deve aguardar outro para responder a uma
solicitação. A forma mais simples e mais comum de interação síncrona ocorre quando a atividade simultânea A requer
informações da atividade simultânea B para prosseguir com seu próprio trabalho.
As interações síncronas são, é claro, a regra para os componentes de software não simultâneos. As chamadas de
procedimento comuns são um ótimo exemplo de interação síncrona: quando um procedimento chama outro, o responsável pela
chamada transfere imediatamente o controle para o procedimento chamado e efetivamente "aguarda" o controle voltar para
ele. No mundo da simultaneidade, no entanto, é necessário ter mecanismos adicionais para sincronizar threads de
controle independentes.
As interações assíncronas não requerem um rendezvous de tempo, mas ainda requerem um mecanismo adicional para oferecer
suporte à comunicação entre dois threads de controle. Geralmente, esse mecanismo assume a forma de canais de
comunicação com filas de mensagens, a fim de que as mensagens possam ser enviadas e recebidas de modo assíncrono.
Observe que um único aplicativo pode combinar a comunicação síncrona e a comunicação assíncrona, e usará uma ou outra
dependendo do seguinte: se é necessário aguardar uma resposta ou se ele tem outro trabalho que possa realizar enquanto
o receptor da mensagem está processando a mensagem.
Tenha em mente que a simultaneidade verdadeira dos processos ou encadeamentos somente é possível em multiprocessadores
com execução simultânea de processos ou encadeamentos. Em um processador único, a ilusão da execução simultânea de
encadeamentos ou processos é criada pelo programador do sistema operacional, que fragmenta os recursos de processamento
disponíveis em pequenas partes para dar a impressão de que há vários encadeamentos ou processos sendo executados
simultaneamente. Um design simples anulará essa fatia de tempo, criando vários processos ou encadeamentos que se
comunicarão de modo freqüente e síncrono, fazendo com que eles despendam grande parte da sua "fatia de tempo"
efetivamente bloqueada e aguardando uma resposta de outro processo ou encadeamento.
As atividades simultâneas podem depender de recursos escassos que devem ser compartilhados entre elas. Exemplos comuns
são os dispositivos de E/S. Se uma atividade exigir um recurso que esteja sendo usado por outra atividade, ela deve
aguardar seu retorno.
Talvez, a questão mais importante do design do sistema simultâneo seja evitar "condições de competição". Quando parte
de um sistema precisa executar funções que dependam de um estado (ou seja, funções cujos resultados dependam do estado
atual do sistema), ele deve ter a certeza de que o estado se manterá consistente durante a operação. Em outras
palavras, determinadas operações devem ser "atômicas". Sempre que dois ou mais encadeamentos de controle tiverem acesso
às mesmas informações de estado, será necessária uma forma de "controle de simultaneidade", a fim de assegurar que um
encadeamento não modifique o estado enquanto o outro estiver executando uma operação atômica dependente de estado. As
tentativas simultâneas de acesso às mesmas informações de estado que podem tornar o estado internamente inconsistente
são denominadas "condições de competição".
Um exemplo comum de condição de competição poderia facilmente ocorrer no sistema de elevador quando um andar fosse
selecionado por um passageiro. Nosso elevador funciona com listas dos andares que serão visitados ao percorrer cada
direção, para cima e para baixo. Sempre que o elevador chega a um andar, um thread de controle remove esse andar da
lista apropriada e obtém o próximo destino da lista. Se a lista estiver vazia, o elevador mudará de direção, caso a
outra lista contenha andares, ou ficará ocioso, se as duas listas estiverem vazias. Um outro thread de controle é
responsável por inserir as solicitações de andar na lista apropriada quando os passageiros escolhem seus andares. Cada
encadeamento está executando combinações de operações na lista que não são inerentemente atômicas: por exemplo,
verificando o próximo slot disponível e ocupando-o. Se os threads intercalarem suas operações, eles poderão
sobrescrever facilmente o mesmo slot na lista.
O conflito é uma condição em que dois threads de controle ficam bloqueados: um aguardando o outro executar alguma ação.
Ironicamente, o conflito ocorre, em geral, porque aplicamos algum mecanismo de sincronização para evitar condições de
competição.
O exemplo do elevador como uma condição de competição poderia facilmente ocasionar um caso relativamente benigno de
conflito. O thread de controle do elevador pensa que a lista está vazia e, assim, nunca visita um outro andar. O thread
de solicitação de andar pensa que o elevador está trabalhando para esvaziar a lista e que, portanto, não é necessário
instruir o elevador a sair do estado ocioso.
Além das questões "fundamentais", existem algumas questões práticas que devem ser tratadas explicitamente no design do
software simultâneo.
Em uma única CPU, os mecanismos necessários para simular a simultaneidade por meio da alternância entre tarefas usam
ciclos de CPU que podem ser utilizados no próprio aplicativo. Por outro lado, se o software precisar aguardar os
dispositivos de E/S, por exemplo, as melhorias de desempenho proporcionadas pela simultaneidade podem ser muito mais
importantes que qualquer sobrecarga acrescentada.
O software simultâneo requer mecanismos de coordenação e controle que não são necessários aos aplicativos de
programação seqüenciais. Esses mecanismos tornam o software simultâneo mais complexo e aumentam as oportunidades de
erros. Os problemas nos sistemas simultâneos também são inerentemente mais difíceis de diagnosticar devido aos vários
threads de controle. Por outro lado, como já dissemos antes, quando as forças propulsoras externas são simultâneas, o
software simultâneo que manipula os vários eventos de forma independente pode ser muito mais simples do que um programa
seqüencial que precise acomodar os eventos em ordem arbitrária.
Como diversos fatores determinam a intercalação da execução dos componentes simultâneos, o mesmo software pode
responder à mesma seqüência de eventos em uma ordem diferente. Dependendo do design, essas mudanças na ordem podem
produzir resultados diferentes.
O software aplicativo pode ou não estar envolvido na implementação do controle da simultaneidade. Existe todo um
espectro de possibilidades, incluindo as seguintes, por ordem de envolvimento crescente:
-
As tarefas de aplicativo podem ser interrompidas a qualquer momento pelo sistema operacional (multitarefa
preemptiva).
-
As tarefas de aplicativo podem definir unidades de processamento atômicas (seções críticas) que não devem ser
interrompidas e informar ao sistema operacional quando elas forem ativadas e desativadas.
-
As tarefas de aplicativo podem decidir quando renunciarão ao controle da CPU em outras tarefas (multitarefa
cooperativa).
-
O software aplicativo pode assumir total responsabilidade pela programação e controle da execução de várias
tarefas.
Essas possibilidades não são um conjunto exaustivo nem são mutuamente exclusivas. Em um dado sistema, uma combinação
dessas possibilidades pode ser empregada.
Um erro comum no design do sistema simultâneo é selecionar os mecanismos específicos a serem usados para simultaneidade
logo no início do processo de design. Cada mecanismo oferece vantagens e desvantagens. A seleção do "melhor" mecanismo
para uma situação específica é geralmente determinada pelas sutis compensações e compromissos. Quanto mais cedo um
mecanismo for escolhido, haverá menos informações para basear a seleção. A escolha do mecanismo também tende a reduzir
a flexibilidade e a capacidade de adaptação do design nas diferentes situações.
Assim como acontece com as tarefas de design mais complexas, a simultaneidade é melhor compreendida usando vários
níveis de abstração. Primeiro, os requisitos funcionais do sistema devem ser bem compreendidos no que diz respeito ao
comportamento desejado. Depois, os possíveis papéis da simultaneidade devem ser explorados. A melhor maneira de fazer
isso é utilizando a abstração de threads sem se comprometer com uma implementação específica. A seleção final dos
mecanismos de simultaneidade deve permanecer, o máximo possível, em aberto, a fim de permitir o ajuste fino do
desempenho e a flexibilidade para distribuir os componentes de modo diferente nas várias configurações do produto.
A "distância conceitual" entre o domínio do problema (por exemplo, um sistema de elevador) e o domínio da solução
(construções de software) permanece como uma das maiores dificuldades do design do sistema. Os "formalismos visuais"
são extremamente úteis para compreender e comunicar idéias complexas, como o comportamento simultâneo e, na prática,
transpor essa brecha conceitual. Entre as ferramentas que comprovaram ser valiosas na solução desses problemas estão:
-
os diagramas de módulos que prevêem os componentes que estão atuando simultaneamente;
-
os threads de tempo que prevêem atividades simultâneas e interativas (que podem ser ortogonais aos componentes);
-
os diagramas de seqüências que visualizam interações entre componentes;
-
os fluxogramas de transição de estado que definem os estados e os comportamentos dependentes de estado dos
componentes.
Para projetar um sistema de software simultâneo, devemos combinar os tijolos de construção do software (procedimentos e
estruturas de dados) com os tijolos de construção da simultaneidade (threads de controle). Discutimos o conceito de uma
atividade simultânea, mas não é possível construir sistemas das atividades. É possível construir sistemas dos
componentes; faz sentido construir sistemas simultâneos dos componentes simultâneos. Nenhum procedimento, estrutura de
dados ou thread de controle cria, por si mesmo, modelos muito naturais para componentes simultâneos, mas os objetos
parecem ser uma forma muito natural de combinar todos esses elementos necessários em um único pacote simples.
Um objeto empacota os procedimentos e as estruturas de dados em um componente coeso com seu próprio estado e
comportamento. Ele encapsula a implementação específica desse estado e comportamento, e define uma interface por meio
da qual os outros objetos ou software poderão interagir com ele. Os objetos geralmente modelam entidades ou conceitos
do mundo real e interagem com outros objetos por meio da troca de mensagens. Agora, eles são bem aceitos por muitas
pessoas como a melhor maneira de construir sistemas complexos.
Figura 4: Um conjunto simples de objetos para o sistema de elevador.
Considere um modelo de objeto para o sistema de elevador. Um objeto de estação de chamada em cada andar monitora os
botões de chamada para cima e para baixo nesse andar. Quando um provável passageiro pressiona um botão, o objeto
estação de chamada responde enviando uma mensagem a um objeto despachante de elevador, que, por sua vez, seleciona o
elevador com maior probabilidade de fornecer o serviço mais rápido, despacha o elevador e reconhece a chamada. Cada
objeto elevador controla simultânea e independentemente seu parceiro físico (o outro elevador), respondendo às seleções
de andar do passageiro e às chamadas do despachante.
A simultaneidade pode assumir duas formas em um modelo de objeto. A simultaneidade entre objetos ocorre quando dois ou
mais objetos estão executando atividades independentemente por meio de threads de controle separados. A simultaneidade
intra-objeto surge quando vários threads de controle estão ativos em um único objeto. Na maioria das linguagens
orientadas a objetos de hoje, os objetos são "passivos" e, portanto, não têm seu próprio encadeamento de controle. Os
threads de controle devem ser fornecidos por um ambiente externo. Muito freqüentemente, o ambiente é um processo padrão
de S.O. criado para executar um "programa" orientado a objetos escrito em uma linguagem como C++ ou Smalltalk. Se o SO
oferecer suporte ao mecanismo multithreading, vários threads poderão ficar ativos no mesmo ou em vários objetos.
Na figura a seguir, os objetos passivos são representados pelos elementos circulares. A área interna sombreada de cada
objeto é sua informação de estado e o anel externo segmentado é o conjunto de procedimentos (métodos) que definem o
comportamento do objeto.
Figura 5: Ilustração de interação do objeto.
A simultaneidade intra-objeto traz consigo todos os desafios do software simultâneo, como a possibilidade de condições
de competição quando vários encadeamento de controle tiverem acesso ao mesmo espaço de memória - nesse caso, os dados
encapsulados no objeto. Talvez, alguém tenha pensado que o encapsulamento de dados traria uma solução para essa
questão. O problema, obviamente, é que o objeto não encapsula o thread de controle. Embora a simultaneidade entre
objetos evite essas questões na maioria das vezes, há ainda um problema inquietante. Para que dois objetos simultâneos
interajam trocando mensagens, pelo menos dois threads de controle devem manipular a mensagem e acessar o mesmo espaço
de memória para enviá-la. Um problema relacionado (no entanto, ainda mais difícil) é o da distribuição de objetos entre
diferentes processos ou, até mesmo, entre processadores. As mensagens entre objetos de diferentes processos requerem
suporte à comunicação entre processos e, geralmente, exigem que a mensagem seja codificada e decodificada em dados que
possam ser passados para além das fronteiras do processo.
Nenhum desses problemas é intransponível, é claro. Na verdade, como dissemos na seção anterior, todos os sistemas
simultâneos devem lidar com esses problemas; portanto, há soluções comprovadas. O problema é que o "controle da
simultaneidade" gera trabalho extra e introduz oportunidades de erro adicionais. Além do mais, ele obscurece a essência
do problema do aplicativo. Por todos esses motivos, queremos minimizar a necessidade de os programadores de aplicativo
lidarem explicitamente com eles. Uma maneira de conseguir isso é criar um ambiente orientado a objetos com suporte à
transmissão de mensagens entre objetos simultâneos (incluindo o controle de simultaneidade) e minimizar ou eliminar o
uso de vários threads de controle em um único objeto. Na verdade, essa medida encapsula o thread de controle juntamente
com os dados.
Os objetos que têm seus próprios encadeamentos de controle são chamados "objetos ativos". Para oferecer suporte à
comunicação assíncrona com outros objetos ativos, cada objeto ativo é fornecido com uma fila de mensagens ou "caixa
postal". Quando um objeto é criado, o ambiente fornece a ele seu próprio thread de controle, que o objeto encapsula até
o fim. Assim como acontece com um objeto passivo, o objeto ativo fica inativo até que chegue uma mensagem de fora. O
objeto executa o código apropriado para processar a mensagem. Qualquer mensagem que chegue enquanto o objeto estiver
ocupado será enfileirada na caixa de correio. Quando o objeto concluir o processamento de uma mensagem, ele retornará
para resgatar a próxima mensagem que está na caixa de correio ou aguardará a chegada de uma. Boas sugestões a objetos
ativos no sistema de elevador incluem os próprios elevadores, as estações de chamada em cada andar e o despachante.
Dependendo de sua implementação, os objetos ativos podem se tornar bastante eficazes. No entanto, eles geram mais
sobrecarga do que um objeto passivo. Desse modo, como nem todas as operações precisam ser simultâneas, é comum combinar
objetos ativos e passivos no mesmo sistema. Devido aos estilos de comunicação diferentes, é difícil torná-los
parceiros. No entanto, um objeto ativo cria um ambiente ideal para os objetos passivos, substituindo os processo de SO
que usamos anteriormente. Na verdade, se o objeto ativo delegar todo o trabalho aos objetos passivos, ele será
basicamente o equivalente a um processo ou thread de SO com recursos de comunicação entre processos. No entanto, os
objetos ativos mais interessantes têm seu próprio comportamento para realizar parte do trabalho, delegando outras
partes aos objetos passivos.
Figura 6: Um objeto 'ativo' fornece um ambiente para classes passivas
Boas sugestões a objetos passivos dentro de um objeto de elevador ativo incluem uma lista de andares em que o elevador
deverá parar enquanto estiver subindo e uma outra lista que ele usará quando estiver descendo. O elevador deve ser
capaz de perguntar à lista qual é a próxima parada, adicionar novas paradas à lista e remover as paradas que já foram
atendidas.
Como os sistemas complexos são quase sempre compostos por vários níveis de subsistemas até chegar aos componentes de
nível-folha, é natural para o modelo de objeto ativo permitir que os objetos ativos contenham outros objetos ativos.
Embora um objeto ativo de thread único não ofereça suporte à verdadeira simultaneidade intra-objeto, delegar trabalho
aos objetos ativos armazenados é uma substituição razoável para vários aplicativos. Ele oferece a importante vantagem
do encapsulamento completo do estado, do comportamento e do thread de controle por objeto, o que simplifica as questões
de controle de simultaneidade.
Figura 7: O sistema de elevador, mostrando objetos ativos aninhados
Considere, por exemplo, o sistema de elevador parcial representado acima. Cada elevador tem portas, um guindaste e um
painel de controle. Cada um desses componentes é bem modelado por um objeto ativo simultâneo, no qual o objeto porta
controla a abertura e o fechamento das portas do elevador, o objeto guindaste controla o posicionamento do elevador por
meio do guindaste mecânico, e o objeto painel de controle monitora os botões de seleção de andar e os botões de
abertura/fechamento de porta. O encapsulamento dos threads simultâneos de controle como objetos ativos leva a um
software muito mais simples do que poderia ser obtido se todo esse comportamento fosse gerenciado por um único thread
de controle.
Como já dissemos quando abordamos as condições de competição, para que um sistema se comporte de maneira correta e
previsível, determinadas operações dependentes de estado devem ser atômicas.
Para que um objeto se comporte adequadamente, é certamente necessário que seu estado seja internamente consistente
antes e após o processamento de qualquer mensagem. Durante o processamento de uma mensagem, o estado do objeto pode
estar em uma condição temporária e pode estar indeterminado porque, talvez, as operações estejam apenas parcialmente
concluídas.
Se um objeto sempre conclui sua resposta a uma mensagem antes de responder a uma outra, a condição temporária não é um
problema. A interrupção de um objeto para executar outro também não é problema, pois cada objeto executa um
encapsulamento estrito de seu estado. (Para sermos mais específicos, isso não é totalmente verdade, como explicaremos
em breve.)
Qualquer circunstância em que um objeto interrompa o processamento de uma mensagem para processar outra abrirá a
possibilidade para as condições de competição e, portanto, requer o uso dos controles de simultaneidade. Isso, por sua
vez, abrirá a possibilidade para o deadlock.
Portanto, o design simultâneo será geralmente mais simples se os objetos processarem cada mensagem de modo que ela seja
concluída antes da aceitação de uma outra. Esse comportamento está implícito na forma particular do modelo de objeto
ativo que apresentamos.
A questão do estado consistente pode se manifestar de duas formas diferentes nos sistemas simultâneos e, talvez, elas
sejam mais fáceis de compreender quando consideramos os sistemas simultâneos orientados a objetos. A primeira forma é
aquela que já abordamos. Se o estado de um único objeto (passivo ou ativo) for acessível a mais de um thread de
controle, as operações atômicas deverão ser protegidas pela atomicidade natural das operações de CPU elementares ou por
um mecanismo de controle de simultaneidade.
A segunda forma da questão do estado consistente é talvez mais sutil. Se mais de um objeto (ativo ou passivo) contiver
as mesmas informações de estado, os objetos inevitavelmente terão estados diferentes por, pelo menos, curtos intervalos
de tempo. Em um design simples, eles podem ter estados diferentes por períodos mais longos, e até mesmo para
sempre. Essa manifestação do estado inconsistente pode ser considerada um "duplo" matemático da outra forma.
Por exemplo, o sistema de controle de impulso do elevador (o guindaste) deve assegurar que as portas estão fechadas e
não podem ser abertas antes que o elevador possa se movimentar. Um design sem garantias adequadas poderia permitir que
as portas fossem abertas em resposta a um passageiro que tenha pressionado o botão de abertura de porta exatamente
quando o elevador começou a se movimentar.
Pode parecer que uma solução fácil para o problema seja permitir que as informações de estado residam somente em um
único objeto. Embora isso possa ajudar, essa medida pode causar também um impacto prejudicial no desempenho,
particularmente em um sistema distribuído. Além do mais, essa não é uma solução infalível. Mesmo se somente um objeto
contiver determinadas informações de estado, contanto que outros objetos simultâneos tomem decisões com base nesse
estado em um determinado momento, as mudanças de estado poderão invalidar as decisões de outros objetos.
Não existe uma solução mágica para o problema do estado consistente. Todas as soluções práticas exigem que
identifiquemos operações atômicas e as protejamos com algum tipo de mecanismo de sincronização que bloqueie o acesso
simultâneo por períodos toleravelmente curtos. A expressão "toleravelmente curto" depende bastante do contexto. Esse
tempo pode durar até que a CPU armazene todos os bytes em um número de ponto flutuante ou até que o elevador chegue à
próxima parada.
Nos sistemas em tempo real, o RUP recomenda o uso de Cápsulas
para representar objetos ativos. As cápsulas possuem uma semântica forte para simplificar a modelagem da simultaneidade:
-
elas utilizam a comunicação baseada em mensagem assíncrona por meio de Portas utilizando Protocolos bem definidos;
-
elas usam uma semântica de execução-conclusão no processamento de mensagens;
-
elas encapsulam objetos passivos (assegurando, assim, que a interferência de threads não poderá ocorrer).
|