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.

  1. 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.
  2. Nenhuma hipótese pode ser feita a respeito da velocidade relativa dos processos
  3. 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.
  4. Dois ou mais processos não devem se bloquear indefinidamente na entrada de uma RC (deadlock).
  5. 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 foreverloop forever
q1:= 1q2:= 1
vez := 1vez := 2
loop while(vez =1 and q2 = 1)loop while(vez = 2 and q1 = 1)
RCRC
q1:= 0q2:= 0


RNCRNC
endloopendloop

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 foreverloop forever
q1:= 1 + q2q2:= 1 + q1
loop while ( q2 > 0   and   q2 < q1 ) loop while ( q1 > 0   and   q1 <= q2 )
RCRC
q1:= 0q2:= 0


RNCRNC
endloopendloop
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 foreverloop 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
  . . .   . . .
endloopendloop

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 foreverloop 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 bufferP(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 bufferV(s) //libera acesso exclusivo ao buffer
  V(full)   //atualiza contador de slots ocupadosV(empty) //atualoza contador de slots vazios


  Consome mensagem mc
  . . .   . . .
endloopendloop
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:

  1. uma ou mais mulheres podem usar simultaneamente o banheiro;
  2. um ou mais homens podem usar simultaneamente o banheiro;
  3. 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:

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.