Sistemas Operacionais

Impasses (Deadlocks)

Professor: Gabriel Soares Baptista

Introdução

Hoje veremos por que um sistema pode parar de progredir mesmo quando a sincronização parece correta.

Deadlock não é lentidão.

É um bloqueio estrutural em que processos ou threads ficam presos esperando uns pelos outros.

Ele surge quando recursos são adquiridos em ordens incompatíveis e ninguém consegue completar o que começou.

A Intuição do Problema

Imagine uma ponte estreita com um carro em cada direção.

Cada um já ocupa parte do caminho e precisa que o outro recue primeiro.

Se nenhum ceder, o sistema não fica apenas mais lento.

Ele fica travado.

DeadlockIntroP1Processo AR2ScannerP1->R2aguardaP2Processo BR1ImpressoraP2->R1aguardaR1->P1alocadoR2->P2alocado

Definição Formal

Um conjunto de processos está em impasse quando cada processo do conjunto espera por um evento que apenas outro processo do mesmo conjunto pode causar.

Consequência:

  • ninguém termina
  • ninguém libera recursos
  • o bloqueio se sustenta sozinho

Essa é a diferença entre um sistema ocupado e um sistema logicamente paralisado.

O Que Está em Disputa

Deadlocks acontecem porque processos disputam recursos.

Tipo Descrição Exemplos
Preemptível Pode ser retirado e devolvido depois CPU, páginas de memória
Não preemptível Não pode ser tomado à força sem quebrar a execução impressora, mutex, lock de banco

O problema aparece quase sempre com recursos não preemptíveis.

Ciclo de Vida de um Recurso

Todo recurso passa por três etapas básicas:

  1. o processo solicita
  2. o processo usa
  3. o processo libera

O deadlock aparece quando diferentes processos executam esse ciclo em ordens incompatíveis.

Deadlock, Starvation e Livelock

Fenômeno O que acontece Progresso?
Deadlock espera circular permanente não
Starvation um processo é sempre preterido sim, para os outros
Livelock todos reagem, mas ninguém avança atividade sem progresso útil

Resumo:

  • no deadlock, todos ficam parados
  • na starvation, o sistema anda sem atender alguém
  • no livelock, todos se mexem, mas nada sai do lugar

Seguro, Inseguro e em Deadlock

Estado Significado
Seguro existe uma sequência em que todos podem terminar
Inseguro não há garantia formal de que todos terminarão
Deadlock ninguém consegue prosseguir com os recursos disponíveis

Estado inseguro não é deadlock imediato.

Ele significa que o sistema entrou numa região de risco.

As Quatro Condições de Coffman

Para haver deadlock, quatro condições devem existir ao mesmo tempo:

  1. Exclusão mútua
  2. Posse e espera
  3. Não preempção
  4. Espera circular
graph TD
    A[Exclusao Mutua] --> D[Deadlock]
    B[Posse e Espera] --> D
    C[Nao Preempcao] --> D
    E[Espera Circular] --> D

Ideia-Chave de Coffman

Deadlock não é qualquer espera longa.

Ele exige a presença simultânea das quatro condições.

Se você quebrar apenas uma delas, o ciclo não se fecha.

Na prática, quase toda estratégia de prevenção ataca exatamente esse ponto.

Exemplo em C: Duas Threads Travam

O erro clássico é adquirir locks em ordem oposta.

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER;

void* threadA(void* arg) {
    pthread_mutex_lock(&lock1);
    sleep(1);
    pthread_mutex_lock(&lock2);
    pthread_mutex_unlock(&lock2);
    pthread_mutex_unlock(&lock1);
    return NULL;
}

void* threadB(void* arg) {
    pthread_mutex_lock(&lock2);
    sleep(1);
    pthread_mutex_lock(&lock1);
    pthread_mutex_unlock(&lock1);
    pthread_mutex_unlock(&lock2);
    return NULL;
}

Sequência do Travamento

O sleep(1) não cria o deadlock.

Ele apenas aumenta a chance de o escalonamento revelar o bug.

sequenceDiagram
    participant A as Thread A
    participant L1 as lock1
    participant B as Thread B
    participant L2 as lock2
    A->>L1: lock(lock1)
    B->>L2: lock(lock2)
    A->>L2: espera por lock2
    B->>L1: espera por lock1
    Note over A,L2: Espera circular permanente

Onde Estão as Quatro Condições?

No exemplo anterior:

  • lock1 e lock2 são exclusivos
  • A segura lock1 e espera lock2
  • B segura lock2 e espera lock1
  • o sistema não toma mutexes à força

