Diretriz: Coincidência
Esta diretriz ajuda os desenvolvedores na escolha da melhor maneira de atender às necessidades para simultaneidade em um sistema de software.
Relacionamentos
Descrição Principal

Introdução

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.

Abordagens de Simultaneidade

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.

Problemas

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?

Comunicação entre Objetos

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.

Pragmática

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.

Heurística

Foco nas Interações entre Componentes Simultâneos

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.

Isolar e Encapsular Interfaces Externas

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].

Isolar e Encapsular o Comportamento de Bloqueio e Polling

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.

Preferir Comportamento Reativo a Comportamento de Polling

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.

Preferir Notificação de Eventos a Difusão de Dados

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).

Utilizar Mais Mecanismos Reduzidos e Menos Mecanismos Pesados

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.

Evitar Intolerância de Desempenho

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.

Escolhendo Mecanismos

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.

Resumo

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.