Exclusão Mútua, Semáforos e Monitores
Os computadores com mais de um processador chegaram para ficar. Existem pesquisas no sentido de substituir o silÃcio usado para construir os processadores por outra substância, mas enquanto isso não se torna economicamente viável a saÃda para o silÃcio, uma vez que chegamos ao limite de velocidade que podemos atingir, a alternativa é aumentar a quantidade de núcleos executando. Isso significa na prática que agora o sistema operacional pode colocar mais de um programa efetivamente executando ao mesmo tempo. Embora possam executar ao mesmo tempo porque haverá mais de um processador, as aplicações vão concorrer pelos outros recursos, porque de acordo com nossas arquiteturas baseadas no modelo von Neuman, usamos um barramento para fazer as trocas de informações e para acessar canais de entrada e saÃda.
Essa caracterÃstica já é estudada nos cursos de sistemas operacionais há tempos, até porque quando só havia um núcleo disponÃvel (e isso ainda acontece em diversas plataformas) as aplicações concorrem também pelo uso do processador. A parte interessante é que este problema, tÃpico da área de sistemas, foi trazido para a área de aplicações inicialmente quando tivemos acesso à criação de novos processos (via fork por exemplo) e à threads. Neste último caso, as novas linguagens já trazem suporte ao trabalho com threads de forma nativa o que ajudou muito a difundir seu uso, no entanto talvez não tenhamos dado devida atenção ao domÃnio das técnicas para usar esse tipo de ferramenta.
Existe muito material disponÃvel sobre threads, mas por hora podemos entender como sendo as linhas de execução de um programa, ou seja, mais de uma tarefa do próprio programa acontece (ou tenta acontecer) ao mesmo tempo. Com isso temos que nos preocupar com os problemas de concorrência, originalmente da área de sistemas, dentro da nossa aplicação.
Para isso vale a pena rever os conceitos ensinados em sistemas operacionais referentes a:
- Condições de corrida (race conditions) – dois ou mais participantes (aplicações) disputam entre si por um recurso, ou seja, se assemelha a uma corrida por um troféu.
- Regiões crÃticas (critical regions) – são as regiões ou seções do programa em que fazemos uso do recurso que é compartilhado, identificando essas partes do programa torna-se possÃvel isolá-las de forma que possamos controlar o inÃcio e fim do uso de algum recurso compartilhado.
Tendo relembrado desses conceitos a primeira coisa que temos a falar é da exclusão mútua, que significa que dois ou mais participantes excluem-se entre si, ou seja, só um pode acessar o recurso compartilhado por vez. O problema então é: como conseguir sinalizar isso? Para responder essa pergunta temos uma série de técnicas que foram propostas também para a área de sistemas operacionais, até porque são técnicas de mais baixo nÃvel. O ideal é que o mecanismo ou estratégia de exclusão mútua seja fornecido pelo ambiente (ou pela linguagem, ou pela biblioteca) usado.
A primeira forma que pensamos é a espera ocupada (busy wait), onde o processo que está esperando gasta seus ciclos de processador sem fazer nada, basicamente equivale a fazer um laço (while ou for) onde testamos a condição para saber se já pode ser executado. Como o sistema operacional faz a troca de processos de tempos em tempos, eventualmente o processo que tem controle do recurso vai terminar de usar e liberar, e alguém da fila de espera vai ser colocado para executar. Quando essa nova troca acontece o laço encerra uma vez que a condição de recurso livre foi atingida. Esses turnos vão se alterando, o que percebemos é que essa estratégia desperdiça ciclos de processador sem realizar uma tarefa útil, logo só serve como exemplo. Em alguns casos de máquinas com vários processadores (ou vários núcleos) pode ser necessário usar essa técnica para sincronizar a leitura de registradores compartilhados ou para verificar se um outro núcleo já disponibilizou o conteúdo esperado, encontramos exemplos disso em vários dos manuais da IBM para programação do Cell Broadband Engine, processador que equipa o Playstation 3 da Sony.
Uma outra alternativa mais de baixo nÃvel (especificamente no nÃvel da máquina exatamente) é ter uma instrução capaz de testar uma variável (ou registrador) e no mesmo ciclo trocar seu valor. A ideia aqui é que se a variável não estiver com alguma das aplicações concorrentes então quem executou essa instrução consegue o recurso, se não estiver disponÃvel então simplesmente retorna. Bom a questão aqui é como disponibilizar essa instrução para as linguagens de mais alto nÃvel.
Por outro lado se usarmos uma solução baseada no sistema operacional ao invés de uma instrução de máquina, torna-se mais simples de torná-la acessÃvel para as linguagens de programação de alto nÃvel. A alternativa é usar duas chamadas do sistema SLEEP (dormir) e WAKEUP (acordar), dessa forma quando testamos a variável e verificamos que a condição que precisamos não está disponÃvel, executamos a chamada SLEEP para que o processo possa esperar até que o recurso esteja disponÃvel. De forma simétrica quando liberamos um recurso, chamamos WAKEUP dizendo qual processo deve executar agora (de forma que vá conseguir o recurso que tem interesse.)
Quando usamos semáforos, essas chamadas de sistema são encapsuladas em chamadas que realizam um controle extra. Isso é necessário porque as chamadas SLEEP e WAKEUP não conseguem por si só garantir a exclusão mútua. Um semáforo é uma variável especial sobre a qual podemos executar as operações DOWN (abaixar) e UP (levantar.) Como chamadas de sistemas, precisam ser implementadas com algum código, dentro desse código, além de verificar o valor da varÃavel passada (o semáforo) também trocamos o valor se for o caso, como se fosse uma instrução de baixo nÃvel. Para garantir que o processo executando não vai ser interrompido antes da variável trocar o valor as interrupções da máquina são desligadas, isso garante a execução atômica, ou seja, como se fosse uma única instrução, logo em seguida as interrupções de máquinas são ligadas e o processo já pode ser trocado pelo sistema operacional.
Uma outra caracterÃstica importante é que o uso de semáforos permite tornar a concorrência uma tarefa de mais alto nÃvel já que elimina o uso de referências direta aos outros processos. Se for necessário um conjunto grande de processos executando o uso de semáforos permite conhecer apenas uma variável, enquanto a referência direta necessitaria de um vetor para armazenar todas as referências.
O problema surge quando usamos mais de um semáforo para controlar tanto a exclusão mútua quanto o uso do recurso compartilhado. Neste caso se não nos concentrarmos na ordem de execução das operações podemos acabar bloqueados
Uma solução para isso é o uso de monitores, que na verdade é uma construção mais ampla composta de funções e estruturas de dados próprias. Normalmente o compilador conhece essa estrutura e é o compilador que ordena as questões referentes as garantias de exclusão mútua e sincronização, isto porque um monitor é construÃdo sobre as soluções oferecidas pelos semáforos. A vantagem disso é que o programador passa a usar um mecanismo de mais alto nÃvel e só precisa conhecer as operações WAIT (esperar) e SIGNAL (sinalizar,) usadas sobre uma variável que é o monitor, ou seja, quando o programador declara a variável, o compilador intervem colocando as instruções necessárias para as garantias. Do ponto de vista conceitual, as soluções são bem parecidas, porém o programador trabalha de forma mais abstrata se preocupando em marcar as regiões que devem ser controlados pelo monitor. Quando o programa executa a instrução WAIT sobre um semáforo, este verifica se existe algum programa atualmente executando, se este for o caso, o novo programa entra em uma fila de espera controlado pelo monitor, caso contrário começa a executar. Quando um programa vai terminar sua seção crÃtica executa como sua última instrução um SIGNAL para o monitor, isso faz com que o programa saia do monitor e este escolhe um novo programa da fila para executar utilizando assim o recurso compartilhado. A estratégia de sincronia escolhida para a linguagem Java foi a de monitores, assim os mecanismos para seu uso são fornecidos como operações da própria linguagem e seu compilador se preocupa em colocar todos os controles necessários em seus devidos lugares.


Excelente artigo, parabéns!!
Onde podemos encontrar exemplos em Java de uso de monitores?? É possÃvel forçar este tipo de coisa diretamente na linguagem??
Imagino publicar aqui alguns exemplos em outro artigo sobre concorrência, na verdade a linguagem já tem essa construção através da palavra chave synchronized.