Capitulo
8 – Princípios básicos da sincronização
8.2 - Coordenação de processos
8.3.1
- Princípios de operação
8.3.2.1 - Implementação de semáforos
8.3.2.2 - Implementação activa
e passiva de semáforos
8.4 - Multiprocessadores de
Memória partilhada
Programação múltipla, tempo partilhado e processo
paralelo fizeram com que a noção de grupos cooperativos de processos sequenciais
fossem possíveis. Operações concorrentes em simultâneo, com relatos de grupos
de processos, permitiram uma única aplicação para obter vantagens do
paralelismo entre o CPU e os dispositivos de ‘input/output’ (entrada e saída)
no caso dum único processador e na colaboração CPUs no caso de
multiprocessadores. No entanto múltiplos processos cooperativos introduzem o
possíveis novos problemas no software de implementações: o ‘deadlock’, as secções criticas e a
possibilidade da não determinação nestas computações. Estes princípios
acontecem quando dois ou mais processos entram em conflito, nomeadamente por
utilização dos mesmos recursos (incluindo informação).
É
importante perceber as várias interacções entre processos antes de desenhar as
máquinas que suportam as mesmas interacções. Por isso a primeira parte deste
capítulo discute a natureza geral dessas interacções. O resto do mesmo foca as
ferramentas clássicas de sincronização, nomeadamente o semáforo e as
respectivas implementações técnicas.
A sincronização é necessária nos uni
processadores, multiprocessadores e redes. Porém o problema é suficientemente
complexo por isso é tradicionalmente estudado no ambiente do uni processador.
Nesta discussão, os resultados são generalizados para os multiprocessadores e
para redes (capitulo 17).
Os
Programas de Computador não são mais que a implementação de séries de
algoritmos, passo-a-passo. Esses procedimentos vão permitir através do
processamento de informação a realização de uma determinada tarefa.
O uso da série de algoritmos
para especificar computações em série tem dominado a programação por toda a
metade do século passado. Enquanto a programação tradicional com o C e o C++ dá
pouco importância ao endereço, no que toca ao hardware tem sistematicamente
evoluído no sentido do desenvolvimento da distribuição e computação paralela
das máquinas. Pressões económicas a isso o obrigam. Para além das referidas
questões económicas, o tratamento de informação, a computação em ambiente de
escritório e aplicações numéricas exercem também uma grande pressão.
Supondo que uma aplicação é
escrita para que dois processos, p1 e p2, executem o
acesso comum a uma variável inteira, x. Por exemplo, p1 poderá
manusear créditos para uma conta, enquanto p2 faz o manuseamento dos
débitos dessa mesma conta. O seguinte código exemplifica como os processos
referenciam o balanço da partilha.
Shared float balance; |
/* shared variable */ |
… |
… |
Code Schema For p1 |
Code Schema For p2 |
… |
… |
balance=balance+amount; |
balance=balance-amount; |
figura 1
Este linguagem de alto nível será compilada e
traduzida pelo seguinte código:
Esquema para p1 |
Esquema para p2 |
||
load |
R1, balance |
load |
R1, balance |
load |
R2, amount |
load |
R2, amount |
Add |
R1, R2 |
sub |
R1, R2 |
store |
R1, balance |
store |
R1, balance |
figura 2
O problema da secção critica pode
ocorrer quando os segmentos de código são executados. Supondo que p1
está a correr num uni processador quando o intervalo do timer acaba e o
processo p1 está a executar a instrução:
load R2,amount
Se o escalonador (scheduler)
seleccionar em seguida o processo p2 para correr e este executar o
segmento de instrução “balance =
balance – amount; “ antes de p1 voltar a correr, então vai existir “ condição
de corrida “ entre p1 e p2. Se p1 ganhar a
corrida, os valores de R1 e R2 serão somados e a variável
balance será escrita na memória sem qualquer erro. Se p2 “ganhar a corrida”, vai ler o resultado
original de balance, efectuar a subtracção ( balance – amount ) e guardar o
valor em balance. Por sua vez p1 que ainda tem o valor antigo de balance
vai executar a soma e sobrepor o resultado ao anterior ficando a operação de
subtracção perdida.
O programa que contem p1
e p2 tem uma secção critica quando há necessidade de partilhar a
variável balance. Para p1 a secção critica é quando soma ‘amount’ a ‘balance’. Para p2 é quando subtrai amount a balance. A
execução cooperativa de dois processos não é garantidamente determinada, visto
que duas execuções diferentes do mesmo programa pode produzir valores
diferentes. Na primeira execução p1 pode ganhar a corrida e na
próxima execução pode ser p2 a ganhar, resultando valores diferentes
nas variáveis partilhadas. Não é possível detectar problemas apenas
considerando o processo ( ou programa ) p1 ou p2. Os
problemas ocorrem devido à partilha, não devido a um erro no código do
programa. O problema da secção critica pode ser evitado fazendo com que ambos
os processos possam entrar na secção critica desde que o outro não se encontre
também na sua secção critica.
Como é que dois processos podem
cooperar para entrar na secção critica? Num sistema multiprogramado, as
interrupções causam alguns problemas, visto que podem fazer com que o
escalonador funcione. Por causa das operações gravadas nos estados dos registos
implícito com troca de contexto, um processo pode resumir a execução carregando
informação inconsistente nos registos. Se o programador se aperceber que a
ocorrência de uma interrupção pode levar a resultados errados, pode fazer com
que o programa desligue as interrupções, enquanto se está a processar a secção
critica do processo.
A seguinte
grelha mostra como, por exemplo, a secção critica pode ser programada
Shared double balance; |
/* shared variable */ |
|
|
Program for p1 |
Program for p2 |
|
|
disableInterrupts(); |
disableInterrupts(); |
balance=balance+amount; |
balance=balance-amount; |
EnableInterrupts(); |
EnableInterrupts(); |
figura 3
As instruções “enableinterrupt” e o “disableinterrupt” para evitar que ambos
os processos sejam iniciados ao mesmo tempo nas secções criticas. As
interrupções são desligadas quando os processos entram na secção critica para
que os mesmos não sejam interrompidos e voltam a ser ligados depois de esta ser
executada. Claro que, esta técnica pode provocar erros nos dispositivos de
entrada/saída (i/o) visto que podem vir a ser desligados durante muito tempo,
consoante os programas. Em particular, imagine que o programa entra num ciclo
infinito na sua secção critica. As interrupções iam ficar desligadas
permanentemente.
Uma alternativa para a solução,
apresentada na anterior grelha, não necessita que as interrupções sejam
desactivadas, dessa forma o problema de grande/infinitos intervalos de
computação é evitado. A ideia é fazer com que os dois processos coordenem as
suas acções sobre as variáveis partilhadas para sincronizar os seus progressos.
A grelha abaixo indicada usa uma flag (bandeira) partilhada, lock, de forma a
que p1 e p2 possam coordenar aos seus acessos a “balance”. Quando p1 entra na
secção critica altera a variável partilhada lock, de forma a prevenir que p2
entre também na sua secção critica. O mesmo acontece quando p2 entra
na secção critica.
shared boolean
lock=FALSE;; |
/* shared variable */ |
shared float balance; |
|
|
|
Program for p1 |
Program for p2 |
... |
... |
/* Acquire the lock */ |
/* Acquire the lock */ |
While (lock) (NULL;); |
While (lock) (NULL;); |
Lock = TRUE |
Lock = TRUE |
/* Execute critical
section */ |
/* Execute critical section
*/ |
balance = balance +
amount; |
balance = balance –
amount; |
/* Release the lock */ |
/* Release the lock */ |
Lock = FALSE; |
Lock = FALSE; |
… |
… |
figura 4
A grelha anterior ilustra um modelo
de execução para dois processos. Supondo que p1 é interrompido
durante a execução do processo.
balance = balance + amount;
Depois de ter alterado lock para TRUE e p2 começar a
ser executado. Então p2 vai esperar num ciclo while enquanto p1 termina e muda o lock para FALSE
figura 5
Quando p1 mudar lock para FALSE indicou a p2
que já pode continuar, quando chegar a interrupção do relógio, p2
passa à execução.
Embora o uso de lock
para entrar na secção critica, está-se a acrescentar uma nova secção critica
relacionada com o teste da variável lock. O problema pode ocorrer quando
acabamos de testar a variável lock e nos preparamos para alterá-la. Se ocorrer
uma interrupção depois de testarmos a variável lock, o outro processo ainda ia
detectar lock = FALSE e entrava na secção critica. Quando fosse a vez do
primeiro voltar a correr iria mudar lock = TRUE mas o outro já lá estava
dentro. Para evitar isso podemos usar as interrupções, mas desta vez por pouco
tempo, apenas para permitir uma instrução como é mostrado no diagrama abaixo
indicado.
enter (lock){ |
exit(lock){ |
disableInterrupts(); |
disableinterrupts(); |
/* loop enquanto lock = TRUE */ |
lock=FALSE |
while (lock) { |
enableinterrupts(); |
/* deixa ocorrerem
interrupcções */ |
} |
enableInterrupts(); |
|
disableInterrupts(); |
|
} |
|
lock = TRUE; |
|
enableinterrupts(); |
|
} |
|
figura 6
Numa situação de deadlock, dois
ou mais processos entram num estado em que ambos seguram um recurso que o outro
está a requisitar. Visto que uma operação de requisição bloqueia a ligação até
o recurso estar disponível, nenhum dos processos vai ter o recurso disponível e
vão ficar bloqueados infinitamente. Neste capítulo, são apenas focados os casos
em que o recurso é secção crítica ou lock. O capitulo 10 este tema é focado de
uma forma mais aprofundada.
Considera-se um caso em que dois
processos p1 e p2 manipulam uma lista comum. Uma tarefa
na manipulação da lista é o processo acrescentar ou apagar entradas, outra é
actualizar uma discrição do programa com o comprimento da lista. Quando uma
operação de apagar ou acrescentar ocorre, o comprimento tem que ser
actualizado. Podemos tentar a aproximação No diagrama seguinte, mas vamos ver
mais tarde que não chega para garantir o bom funcionamento do programa.
boolean lock1 =
FALSE; |
/*
variaveis partilhadas */ |
boolean
lock2 = FALSE; |
|
shared list L; |
|
|
|
Program for p1 |
Program for p2 |
|
|
… |
… |
/*
Entrar na secção critica para |
/*
Entrar na secção critica para |
apagar
um elemento da lista */ |
actualizar
o comprimento */ |
enter
(lock1); |
enter
(lock2); |
<Apagar
elemento>; |
<Actualizar
comprimento>; |
/*
Sair da secção critica */ |
/*
Sair da secção critica */ |
exit
(lock1); |
exit
(lock2); |
<Computação
intermédia>; |
<Computação
intermédia>; |
/*
Entrar na secção critica para |
/*Entrar
na secção critica para |
actualizar
o comprimento */ |
acrescentrar
um elemento à lista*/ |
enter(lock2); |
enter(lock1); |
<Actualizar
comprimento>; |
<Adicionar
elemento>; |
/*
Sair da secção critica */ |
/*
Sair da secção critica */ |
exit(lock2); |
exit(lock1); |
… |
… |
figura 7
Se os processos p1 e p2 são executados ao mesmo tempo
num sistema multiprogramado, uma interrupção do relógio pode ocorrer depois de
p1 apagar um elemento, mas antes de ter actualizado o comprimento,
então os dados na descrição não são iguais aos dados reais. O processo deve
executar as duas operações ou então nenhuma. Para não ocorrerem estes erros
vamos modificar o nosso programa.
Quando p1 entra na secção critica
para manipular a lista muda lock1 o que previne p2 de entrar na sua
secção critica. Depois de executada a secção critica volta a mudar lock1 para o
seu valor inicial. O mesmo acontece se for p2 a entrar primeiro na
secção critica.
boolean lock1 =
FALSE; |
/*
variaveis partilhadas */ |
boolean
lock2 = FALSE; |
|
shared list i,
j; |
|
|
|
Program for p1 |
Program for p2 |
|
|
… |
… |
/*
Entrar na secção critica |
/*
Entrar na secção critica |
da
lista */ |
da descrição
*/ |
enter
(lock1); |
enter
(lock2); |
<Apagar
elemento>; |
<Actualizar
comprimento>; |
<Computação
intermédia>; |
<Computação
intermédia>; |
/*
Entrar na secçãp critica |
/*Entrar
na secção critica |
da
descrição */ |
para
i */ |
enter(lock2); |
enter(lock1); |
<Actualizar
comprimento>; |
<Adicionar
elemento>; |
/*
Sair da secção critica */ |
/*
Sair da secção critica */ |
exit (lock1); |
exit (lock2); |
exit(lock2); |
exit(lock1); |
… |
… |
figura 8
8.2 Coordenação
de processos
A necessidade da interacção
entre processos faz com que tenha de haver sincronização de processos, por sua
vez a sincronização conduz a problemas com secções criticas e deadlocks. FORK,
JOIN e QUIT são introduzidos no capitulo 6, como mecanismos para criar e
eliminar processos. Também são usados para sincronizar partes de uma computação
cooperativa.
figura 9
shared double x,y; |
/*
variáveis partilhadas */ |
|
|
proc_A{ |
proc_B{ |
While (true){ |
While (true){ |
<compute A1>; |
read (x); /* Consume x */ |
write(x); /* Produce x */ |
<compute B1>; |
<compute A2>; |
write(y); /* Produce y */ |
read (y); /* Consume y */ |
<compute B2>; |
} |
} |
} |
} |
figura 10
8.3 Semáforos
Um semáforo é assemelha-se um
sistema operativo de informação abstracta que executa operações similares ás
funções enter e exit descritas na figura 6.
Como já foi
discutido, um processo não pode entrar numa secção critica se outro já se
encontra na sua secção critica. Quando um processo está prestes a entrar na sua
secção critica, primeiro vai determinar se outro processo já se encontra na sua
secção critica. Se já se encontrar algum na secção critica, o segundo processo
sincroniza a sua operação bloqueando-se até que o primeiro processo saia da sua
secção critica. Uma solução aceitável para o problema da secção critica é
estabelecer as seguintes regras:
O problema da secção critica é
na sua essência um problema de software. Esta secção assume particular
importância em aspectos importantes relacionados com cooperação entre dois
processos como é indicado na figura abaixo indicada.
proc_0 () { |
proc_1 () { |
while (TRUE) { |
while (TRUE) { |
<secção de computação>; |
<secção de computação>; |
<secção critica>; |
<secção critica>; |
} |
} |
} |
} |
|
|
<declarações
globais partilhadas> |
|
<processamento
inicial> |
|
fork
(proc_0, 0); |
|
fork
(proc_1, 0); |
|
figura 11
São aplicados os seguintes itens
sobre o esquema do software da figura:
·
Escrever
e ler uma célula de memória comum a dois processos é uma operação indivisível.
Qualquer tentativa de dois processos executarem simultaneamente operações de
escrita ou leitura resulta em vários ordenamentos desconhecidos, mas as
operações não vão ocorrer ao mesmo tempo.
·
Não
é suposto haver prioridades entre os processos no caso de dois ou mais
processos quererem entrar na secção critica.
·
As
velocidades relativas dos processos são desconhecidas, por isso o mesmo programa
pode demorar intervalos de tempo a executar.
·
Como
é indicado na figura 11, é suposto que os processos sejam sequenciais e
cíclicos.
Um semáforo, “s”, é uma variável inteira não negada
que só é alterada ou testada por uma de duas rotinas de acesso:
V(s) : [s=s+1]
P(s) : [while (s=0) (wait) ; s=s+1]
Os parêntesis rectos que cercam
as expressões nas rotinas de acesso indicam que as operações são indivisíveis
ou atómicas. Na operação V, o processo que executa a rotina não pode ser
interrompido até completar a rotina. A operação P é mais complexa. Se “s” é maior que 0, é testado e
decrementado como uma operação indivisível. Por outro lado se “s” é igual a 0, o processo que executa a
operação P pode ser interrompido.
proc_0 () { |
proc_1 () { |
while (TRUE) { |
while (TRUE) { |
<secção de computação>; |
<secção de computação>; |
P(mutex); |
P(mutex); |
<secção critica>; |
<secção critica>; |
V(mutex); |
V(mutex); |
} |
} |
} |
} |
|
|
semaphore mutex = 1; |
|
fork (proc_0, 0); |
|
fork (proc_1, 0); |
|
figura 12
8.3.2.1 Implementação de semáforos
A figura 6 mostra como as interrupções
podem ser desligadas para manipular a variável lock. A mesma técnica pode ser
usada para implementar operações V e P. Visto que as mesmas são implementados
como funções do sistema operativo, o modelo de uma solução assume que os
processos do utilizador podem ter um ponteiro para o semáforo, mas o ponteiro é
para um objecto no sistema operativo.
Uma
implementação que siga este modelo desliga as interrupções, mas apenas por
pequenos períodos de tempo. Quando um processo bloqueia num semáforo, as
interrupções estão ligadas; As interrupções estão desligadas apenas quando é
manipulado o valor do semáforo. Isto tem dois efeitos importantes:
Os semáforos podem ser
implementados sem desligar as interrupções se o hardware fornecer algumas
condições especiais. Uma implementação directa
das instruções P e V é algo difícil devido à necessidade de acomodar o
ciclo wait na operação P. Em vez disso, uma implementação uniprocessador
geralmente quer interagir com o escalonador para a implementação de P, logo uma
parte da instrução vai ser implementada em hardware e outra em software. O
sistema operativo pode criar recursos abstractos para cada semáforo. Depois usa
um recurso de gestão bloqueia processos que executam operações P como se
tivessem executado uma operação de requerimento num recurso convencional. A
garantia vem como o recurso de gestão do semáforo consegue implementar
simultaneamente e correctamente os acessos ao semáforo sem usar as
interrupções.
As
instruções TS (test and set) são o caminho dominante para conseguir os efeitos
P e V. TS é uma instrução simples que pode de incluído no repertório da máquina
sem muito esforço. Mas isto pode fazer a implementação de semáforos muito
simples. A local de memória TS, m, ou TS(m), é carregado para um registo do
CPU, e na memória é escrito o valor TRUE.
Para um
processo entrar na secção crítica vai ler a memória. Ao comparar o valor de m o
processo pára ou segue.
8.3.2.2 Implementação de semáforos activos e
passivos
Um semáforo pode ser visto como
um recurso de consumo. Um processo bloqueia se o valor requisitado a um
semáforo não está disponível. Um processo move-se de um estado de execução para
um estado bloqueado executando uma operação P num semáforo de valor zero. O
processo passa do estado bloqueado ao estado executável quando é lido um valor
positivo no semáforo; ele decrementa o semáforo enquanto muda de estado. Quando
um processo larga um recurso, neste caso executa uma operação V, o alocador de
recursos move o primeiro processo de bloqueado para pronto.
8.4 Multiprocessadores com memória
partilhada
As secções 8.1 e 8.3 descrevam uma técnica para
implementação de semáforos desligando interrupções. Num multiprocessador com
memória partilhada, isto é insuficiente, visto que desligando as interrupções
num CPU não afecta o comportamento dos outros processadores. Por isso
multiprocessadores com memória partilhada comercializados usam instruções
especificas como TS para implementar
semáforos. Em multiprocessadores com partilha de memória, a memória pode ser
projectada para suportar TS.