Logo, o ciclo de espera fica completo.

Grafo de Alocação de Recursos

No Resource Allocation Graph:

  • processo é círculo
  • recurso é quadrado
  • $P \to R$ significa pedido
  • $R \to P$ significa alocação
RAGP1P1R2R2P1->R2solicitaP2P2R1R1P2->R1solicitaR1->P1alocadoR2->P2alocado

Wait-for Graph

Quando focamos só em processos, removemos os nós de recurso e ligamos quem espera por quem.

Esse formato é muito usado em bancos de dados e ferramentas de diagnóstico.

WFGP1P1P2P2P1->P2esperaP3P3P2->P3esperaP3->P1espera

Ciclo Nem Sempre Significa Deadlock

Com múltiplas instâncias de um recurso, o ciclo deixa de ser prova suficiente.

Se algum processo puder terminar e devolver uma instância, o sistema ainda escapa.

MultiInstanceP1P1RR2 instanciasP1->RsolicitaP2P2P3P3R->P2instancia 1R->P3instancia 2

Estratégias Para Lidar com Deadlocks

Estratégia Ideia Custo
Avestruz ignorar zero overhead direto
Detecção e recuperação deixar acontecer e depois desfazer monitoramento + rollback
Evitação só conceder se o estado continuar seguro alto custo analítico
Prevenção quebrar uma condição de Coffman menos flexibilidade

Cada uma sacrifica algo diferente para reduzir risco.

Algoritmo do Avestruz

Em sistemas gerais, muitas vezes o kernel não tenta resolver genericamente todo deadlock.

A lógica é pragmática:

  • deadlocks raros podem custar menos que uma política pesada de prevenção
  • em muitos casos, deadlock é tratado como bug de implementação
flowchart LR
    A[Requisicao de recurso] --> B{Vale monitorar formalmente?}
    B -- Nao --> C[Concede e segue]
    C --> D{Travou?}
    D -- Nao --> E[Execucao normal]
    D -- Sim --> F[Bug, reinicio, rollback ou correcao de codigo]

Detecção Matricial

Para múltiplos recursos, usamos quatro estruturas:

  • $E$: recursos existentes
  • $A$: recursos disponíveis
  • $C$: alocação atual
  • $R$: requisições pendentes

A ideia é procurar processos que ainda conseguem terminar com o que há em $A$.

Se algum termina, ele devolve recursos e o teste continua.

Detecção em C

#include <stdio.h>
#include <stdbool.h>

#define N 3
#define M 4

void detectar_deadlock(int available[M], int current[N][M], int request[N][M]) {
    int work[M];
    bool finish[N] = {false};

    for (int j = 0; j < M; j++) work[j] = available[j];

    bool mudou = true;
    while (mudou) {
        mudou = false;
        for (int i = 0; i < N; i++) {
            if (finish[i]) continue;
            bool pode_terminar = true;
            for (int j = 0; j < M; j++) {
                if (request[i][j] > work[j]) {
                    pode_terminar = false;
                    break;
                }
            }
            if (pode_terminar) {
                for (int j = 0; j < M; j++) work[j] += current[i][j];
                finish[i] = true;
                mudou = true;
            }
        }
    }
}

Exemplo Passo a Passo da Detecção

Considere:

$$A = (2,1,0,0)$$

Processo Alocado $C$ Requisição $R$
$P_1$ $(0,0,1,0)$ $(2,0,0,1)$
$P_2$ $(2,0,0,1)$ $(1,0,1,0)$
$P_3$ $(0,1,2,0)$ $(2,1,0,0)$

Ordem possível:

$$P_3 \rightarrow P_2 \rightarrow P_1$$

Como todos terminam, não há deadlock.

Recuperação

Depois de detectar um ciclo, o sistema precisa desfazê-lo.

Opções clássicas:

  1. preempção de algum recurso, se for possível
  2. rollback para um estado seguro
  3. abortar uma ou mais vítimas

Critérios de escolha incluem custo de rollback, prioridade, recursos segurados e impacto para o usuário.

Evitação: Algoritmo do Banqueiro

O Banqueiro só concede um recurso se o sistema continuar em estado seguro depois da concessão.

Ele não pergunta apenas se há recurso livre agora.

Ele pergunta se ainda existirá uma sequência de término para todos depois do empréstimo.

Modelos usados:

  • Max
  • Allocation
  • Need = Max - Allocation
  • Available

Como Pensar no Banqueiro

