Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

O Problema da Exclusão Mútua

Volte pro counter++. Aquela linha vira três instruções: lê counter num registrador, incrementa o registrador, escreve de volta. O bug aparece quando uma thread é interrompida entre a leitura e a escrita, e outra thread lê o mesmo valor antigo antes da primeira salvar o resultado. As duas escrevem por cima uma da outra, e um incremento evapora.

O trecho de código que precisa rodar sem essa interferência (aqui, a sequência lê-incrementa-escreve) é o que queremos proteger. E “proteger” significa uma coisa específica: no máximo uma thread pode estar dentro dele por vez.

Vale insistir nesse ponto, porque é onde muita gente entende errado. Exclusão mútua não é sobre duas threads executarem “ao mesmo tempo” no sentido físico. Num computador single-core, duas threads nunca executam de verdade simultaneamente, e o bug do contador acontece do mesmo jeito, porque o escalonador interrompe uma thread no meio das três instruções. O que importa é a ocupação do trecho: uma thread que foi interrompida no meio do counter++ ainda está “dentro” dele, mesmo parada, sem executar nada. Exclusão mútua exige que, enquanto uma thread ocupa o trecho, nenhuma outra entre, esteja ela rodando ou preemptada.

O protocolo de entrada e saída

Pra impor essa ocupação exclusiva, cada thread precisa pedir permissão antes de entrar no trecho protegido e avisar quando sair. A estrutura geral fica assim:

enquanto (true) {
    // ... código que não mexe no recurso compartilhado ...

    protocolo_de_entrada()      // pede permissão pra entrar

    // trecho protegido: no máximo uma thread aqui dentro

    protocolo_de_saida()        // libera pra próxima

    // ... resto ...
}

A pergunta central é: o que colocar em protocolo_de_entrada e protocolo_de_saida pra que a coisa funcione? Uma resposta correta para essa pergunta precisa satisfazer três propriedades. Elas foram formuladas por Dijkstra nos anos 60, e valem para qualquer mecanismo, seja um mutex, um semáforo, ou um algoritmo feito na unha só com variáveis compartilhadas.

Exclusão mútua. No máximo uma thread ocupa o trecho protegido por vez. É a propriedade que dá nome a tudo, e é o que faltava no contador. Sem ela, o counter++ de duas threads se intercala e incrementos se perdem.

Progresso. Se ninguém está no trecho protegido e uma ou mais threads querem entrar, a decisão de qual entra não pode ser adiada pra sempre. Ou seja, o protocolo não pode travar num impasse quando o recurso está livre. Uma “solução” que simplesmente proíbe todo mundo de entrar satisfaz a exclusão mútua de forma trivial, e é inútil justamente por violar o progresso.

Espera limitada. Existe um limite pra quantas vezes outras threads conseguem entrar depois que uma thread pediu pra entrar e antes dela conseguir. Sem isso, uma thread azarada pode ficar eternamente vendo as outras passarem na frente. Esse problema tem nome próprio, starvation, e um mecanismo pode garantir exclusão mútua e progresso e ainda assim deixar uma thread morrer de fome.

Por que não fazer na unha

Uma reação natural é tentar resolver isso sozinho, com uma variável de flag:

int ocupado = 0;

void entrar(void) {
    while (ocupado) { }   // espera enquanto está ocupado
    ocupado = 1;          // marca como ocupado
}

void sair(void) {
    ocupado = 0;
}

Parece razoável, e está errado. while (ocupado) e ocupado = 1 são duas operações separadas. Duas threads podem, ambas, ver ocupado == 0, sair as duas do while, e as duas marcarem ocupado = 1 e entrarem juntas no trecho. O protocolo de entrada tem, ele mesmo, exatamente a race que estamos tentando eliminar. Aninhar a proteção não resolve, só empurra o problema um nível pra baixo.

Existem algoritmos corretos feitos só com leituras e escritas comuns, como o algoritmo de Peterson e o de Dekker, mas eles são engenhosos, limitados a duas threads na forma clássica, e frágeis diante do reordenamento de instruções que o hardware faz. Na prática, ninguém protege seção crítica assim. O que se usa é uma primitiva que resolve o entrar/sair de forma atômica, com apoio do hardware e do sistema operacional. Essa primitiva é o lock.