A arte de um bom design está na escolha da "melhor" maneira de atender a um conjunto de requisitos. Em geral, a arte de
desenvolver um design satisfatório para sistemas simultâneos está em satisfazer, da forma mais simples, as necessidades
de simultaneidade. Uma das principais regras que os designers devem respeitar é não tentar reinventar a roda. Padrões e
idiomas de design adequados foram desenvolvidos para solucionar a maioria dos problemas. Devido à complexidade dos
sistemas simultâneos, o mais sensato é usar soluções testadas e aprovadas, mantendo a simplicidade do design.
As tarefas simultâneas que ocorrem inteiramente em um computador são chamadas de encadeamentos de execução. Como
todas as tarefas simultâneas, os encadeamentos de execução são um conceito abstrato, pois ocorrem no tempo. A melhor
forma de identificar fisicamente um thread de execução é representar o estado desse thread em um momento específico.
O meio mais direto de representar tarefas simultâneas utilizando computadores é dedicar um computador separado para
cada tarefa. No entanto, essa prática costuma ser bastante dispendiosa e nem sempre contribui para a resolução de
conflitos. Por isso, é comum suportar várias tarefas no mesmo processador físico por meio de alguma forma de
multitarefa. Nesse caso, o processador e seus recursos associados, como memória e barramentos, são
compartilhados. (Infelizmente, esse compartilhamento de recursos também pode gerar novos conflitos que não estavam
presentes no problema original).
A forma mais comum de multitarefa é fornecer um processador "virtual" para cada tarefa. Esse processador virtual é
geralmente chamado de processo ou tarefa. Normalmente, cada processo tem seu próprio espaço de endereço, que é
logicamente distinto do espaço de endereço de outros processadores virtuais. Isso impede que os processos entre em
conflito uns com os outros devido a sobrescritas acidentais da memória de cada um. Infelizmente, a carga necessária
para alternar o processador físico entre os processos costuma ser proibitiva. Isso envolve trocas significativas de
conjuntos de registros na CPU (comutação de contexto) que, mesmo com modernos processadores de alta velocidade,
podem levar centenas de microssegundos.
Para reduzir essa sobrecarga, muitos sistemas operacionais fornecem a capacidade de incluir vários encadeamentos
reduzidosem um único processo. Os threads existentes em um processo compartilham o espaço de endereço desse
processo. Embora, esse método reduza a carga envolvida na alternância de contexto, a probabilidade de ocorrerem
conflitos na memória aumenta.
Mesmo em alguns aplicativos com alto desempenho, a carga decorrente da alternância de threads leves também pode ser
inaceitável. Nessas situações, é comum aproveitar alguns recursos especiais do aplicativo para obter uma forma ainda
mais leve de multitarefa.
Os requisitos de simultaneidade do sistema podem impactar consideravelmente a arquitetura do sistema. A decisão de
passar a funcionalidade de uma arquitetura de processo único para outra de processos múltiplos acarreta mudanças
significativas em várias dimensões da estrutura do sistema. Talvez seja necessário incluir mecanismos adicionais (por
exemplo, chamadas de procedimentos remotos), que alteram bastante a arquitetura do sistema.
Os requisitos de disponibilidade do sistema devem ser considerados, bem como a carga extra para gerenciar processos e
threads adicionais.
Assim como ocorre com a maioria das decisões relativas à arquitetura, a mudança da arquitetura do processo substitui um
tipo de problema por outro:
Abordagem
|
Vantagens
|
Desvantagens
|
Processo único, sem threads
|
-
Simplicidade
-
Serviço rápido de mensagens dentro de processos
|
-
É difícil equilibrar a carga de trabalho
-
Não é possível escalar para processadores múltiplos
|
Processo único, threads múltiplos
|
-
Serviço rápido de mensagens dentro de processos
-
Multitarefa sem comunicação entre processos
-
Melhor recurso multitarefa sem a carga de processos 'pesados'
|
-
O aplicativo deve ser seguro contra threads
-
O sistema operacional deve ter gerenciamento eficiente de threads
-
É possível que ocorram problemas decorrentes da memória compartilhada
|
Processos múltiplos
|
-
Boa escalonabilidade à medida que os processadores são adicionados
-
Distribuição relativamente fácil entre nós
|
-
Sensível ao limite de processos: o uso excessivo de comunicação entre processos prejudica o
desempenho
-
As trocas e alternâncias de contexto são dispendiosas
-
É mais difícil de projetar
|
O caminho evolutivo normal é começar com uma arquitetura de processo único e ir adicionando processos para grupos de
comportamentos que precisam ocorrer simultaneamente. Nesses agrupamentos mais amplos, considere as necessidades
adicionais de simultaneidade, adicionando threads nos processos para aumentar a simultaneidade.
O ponto de partida é designar vários objetos ativos a um único encadeamento ou tarefa do sistema operacional,
utilizando um planejador de objetos ativos com finalidade definida. Desse modo, geralmente é possível obter uma
simulação bem reduzida de simultaneidade, embora, com um único encadeamento ou tarefa do sistema operacional, não seja
possível tirar proveito de máquinas com várias CPUs. A decisão-chave é isolar o comportamento de bloqueio em
encadeamentos separados, para que esse comportamento não se torne um gargalo. O resultado é a separação dos objetos
ativos com comportamento de bloqueio em seus próprios threads do sistema operacional.
Em sistemas de tempo real, esse raciocínio também se aplica às cápsulas. Cada cápsula tem um thread lógico de controle,
que pode ou não compartilhar threads, tarefas ou processos do sistema operacional com outras cápsulas.
Infelizmente, como ocorre com muitas decisões sobre arquitetura, não há respostas fáceis. A solução envolve uma
abordagem cuidadosamente elaborada. Pequenos protótipos de arquitetura podem ser usados para verificar as implicações
de um determinado conjunto de opções. Ao desenvolver o protótipo arquitetural do processo, concentre-se em escalonar o
número de processos até o máximo teoricamente aceito pelo sistema. Considere as seguintes questões:
-
O número de processos pode ser escalonado ao máximo? Até quanto além de seu limite máximo o sistema pode ir? Existe
espaço para crescimento?
-
Qual é o impacto causado por alterar alguns processos para threads leves que operam em um espaço de endereço de
processo compartilhado?
-
O que ocorre com o tempo de resposta quando aumenta o número de processos? E quando aumenta o volume de comunicação
entre processos (IPC)? Ocorre degradação visível?
-
O volume de IPC pode ser reduzido pela combinação ou reorganização de processos? Esse tipo de mudança pode resultar
em grandes processos monolíticos com cargas difíceis de equilibrar?
-
A memória compartilhada pode ser usada para reduzir a IPC?
-
Todos os processos devem ter o "mesmo tempo" quando os recursos de tempo são alocados? É possível fazer a alocação
de tempo? Existem possíveis desvantagens associadas à mudança das prioridades de programação?
Os objetos ativos podem comunicar-se entre si de forma síncrona ou assíncrona. A comunicação síncrona é útil porque
simplifica colaborações complexas por meio de um seqüenciamento rigidamente controlado. Em outras palavras, enquanto um
objeto ativo estiver executando uma etapa de execução-conclusão que envolva invocações síncronas de outros objetos
ativos, as interações simultâneas iniciadas por outros objetos poderão ser ignoradas até que a seqüência completa seja
concluída.
Apesar de útil em alguns casos, esse procedimento também pode ser problemático, porque pode fazer com que um evento
mais importante, com prioridade mais alta (inversão de prioridade), tenha de esperar. Esse fator é exacerbado pela
possibilidade de o objeto disparado de maneira síncrona poder se autobloquear enquanto espera a resposta para uma
invocação síncrona que ele mesmo originou. Uma situação desse tipo pode gerar uma inversão de prioridade ilimitada. Nos
casos mais extremos, se houver circularidade na cadeia de invocações síncronas, poderá ocorrer um deadlock.
As invocações assíncronas evitam esse problema permitindo tempos de resposta limitados. Entretanto, dependendo da
arquitetura do software, a comunicação assíncrona costuma gerar códigos mais complexos, já que um objeto ativo pode ter
de responder, em algum momento, a vários eventos assíncronos (cada um deles podendo iniciar uma seqüência complexa de
interações assíncronas com outros objetos ativos). Essa implementação pode ser muito difícil e está sujeita a
erros.
O uso da tecnologia de serviço de mensagens assíncrono com liberação de mensagens garantida pode simplificar a tarefa
de programação do aplicativo. O aplicativo poderá continuar a operação, mesmo que a conexão de rede ou o aplicativo
remoto não esteja disponível. O serviço de mensagens assíncrono não impossibilita sua utilização no modo síncrono. A
tecnologia síncrona exigirá a disponibilização de uma conexão sempre que o aplicativo estiver disponível. Como a
conexão existe, o processamento de confirmação pode se tornar uma tarefa mais fácil.
Na abordagem recomendada no Rational Unified Process para sistemas em tempo real, as cápsulas
se comunicam assincronicamente por meio de sinais,
de acordo com protocolos específicos. No entanto, é possível obter a comunicação
síncrona usando pares de sinais, um em cada direção.
Embora a carga de alternância de contexto dos objetos ativos possa ser bem baixa, é possível que esse custo ainda seja
inaceitável para alguns aplicativos. Geralmente, isso ocorre em situações nas quais grandes volumes de dados precisam
ser processados em alta velocidade. Nesses casos, pode ser preciso recorrer ao uso de objetos passivos e técnicas de
gerenciamento de simultaneidade mais tradicionais (e mais arriscadas), como os semáforos.
Essas considerações, no entanto, não implicam necessariamente em abandonar completamente a abordagem de objeto ativo.
Mesmo com aplicativos que usem grandes volumes de dados, a parte sensível ao desempenho geralmente é relativamente
pequena no sistema geral. Como conseqüência, o resto do sistema pode continuar tirando proveito do paradigma de objetos
ativos.
Em termos gerais, o desempenho é apenas um dos critérios para o design de sistemas. Se o sistema for complexo, outros
critérios (como manutenibilidade, facilidade para fazer mudanças, compreensibilidade e outros) serão tão ou mais
importantes. A abordagem de objeto ativo é bem mais vantajosa, pois oculta grande parte da complexidade e do
gerenciamento da simultaneidade, apesar de permitir que o design seja expresso em termos específicos do aplicativo, ao
contrário de mecanismos específicos de tecnologias de nível inferior.
Componentes simultâneos que não interagem são um problema quase trivial. Praticamente todos os desafios de design têm a
ver com interações entre tarefas simultâneas. Por isso, devemos primeiro tentar compreender as interações. Algumas
perguntas a serem feitas:
-
A interação é unidirecional, bidirecional ou multidirecional?
-
Existe algum relacionamento cliente/servidor ou mestre/escravo?
-
Há necessidade de alguma forma de sincronização?
Uma vez entendida a interação, vamos pensar nas maneiras de implementá-la. A implementação deve ser selecionada com a
finalidade de obter o mais simples design compatível com as metas de desempenho do sistema. Os requisitos de
desempenho, em geral, incluem na resposta a eventos gerados externamente tanto taxas gerais de transferência de dados
como latência aceitável.
Essas questões se tornam ainda mais críticas em sistemas de tempo real, que costumam ser menos tolerantes a variações
de desempenho. Por exemplo, instabilidade no tempo de resposta ou prazos não cumpridos.
Não é uma prática recomendável incorporar suposições específicas sobre interfaces externas em um aplicativo inteiro.
Também não é nada eficaz manter vários threads de controle bloqueados, à espera de um evento. O melhor é atribuir a um
único objeto a tarefa dedicada de detectar o evento. Quando o evento ocorrer, o objeto poderá notificar os outros que
precisarem saber do evento. Este design baseia-se em um padrão de design conhecido e comprovado, o padrão "Observador"
[GAM94]. Para obter maior flexibilidade, ele pode ser facilmente estendido para o
"Padrão Publicador-Assinante", em que um objeto publicador age como intermediário entre os detectores de eventos e os
objetos interessados no evento ("assinantes") [BUS96].
As ações realizadas em um sistema podem ser disparadas por eventos gerados externamente. Um evento de grande
importância gerado externamente pode ser a própria passagem do tempo, representada pelas batidas de um relógio. Outros
eventos externos originam-se em dispositivos de entrada conectados a um hardware externo, como dispositivos de
interface do usuário, sensores de processos e links de comunicação com outros sistemas. Nos sistemas de tempo real,
essa característica é totalmente verdadeira, pois geralmente eles apresentam alta conectividade com o mundo externo.
Para que o software detecte um evento, ele deve estar bloqueado, à espera de uma interrupção, ou deve verificar
periodicamente se ocorreu algum evento. Nesse último caso, o ciclo periódico talvez precise ser menor para evitar a
perda de eventos rápidos ou de ocorrências múltiplas ou apenas para minimizar o período de latência entre a ocorrência
e a detecção do evento.
O interessante é que, mesmo no caso de um evento mais raro, algum software precisa estar bloqueado, à sua espera, ou
verificar com freqüência a existência desse evento. Entretanto, muitos (se não a maioria) dos eventos com os quais o
sistema deve lidar são raros. Na maioria das vezes, em qualquer tipo de sistema, nada de significativo está realmente
acontecendo.
O sistema de elevadores oferece bons exemplos sobre isso. Entre os eventos importantes na vida de um elevador estão a
chamada para serviço, a seleção do piso, o bloqueio manual da porta por um usuário e a passagem de um piso para o
outro. Alguns desses eventos exigem uma resposta muito pontual, mas todos eles são extremamente raros quando comparados
à escala do tempo de resposta desejado.
Um único evento pode disparar várias ações, e as ações variam de acordo com o estado de diversos objetos. Além disso,
diversas configurações de um sistema podem usar o mesmo evento de maneira diferente. Por exemplo, quando um elevador
passa por um piso, o visor da cabine deve ser atualizado e o próprio elevador deve saber onde está, para que seja capaz
de responder a novas chamadas e reconhecer as seleções de piso feitas pelos usuários. Pode haver ou não um visor de
localização em cada piso.
A varredura é dispendiosa, pois requer que parte do sistema interrompa periodicamente o que está fazendo para verificar
se ocorreu algum evento. Se o evento exigir resposta rápida, o sistema terá de verificar a ocorrência de eventos com
bastante freqüência, limitando a realização de outros trabalhos.
É bem mais eficaz alocar uma interrupção para o evento, com o código referente ao evento ativado pela interrupção.
Embora, às vezes, as interrupções sejam evitadas por serem consideradas "dispendiosas", elas podem ser muito mais
eficientes quando utilizadas ponderadamente do que o polling repetido.
Os casos nos quais as interrupções são preferíveis como mecanismo de notificação de eventos são aqueles em que a
chegada de eventos é aleatória e pouco freqüente, fazendo com que a maioria dos esforços de varredura não identifique a
ocorrência do evento. Os casos nos quais a varredura é preferível são aqueles em que os eventos chegam de forma regular
e previsível, e a maioria dos esforços de varredura identificam a ocorrência do evento. Há um ponto entre os dois
métodos no qual é indiferente usar o comportamento de varredura ou o reativo. Os dois funcionarão bem e com pouca
variação. Na maioria dos casos, porém, devido à aleatoriedade dos eventos no mundo real, o comportamento reativo é mais
aconselhável.
A transmissão de dados (normalmente feita com a utilização de sinais) é dispendiosa e representa um desperdício, pois
apenas poucos objetos podem estar interessados nos dados, mas todos (ou muitos) precisam ser interrompidos para
verificá-los. Uma abordagem melhor e com menor consumo de recursos é a notificação para informar somente aos objetos
interessados a ocorrência de algum evento. Restrinja a transmissão aos eventos que precisam da atenção de muitos
objetos (geralmente eventos de tempo ou de sincronização).
Mais especificamente:
-
Use objetos passivos e invocações de método síncrono quando somente as respostas instantâneas representarem um
problema, e não a simultaneidade.
-
Use objetos ativos e mensagens assíncronas para a grande maioria dos conceitos de simultaneidade em nível de
aplicativo.
-
Use threads do sistema operacional para isolar elementos de bloqueio. É possível mapear um objeto ativo para um
thread do sistema operacional.
-
Use os processos do sistema operacional para obter isolamento máximo. Processos separados serão necessários para
programas que precisem ser inicializados e desativados de forma independente e para subsistemas que talvez tenham
de ser distribuídos.
-
Use CPUs separadas para fazer a distribuição física ou para manter a força bruta.
Talvez a diretriz mais importante para o desenvolvimento de aplicativos simultâneos eficientes seja maximizar o uso dos
mecanismos de simultaneidade mais leves. Tanto o software do sistema operacional quanto o hardware desempenham um papel
importante no suporte à simultaneidade. No entanto, ambos contêm mecanismos relativamente pesados, o que aumenta o
trabalho do designer de aplicativos. Cabe a nós preencher a lacuna existente entre as ferramentas disponíveis e as
necessidades dos aplicativos simultâneos.
Os objetos ativos ajudam a preencher essa lacuna por meio de dois recursos importantes:
-
Eles unificam as abstrações de design encapsulando a unidade básica de simultaneidade (um thread de controle), que
pode ser implementada usando qualquer mecanismo básico do sistema operacional ou da CPU.
-
Quando os objetos ativos compartilham um único thread do sistema operacional, eles passam a ser um mecanismo de
simultaneidade leve muito eficiente, que, de outra forma, teria de ser implementado diretamente no aplicativo.
Os objetos ativos também são um ambiente ideal para os objetos passivos fornecidos por linguagens de programação.
Projetar um sistema completo a partir dos fundamentos de objetos simultâneos sem artefatos procedurais (como programas
e processos) permite que os designs sejam mais modulares, coesos e compreensíveis.
Na maioria dos sistemas, menos de 10% do código usam mais de 90% dos ciclos da CPU.
Muitos designers de sistema agem como se todas as linhas de código tivessem de ser otimizadas. Prefira usar seu tempo
para otimizar os 10% do código que são executados com mais freqüência ou que são mais demorados. Crie o design dos
outros 90%, enfatizando aspectos como compreensibilidade, manutenibilidade, modularidade e facilidade de implementação.
Os requisitos não-funcionais e a arquitetura do sistema afetarão a escolha dos mecanismos utilizados para implementar
chamadas de procedimentos remotos. Será apresentada abaixo uma visão geral dos tipos de intercâmbio existentes
entre as alternativas.
Mecanismo
|
Usos
|
Comentários
|
Serviço de mensagens
|
Acesso assíncrono a servidores empresariais
|
O middleware do serviço de mensagens pode simplificar a tarefa de programação do aplicativo, pois
trabalha com enfileiramentos, timeout e condições de recuperação e reinicialização. O middleware do
serviço de mensagens também pode ser usado em um modo pseudo-síncrono. Normalmente, a tecnologia do
serviço de mensagens pode suportar mensagens grandes. Algumas abordagens RPC podem apresentar
limitações quanto ao tamanho das mensagens e exigir programação adicional para lidar com mensagens
grandes.
|
JDBC/ODBC
|
Chamadas de bancos de dados
|
São interfaces independentes do banco de dados, usadas para permitir que servlets Java ou programas de
aplicativo enviem chamadas a bancos de dados que podem estar no mesmo servidor ou em um outro.
|
Interfaces nativas
|
Chamadas de bancos de dados
|
Muitos fornecedores de bancos de dados implementaram interfaces nativas de programas de aplicativo em
seus próprios bancos de dados, oferecendo uma vantagem de desempenho em relação ao ODBC à custa da
portabilidade do aplicativo.
|
Chamada de Procedimento Remoto
|
Chamar programas em servidores remotos
|
Talvez você precise programar no nível de RPC se tiver um desenvolvedor de aplicativos que cuide disso
para você.
|
Conversação
|
Pouco usado em aplicativos de comércio eletrônico
|
Em geral, é a comunicação entre programas de nível inferior que utilizam protocolos, como APPC ou
Sockets.
|
Diversos sistemas precisam de recursos de simultaneidade e de componentes distribuídos. A maioria das linguagens de
programação oferece pouca ajuda a qualquer dessas questões. Vimos que precisamos de boas abstrações para compreender a
necessidade da simultaneidade em aplicativos e as opções para implementá-la no software. Vimos também que,
paradoxalmente, enquanto o software simultâneo é inerentemente mais complexo do que o não-simultâneo, ele também é
capaz de simplificar bastante o design de sistemas que lidam com a simultaneidade no mundo real.
|