Sempre que um processo pede algo novo, o sistema faz uma simulação mental:

  1. concede provisoriamente
  2. recalcula Available, Allocation e Need
  3. tenta encontrar uma sequência segura de término

Se encontrar, concede de verdade.

Se não encontrar, adia o pedido.

Exemplo Intuitivo do Banqueiro

Cliente Máximo Já tem Necessidade
A 6 1 5
B 5 1 4
C 4 2 2
D 7 1 6

Se o caixa tem 5, ele consegue atender C.

Quando C termina, devolve recursos e abre caminho para os demais.

Portanto, o estado é seguro.

Exemplo Seguro Passo a Passo

Um único tipo de recurso.

Processo Máximo Já recebeu Ainda precisa
$P_1$ 9 3 6
$P_2$ 4 2 2
$P_3$ 7 2 5

$$Available = 3$$

Sequência segura:

$$P_2 \rightarrow P_3 \rightarrow P_1$$

Nem todos terminam de imediato, mas existe uma ordem segura.

Exemplo de Estado Inseguro

Processo Máximo Já recebeu Ainda precisa
$P_1$ 8 4 4
$P_2$ 6 4 2
$P_3$ 5 3 2

$$Available = 1$$

Agora ninguém consegue terminar.

Se nenhum processo conclui, ninguém devolve recursos.

Esse é um estado inseguro.

Verificação de Segurança em C

#include <stdio.h>
#include <stdbool.h>

#define N 5
#define M 3

bool estado_seguro(int available[M], int allocation[N][M], int max_demand[N][M]) {
    int need[N][M], work[M], ordem[N], k = 0;
    bool finish[N] = {false};

    for (int i = 0; i < N; i++)
        for (int j = 0; j < M; j++)
            need[i][j] = max_demand[i][j] - allocation[i][j];

    for (int j = 0; j < M; j++) work[j] = available[j];

    bool progresso = true;
    while (progresso) {
        progresso = false;
        for (int i = 0; i < N; i++) {
            if (finish[i]) continue;
            bool pode_terminar = true;
            for (int j = 0; j < M; j++) {
                if (need[i][j] > work[j]) { pode_terminar = false; break; }
            }
            if (pode_terminar) {
                for (int j = 0; j < M; j++) work[j] += allocation[i][j];
                finish[i] = true;
                ordem[k++] = i;
                progresso = true;
            }
        }
    }
    return k == N;
}

Limitação do Banqueiro

O algoritmo é elegante, mas pouco usado em sistemas operacionais gerais.

Motivo principal:

  • ele exige que cada processo declare sua demanda máxima com antecedência

Na prática, isso raramente é conhecido com precisão.

Prevenção

Na prevenção, o sistema muda as regras para tornar o deadlock impossível.

Condição atacada Estratégia
Exclusão Mútua virtualização, como spooling
Posse e Espera pedir tudo no início
Não Preempção liberar tudo ao falhar, quando possível
Espera Circular ordem global de aquisição

Ordem Global de Locks

Se todos adquirirem recursos na mesma ordem, o ciclo não fecha.

#include <pthread.h>

extern pthread_mutex_t lock1;
extern pthread_mutex_t lock2;

void* threadA(void* arg) {
    pthread_mutex_lock(&lock1);
    pthread_mutex_lock(&lock2);
    pthread_mutex_unlock(&lock2);
    pthread_mutex_unlock(&lock1);
    return NULL;
}

void* threadB(void* arg) {
    pthread_mutex_lock(&lock1);
    pthread_mutex_lock(&lock2);
    pthread_mutex_unlock(&lock2);
    pthread_mutex_unlock(&lock1);
    return NULL;
}

trylock e Retrocesso

Outra estratégia prática é falhar rápido e soltar o que já foi adquirido.

#include <pthread.h>
#include <unistd.h>

extern pthread_mutex_t lock1;
extern pthread_mutex_t lock2;

void* worker(void* arg) {
    while (1) {
        pthread_mutex_lock(&lock1);
        if (pthread_mutex_trylock(&lock2) == 0) {
            pthread_mutex_unlock(&lock2);
            pthread_mutex_unlock(&lock1);
            break;
        }
        pthread_mutex_unlock(&lock1);
        usleep(1000);
    }
    return NULL;
}

Sem o pequeno backoff, isso pode virar livelock.

Próximos passos

Na próxima aula: 8 - memory management.

Depois de entender como processos travam ao disputar recursos, vamos estudar o espaço onde eles vivem: a memória.