 |
MC514 1ºs 2004 - Algoritmos clássicos de exclusão mútua |
 |
Atualizado em 19/04/04 - Prof. Célio Guimarães
Hipótese básica sobre o hardware: apenas as seguintes operações são indivisíveis:
Leitura de uma posição de memória
Escrita sobre uma posição de memória
Postulados de Dijkstra
São condições necessárias para uma solução do problema de exclusão mútua.
- Exclusão mútua: apenas um processo de cada vez pode executar o código
de Região Crítica (RC) associada a um recurso compartilhado do sistema
(também se diz entrar na RC).
Quando nenhum processo está numa dada RC qualquer processo que queira
entrar na RC pode fazê-lo imediatamente.
- Nenhuma hipótese pode ser feita a respeito da velocidade relativa dos processos
- Um processo fica dentro de uma RC por um tempo finito.
Fora da RC nehum processo interfere com outros processos que queiram
entrar na RC.
- Dois ou mais processos não devem se bloquear indefinidamente na entrada de uma RC (deadlock).
- Um processo não pode ser indefinidamente impedido (por outros processos) de
entrar numa RC (inanição ou starvation)
A. Dois processos P1 e P2 com busy wait
A.1 Algoritmo de Peterson (1981)
Notação:
qi, i= 1,2: variável compartilhada, só alterada pelo processo Pi
valor 0 fora da RC e colocada em 1 quando processo Pi "quer" entrar na RC,
permanecendo em 1 até Pi entrar e sair da RC.
vez: variável compartilhada, alterada por P1 e P2
P1 |
P2 |
loop forever | loop forever |
q1:= 1 | q2:= 1 |
vez := 1 | vez := 2 |
loop while(vez =1 and q2 = 1) | loop while(vez = 2 and q1 = 1) |
RC | RC |
q1:= 0 | q2:= 0 |
|
|
RNC | RNC |
endloop | endloop |
O código executado por um processo desde o instante em que deseja entrar na RC
até o momento em que entra na RC é chamado de protocolo de entrada da RC
e o código executado ao liberar a RC é chamado de protocolo de saída da RC.
Normalmente eles seriam encapsulados em duas funções, digamos, enter_cs()
e leave_cs(). No algoritmo acima o protocolo de entrada de P1
começa com a atribuição q1 := 1 e termina na saída do laço loop while().
O protocolo de saída é simplesmente a atribuição q1 := 0.
A.2 Algoritmo da "padaria" restrito a 2 processos (Lamport 1974)
Notação: qi, i= 1,2 têm o mesmo objetivo que acima
P1 |
P2 |
loop forever | loop forever |
q1:= 1 + q2 | q2:= 1 + q1 |
loop while ( q2 > 0 and q2 < q1 )
| loop while ( q1 > 0 and q1 <= q2 ) |
RC | RC |
q1:= 0 | q2:= 0 |
|
|
RNC | RNC |
endloop | endloop |
Obs: Deve ser óbvio que o algoritmo de Lamport é muito mais simples do que o de
Peterson: só requer duas variáveis compartilhadas que nunca são escritas pelos dois
processos e o laço para resolver situações de corrida é tão simples
quanto no algoritmo de Peterson. Curiosamente os livros sobre sistemas operacionais
só apresentam o algoritmo de Peterson (ou o seu predecessor, o algoritmo
de Dekker relatado por Dijkstra e que é mais complicado ainda!).
No caso de N processos o algoritmo
de Lamport que veremos a seguir é mais simples e intuitivo do que todos os que conhecemos.
B. N processos P1 . . . PN, com busy wait:
B.1 Algoritmo da "padaria" (Lamport, 1974)
Idéia básica: para entrar na RC cada processo calcula o valor de um "billhte"
ou "senha" > que o maior
bilhete ainda não usado pelos seus pares. O processo com o bilhete de menor
número tem preferência para entrar na RC. Pode haver empates no cálculo
concorrente do valor do bilhete. Nesse caso o processo com menor número
tem preferência (regra de desempate)
Ref: L. Lamport, "A New Solution of Dijkstra's Concurrent Programming Problem",
CACM Aug 74, p. 453-455.
Notação:
qi, i= 1,2, ... N: tem o mesmo objetivo que acima,
vale 0 fora da RC e colocada em 1 por Pi quando Pi "quer" entrar na RC
permanecendo em 1 até Pi sair da RC.
ci, i= 1,2, ... N :
vale 0 fora da RC e após Pi ter obtido o seu "bilhete";
colocada em 1 por Pi quando este está calculando o valor do seu bilhete
Observe que tanto qi quanto ci só sâo escritas pelo
processo Pi, mas são lidas por todos os outros.
Código de Pi |
loop forever |
ci:= 1 |
qi:= 1 + max (q1, q2, ..., qN) |
ci:= 0 |
for j =1, step 1 until N do |
loop while (cj != 0) |
loop while (qj != 0 and (qj, j) < (qi, i) )
(*) |
endfor |
|
RC |
qi := 0
|
|
RNC |
endloop |
(*) (qj, j) < (qi, i) se qj < qi
ou qj = qi e j < i (comparação lexicográfica)
Exercícios
1. Analise a seguinte variante do Algoritmo de Peterson onde a variável
vez foi eliminada, no que diz respeito a
exclusão mútua e ausência de deadlock.
Processo P1 Processo P1
loop forever loop forever
q1 := 1 q2 := 1
loop while (q2 = 1) loop while (q1 = 1)
RC RC
q1 := 0 q2 := 0
endloop endloop
2. Suponha que no algoritmo de Lamport para dois processos o bilhete de
P1 fosse obtido assim: q1 := 1 e o de
P2 fosse obtido assim: q2 := 2. Analise o novo algoritmo
no que diz respeito a exclusão mútua e ausência de deadlock. Ele tem
alguma desvantagem em relação ao algoritmo original?
3. Analise agora a seguinte solução com apenas uma variável compartilhada
q, onde q vale 0 fora da RC e recebe o valor i quando Pi quer
entrar na RC:
P1 P2
loop forever loop forever
loop loop
loop while ( q > 0 ) loop while ( q > 0 )
q := 1 q := 2
while ( q != 1) while (q != 2)
RC RC
q := 0 q := 0
RNC RNC
endloop endloop
4. Analise a seguinte solução do problema de exclusão mútua com apenas
duas variáveis compartilhadas q e vez onde q vale 0 fora da RC;
q e vez recebem o valor i quando Pi quer entrar na RC:
P1 P2
loop forever loop forever
loop loop
loop while ( q > 0) loop while ( q > 0 )
vez := 1 vez := 2
q := 1 q := 2
while ( vez # 1) while (vez # 2)
RC RC
q := 0 q := 0
RNC RNC
endloop endloop
O problema do Produtor - Consumidor usando buffer circular
Este problema aparece em muitos contextos práticos, onde processos
ditos "produtores" enviam dados ou mensagens para processos ditos "consumidores".
Exemplos: o problema de spooling de impressão visto em aula,
onde processos submetem nomes de arquivos para um processo servidor de impressão;
o uso de pipes pelo shell de Unix é outro exemplo onde a saída de um processo
é enviada para a entrada de outro processo através do pipe que nada mais é
do que um buffer de memória controlado pelo kernel do Unix. Outro exemplo é
o sistema X-Windows onde um processo "gerenciador de janela" recebe comandos
de atualização da janela de um outro processo.
Vamos estudar inicialmente o caso de 1 processo produtor e 1 processo
consumidor que utilizam para comunicação e sincronização um buffer de memória
buf[0..N-1] contendo N slots para mensagens e administrado de forma circular.
Nesse caso é possível sincronizar os dois processos usando apenas 2 variáveis
compartilhadas p e c com a característica desejável de que p é atualizada
apenas pelo produtor e c apenas pelo consumidor; p e c atuam como índices
sobre o buffer com a seguinte convenção: p representa o índice do próximo slot
livre do buffer onde será colocada a próxima mensagem e c representa o índice
da mensagem mais antiga e ainda não processada pelo consumidor.
A sincronização é obtida testando as variáveis p e c. Quando o buffer
está vazio vamos adotar a convenção de que p = c (inicialmente p = c = 0 ).
Após colocar uma mensagem no buffer o produtor incrementa o valor de p (módulo N) e após
retirar uma mensagem o consumidor incrementa o valor de c (módulo N). Fazendo um diagrama
é fácil de ver que ao preencher todos os slots do buffer surge uma ambiguidade
pois nesse momento p = c. Poderíamos resolver a ambiguidade criando uma variável
ct contendo o número corrente de slots ocupados e usá-la para sincronizar os
dois processos; no entanto ct teria que ser atualizada e lida pelos dois
processos o que é um problema conforme já vimos. Por essa razão vamos permitir
que apenas N-1 slots do buffer sejam ocupados bloqueando o produtor quando
isto acontece. Veremos então que nenhum código associado a uma RC será necessário
para sincronizar os dois processos que terão acesso concorrente a slots distintos
do buffer! Vamos supor também que cada processo possui uma variável privativa
onde é colocada a mensagem a ser enviada ou consumida. Vamos chamá-las de mp e
mc, respectivamente.
Produtor |
Consumidor |
loop forever | loop forever |
Produz mensagem mp | |
loop while (p + 1 mod N = c) //buffer cheio |
loop while (p = c ) // buffer vazio |
buf[p] := mp | mc := buf[c] |
p := p + 1 mod N | c := c + 1 mod N |
|
|
| Consome mensagem mc |
. . . | . . . |
endloop | endloop |
C. Soluções sem busy wait
Todas as soluções vistas até agora implicam em laços de espera ocupada
(busy wait) onde um processo continuamente testa uma condição que só será satisfeita através
de um outro processo. Isto obviamente desperdiça o tempo da CPU até o processo
ser interrompido pelo escalonador. Vamos tentar resolver o problema introduzindo
a possibilidade de processos interagirem com o escalonador através de uma função
chamada yield() onde o processo solicita ao escalonador que outro processo
receba o controle da CPU. Quando o processo for novamente escalonado
para execução ele continua imediatamente após a chamada yield().
Poderíamos então mudar a solução acima da seguinte
forma: no produtor a linha loop while ( p + 1 mod N = c) seria substituída por:
loop while ( p + 1 mod N = c) begin yield() end e no consumidor
a linha loop while (p = c ) seria substituída por:
loop while (p = c ) begin yield() end. A solução não é ideal:
suponha que o produtor seja muito mais rápido do que o consumidor, de forma que
o buffer quase sempre esteja cheio: após o produtor invocar yield,
eventualmente o escalonador vai escalonar novamente o produtor que
encontaria novamente o buffer cheio e assim sucessivamente. Uma solução completa
só é possível quando o produtor só for desbloqueado pelo escalonador
quando houver um ou mais slots livres e isso exige uma interação muito
maior dos processos envolvidos com o escalonador. Veremos que isto será
possível quando estudarmos semáforos.
Implementação de RC com instruções do tipo TSL
Instruções que permitam ler e escrever de forma atômica uma posição de memória
compartilhada permitem resolver de forma simples o problema de acesso exclusivo
a uma RC inclusive em sistemas multiprocessados (onde mais de uma CPU
executa processos de usuários). Instruções desse tipo são "Test and Set Lock"
e "Exchange Memory with Register" . A 1ª lê o conteúdo de uma posição de memória
num registrador e coloca o valor 1 na mesma posição e a 2ª troca o conteúdo
de uma posição de memória com um registrador
(por exemplo a instrução xchg da família 80x86).
A posição de memória pode ser usada para
controlar o acesso a uma dada RC (várias RCs distintas podem existir numa dada
aplicação). Quando a RC é de curta duração (é o caso mais comum) o controle de
acesso à RC pode ser feito com busy wait através do esqueleto de programa da figura 2.22
do livro texto (veja no arquivo ppt do
capítulo 2).
Caso a RC seja de longa duração é interessante fazer com que o protocolo de entrada
na RC também use a chamada yield() caso a RC esteja ocupada. A seguinte função
poderia fazer isso:
enter_cs(lock)
begin
L1: TSL Reg, lock
CMP Reg, 0
JZ LIVRE
yield() // RC estava ocupada, libere o processador e tente mais tarde
JMP L1
LIVRE: return()
end
A saída da RC poderia ser escrita assim:
leave_cs(lock)
begin
lock := 0
end
Vamos agora resolver o problema mais geral onde temos N produtores e N consumidores.
Solução do problema de N produtores e M consumidores usando buffer circular e função yield
Agora precisamos controlar o acesso ao buffer através de uma RC pois os ponteiros
p e c não podem ser atualizados concorrentemente. Observe que agora cada produtor
tem sua variável privativa mp e cada consumidor sua variável privativa mc:
Produtor Consumidor
loop forever loop forever
Produz mensagem mp
loop loop
enter_cs(lock) enter_cs(lock)
if ( p + 1 mod N = c) if (p = c)
begin begin
leave_cs(lock) leave_cs(lock)
yield() yield()
end end
else break // sai do loop interno else break
end loop end loop
buf[p] := mp // RC continua ocupada mc := buf[c]
p := p + 1 mod N c := c+1 mod N
leave_cs(lock) leave_cs(lock)
. . . Consome mensagem mc
end loop end loop
Exercícios
1. Re-escreva a função enter_cs(lock) utilizando a instrução xchg do 80X86.
2. Analise a seguinte solução do problema de N produtores e M consumidores
comparando-a com a apresentada acima:
Produtor Consumidor
loop forever loop forever
Produz mensagem mp
enter_cs(lock1) enter_cs(lock2)
while ( p + 1 mod N = c) yield() while (p = c) yield()
buf[p] := mp // RC continua ocupada mc := buf[c]
p := p + 1 mod N c := c+1 mod N
leave_cs(lock1) leave_cs(lock2)
. . . Consome mensagem mc
end loop end loop
C.1 Solução via semáforos ( Dijkstra, 1965 )
A seguinte solução do problema de exclusão mútua para N processos prevê
um mecanismo de sincronização interagindo com o escalonador
e portanto deve ser implementada dentro do kernel.
Ela provê duas operações indivisíveis sobre um inteiro não negativo denominado
semáforo em analogia com um semáforo de tráfego,
e que podem causar o reescalonamento do processo que invoca a operação.
Essas operações foram denominadas P() e V()
e correspondem
no caso mais simples de semáforos binários aos protocolos de entrada e de saída de uma RC,
respectivamente. Vamos manter a notação de Dijkstra por ser mais curta
(o livro texto usa down() e up() respectivamente).
Além dessas operações é permitido atribuir um valor inicial ao semáforo.
Um semáforo é dito binário quando só toma os valores 0 e 1.
Vamos descrever agora as operações P e V, lembrando que elas teriam que
ser implementadas com auxílo de função semelhante a enter_cs()
vista anteriormente, ou seja de forma indivisível. A cada semáforo s existe
associada uma fila s.queue de processos que possam estar bloqueados pelo semáforo.
Semântica da operação P(s)
se s > 0 decrementa s senão bloqueia processo colocando-o na fila s.queue
Semântica da operação V(s)
se fila s.queue não vazia desbloqueia o 1º processo da fila, senão incrementa s
Um semáforo binário s com valor inicial 1 pode ser usado para delimitar
uma RC da seguinte forma (exercício: explique porque!):
P(s)
RC
V(s)
Neste caso o semáforo é muitas vezes chamado de mutex.
Semáforos podem ser usados também como contadores que podem ser atômicamente
incrementados e decrementados, além de permitirem bloquear e desbloquear o
processo que invoca P() ou V().
Implementação de operações sobre semáforos - um problema do ovo e da galinha
O exemplo acima mostra o acesso exclusivo a uma Região Crítica usando as operações P e V
sobre um semáforo binário. Em situações de corrida elas podem ser invocadas
"ao mesmo tempo" por processos concorrentes e como elas atualizam variáveis
compartilhadas, claramente precisam ser implementadas de forma indivisível, ou seja,
como Regiões Críticas. A rotina enter_cs() abaixo, cujo corpo está em assembler,
resolve o problema para a família de processadores X86,
implementando o protocolo de entrada numa RC com auxílio da instrução xchg Reg,[mem]:
enter_cs(int * lock)
{
asm("
l1:
movl 8(%ebp),%ecx # store lock address in ecx
xor %eax, %eax
inc %eax # 1 to eax
xchg %eax, (%ecx) # exchange eax with lock value
or %eax, %eax # set flags
jnz l1 # eax received 1, try again
");
} // return with lock set to 1
Observe também que como a RC é de curta duração, ela foi implementada com busy wait.
Você poderá ver um exemplo do seu uso neste
teste com dois threads.
Solução do problema de N Produtores e M Consumidores via semáforos
O problema geral dos Produtores e Consumidores pode ser resolvido
com 3 semáforos: um semáforo binário s (valor inicial 1)
para garantir exclusão mútua no acesso
ao buffer, um semáforo empty (valor inicial N, representando o número
de slots vazios no buffer)e um semáforo full
(valor inicial 0, representando o número de slots ocupados no buffer).
O pseudo-código a seguir ilustra a sua utilização:
Produtor |
Consumidor |
loop forever | loop forever |
Produz mensagem mp | |
P(empty) //se empty=0, buffer cheio, bloqueia |
P(full) // se full = 0, buffer vazio, bloqueia |
P(s) //obtém acesso exclusivo ao buffer | P(s) //obtém acesso exclusivo ao buffer |
buf[p] := mp | mc := buf[c] |
p := p + 1 mod N | c := c + 1 mod N |
V(s) //libera acesso exclusivo ao buffer | V(s) //libera acesso exclusivo ao buffer |
V(full) //atualiza contador de slots ocupados | V(empty) //atualoza contador de slots vazios |
|
|
| Consome mensagem mc |
. . . | . . . |
endloop | endloop |
A solução apresentada utiliza os semáforos empty e full para sincronizar
produtores com consumidores, bloqueando um produtor quando empty = 0 e
bloqueando um consumidor quando full = 0 e desbloqueando, se apropriado, um consumidor
via V(full) e um produtor via V(empty). Observe também que como as variáveis
p e c não são mais usadas para sincronização todos os N slots do buffer podem
ser utilizados. Compare a clareza e eficiência dessa solução com a vista
anteriormente usando yield().
Deadlocks
Apesar da aparente simplicidade, quando mais de um semáforo é usado
o sistema pode facilmente entrar em deadlock. Por exemplo, na solução acima
se a ordem das operações no Produtor, P(full) e P(s) forem invertidas
e o buffer estiver cheio (full = 0), o produtor ficará bloqueado dentro da RC
delimitada pelo par P(s) V(s). Para desbloquear o produtor é preciso que
um consumidor tenha acesso à RC, mas qualquer consumidor ficará bloqueado
ao executar P(s).
Outra possibilidade de deadlock muito comum é quando a aplicação requer
mais de uma RC e elas sejam executadas em ordem inversa por dois processos.
É fácil de ver que o código a seguir pode facilmente levar a um deadlock
de dois processos, numa situação de corrida, utilizando dois mutexes s e t
(valor inicial = 1):
Processo 1 Processo2
P(s) P(t)
... ...
P(t) P(s)
... ...
V(s) V(t)
... ...
V(t) V(s)
Exercícios
1. Escreva rotinas para implementar as operações P e V tomando como modelo
a função enter_cs() vista anteriormente.
Dica: é conveniente considerar um semáforo s como uma
estrutura com 3 campos: s.val para o valor do semáforo. s.lock para implementar
exclusão mútua da operação e s.queue para representar uma fila
de processos bloqueados em s. Suponha também que as seguintes
chamadas de sistema foram acrescentadas: queue_insert(s.queue)
que bloqueia o processo corrente, inserindo-o na fila s.queue
e queue_remove(s.queue) que desbloqueia o 1º processo da fila
s.queue, caso não vazia. Observe que estas duas funções são essenciais para
implementar a semântica de semáforos e evitar busy wait, ao
contrário da função yield() vista anteriormente.
2. A implementação anterior ficaria mais eficiente caso semáforos possam
tomar valores negativos na operação P(s): nesse caso | s.val | representa o número
de processos bloqueados na fila s.queue. Re-escreva a solução anterior utilizando
este recurso. Como mudaria a descrição da semântica das operações P e V nesse caso?
Observe que processos devem ficar bloqueados ou desbloqueados exatamente nas
mesmas condições de antes, ou seja, nenhuma mudança seria requerida em qualquer
aplicação utilizando P e V!
3. Analise a solução do problema dos
Leitores e Escritores
(slide 43) e mostre que ela
dá prioridade aos leitores no acesso ao recurso compartilhado (base de dados).
4. Modifique a solução do problema do
"barbeiro dorminhoco", (slide 45) onde o semáforo barbers tem um
valor inicial k > 0, representando o número de barbeiros que estão ociosos
na barbearia e que são requisitados e liberados pelos clientes; desta forma
Você terá uma solução mais natural do problema.
5. Duas classes de processos denominadas respectivamente, "homens" e "mulheres",
compartilham um recurso comum denominado "banheiro", da seguine forma:
- uma ou mais mulheres podem usar simultaneamente o banheiro;
- um ou mais homens podem usar simultaneamente o banheiro;
- qualquer combinação de homens e mulheres não pode usar simultaneamente o banheiro.
Você deve sincronizar o acesso ao banheiro pelas 2 classes de processos utilizando
as operações P e V de Dijkstra.
Sugestões:
- utilize um semáforo binário h para garantir exclusão mútua dos homens
ao executar os protocolos
de entrada e de saida da RC (uso do banheiro);
- utilize um semáforo binário m para garantir exclusão mútua das mulheres
ao executar os protocolos de entrada e de saida da RC (uso do banheiro);
- utilize um semáforo binário b para que homens possam excluir o acesso
de mulheres quando eles estão utilizando o banheiro e também para que mulheres
possam excluir o acesso de homens quando elas estão usando o banheiro;
- utilize dois contadores para registrar, respectivamente, o número de homens
e de mulheres presentes no banheiro.
- uma política simétrica de acesso ao banheiro que dê prioridade a mulheres enquanto
há mulheres utilizando o banheiro e vice versa, dando prioridade a homens
enquanto há homens utilizando o banheiro, conduz a uma solução bastante simples.
6. Escreva o código de um Monitor para resolver o problema acima, contendo
os procedimentos: Homem_quer_usar_banheiro(), Homem_deixa_banheiro(),
Mulher_quer_usar_banheiro() e Mulher_deixa_banheiro() . Estabeleça claramente
o objetivo e as condições iniciais das variáveis utilizadas.