O Jantar dos Filósofos

Programação Aplicada em .NET • Prof. Me. William Tenório

O problema

Cinco filósofos estão sentados ao redor de uma mesa redonda. Na frente de cada um há um prato de macarrão e, entre cada par de pratos, um único garfo — são 5 garfos no total. A vida de um filósofo se resume a duas atividades: pensar e comer.

Para comer, o filósofo precisa de dois garfos: o da sua esquerda e o da sua direita. Ele só pode pegar um garfo de cada vez, e um garfo só pode estar na mão de um filósofo por vez. Depois de comer, ele devolve os dois garfos à mesa e volta a pensar.

F0 F1 F2 F3 F4 G0 G1 G2 G3 G4
O filósofo i usa o garfo i (esquerda) e o garfo (i + 1) % 5 (direita). Cada garfo é disputado por dois vizinhos.

O problema foi proposto por Edsger Dijkstra em 1965 (e reescrito nessa forma por Tony Hoare) como exercício de sincronização. Ele parece uma brincadeira, mas descreve uma situação muito comum: várias tarefas que precisam de mais de um recurso compartilhado ao mesmo tempo — por exemplo, uma transferência bancária que precisa bloquear a conta de origem e a de destino.

O desafio é escrever o comportamento dos filósofos de forma que todos consigam comer, nenhum fique esperando para sempre e, de preferência, vários possam comer ao mesmo tempo.

O que pode dar errado

As 4 condições para o deadlock (condições de Coffman)

Um deadlock só acontece se as quatro condições abaixo forem verdadeiras ao mesmo tempo. Toda solução funciona quebrando pelo menos uma delas:

  1. Exclusão mútua — um garfo só pode estar com um filósofo por vez.
  2. Posse e espera — o filósofo segura um garfo enquanto espera o outro.
  3. Sem preempção — ninguém pode tomar o garfo da mão de outro filósofo.
  4. Espera circular — F0 espera F1, que espera F2, ..., que espera F0.

A versão ingênua — e o deadlock

Cada garfo é um object protegido por lock, como o saldo da aula de Concorrência. O filósofo pega o garfo da esquerda e, sem soltá-lo, pega o da direita (um lock dentro do outro).

using System.Diagnostics;

const int N = 5;
object[] garfos = new object[N];
for (int i = 0; i < N; i++)
    garfos[i] = new object();

void Filosofo(int id)
{
    int esquerdo = id;
    int direito = (id + 1) % N;

    for (int refeicao = 1; refeicao <= 3; refeicao++)
    {
        Thread.Sleep(100);                       // pensando

        lock (garfos[esquerdo])
        {
            Console.WriteLine($"Filósofo {id} pegou o garfo {esquerdo}");
            Thread.Sleep(50);                    // facilita o deadlock aparecer

            lock (garfos[direito])
            {
                Console.WriteLine($"Filósofo {id} comendo ({refeicao}ª refeição)");
                Thread.Sleep(100);               // comendo
            }
        }
    }
    Console.WriteLine($"Filósofo {id} terminou o jantar");
}

// ---------- daqui para baixo, não muda nas soluções ----------
Stopwatch relogio = Stopwatch.StartNew();
List<Thread> filosofos = new List<Thread>();

for (int i = 0; i < N; i++)
{
    int id = i;                                  // cópia para a lambda
    Thread t = new Thread(() => Filosofo(id));
    t.IsBackground = true;
    filosofos.Add(t);
    t.Start();
}

bool todosJantaram = filosofos.All(t => t.Join(5000));

if (todosJantaram)
    Console.WriteLine($"Todos jantaram em {relogio.Elapsed.TotalSeconds:F1}s");
else
    Console.WriteLine("DEADLOCK: depois de 5s, ainda há filósofos travados esperando um garfo.");
dotnet run

Por que Thread e não Task.Run? Criando as threads diretamente garantimos que os 5 filósofos começam juntos. Com Task.Run, em computadores com poucos núcleos, o thread pool pode demorar para iniciar o último filósofo e "esconder" o deadlock. t.Join(5000) espera a thread terminar por até 5 segundos e IsBackground = true permite o programa encerrar mesmo com threads travadas.

Ao rodar, todos pegam o garfo da esquerda e o programa trava. Repare que o código não tem nenhum erro de sintaxe ou de lógica se olharmos um filósofo isolado — o problema só existe na combinação das threads. Por isso deadlocks são tão difíceis de achar em sistemas reais: às vezes acontecem uma vez a cada mil execuções.

Teste: remova o Thread.Sleep(50) entre os dois lock e rode várias vezes. O deadlock continua acontecendo sempre?

Solução 1 — Um único lock para a mesa toda

A ideia mais simples: em vez de disputar garfos, os filósofos disputam a mesa. Quem consegue o lock da mesa pega os dois garfos, come e libera. Substitua apenas a função Filosofo (e declare a mesa logo acima dela):

object mesa = new object();

void Filosofo(int id)
{
    for (int refeicao = 1; refeicao <= 3; refeicao++)
    {
        Thread.Sleep(100);                       // pensando

        lock (mesa)
        {
            Console.WriteLine($"Filósofo {id} comendo ({refeicao}ª refeição)");
            Thread.Sleep(100);                   // comendo
        }
    }
    Console.WriteLine($"Filósofo {id} terminou o jantar");
}

Solução 2 — Ordem global dos garfos (hierarquia de recursos)

Numeramos os garfos e combinamos uma regra: todo filósofo pega primeiro o garfo de menor número. Para F0 a F3 nada muda (esquerdo < direito). Mas F4 usa os garfos 4 e 0, então ele pega o garfo 0 primeiro — ou seja, F4 é o único que começa pela direita.

void Filosofo(int id)
{
    int esquerdo = id;
    int direito = (id + 1) % N;
    int primeiro = Math.Min(esquerdo, direito);
    int segundo = Math.Max(esquerdo, direito);

    for (int refeicao = 1; refeicao <= 3; refeicao++)
    {
        Thread.Sleep(100);                       // pensando

        lock (garfos[primeiro])
        {
            Thread.Sleep(50);
            lock (garfos[segundo])
            {
                Console.WriteLine($"Filósofo {id} comendo ({refeicao}ª refeição)");
                Thread.Sleep(100);               // comendo
            }
        }
    }
    Console.WriteLine($"Filósofo {id} terminou o jantar");
}

Uma variação equivalente é a solução assimétrica: filósofos de número par pegam primeiro o garfo da esquerda, e os ímpares, o da direita.

Solução 3 — O garçom (limitar quem senta à mesa)

Se no máximo 4 dos 5 filósofos tentarem comer ao mesmo tempo, sobram 5 garfos para 4 pessoas: pelo menos uma delas certamente consegue os dois garfos, come e libera para as outras. Quem controla isso é um "garçom": um SemaphoreSlim com N − 1 permissões — exatamente a ferramenta de capacidade da aula de Concorrência.

SemaphoreSlim garcom = new SemaphoreSlim(N - 1);   // no máximo 4 à mesa

void Filosofo(int id)
{
    int esquerdo = id;
    int direito = (id + 1) % N;

    for (int refeicao = 1; refeicao <= 3; refeicao++)
    {
        Thread.Sleep(100);                       // pensando

        garcom.Wait();                           // pede licença ao garçom
        try
        {
            lock (garfos[esquerdo])
            {
                Thread.Sleep(50);
                lock (garfos[direito])
                {
                    Console.WriteLine($"Filósofo {id} comendo ({refeicao}ª refeição)");
                    Thread.Sleep(100);           // comendo
                }
            }
        }
        finally
        {
            garcom.Release();                    // sai da mesa
        }
    }
    Console.WriteLine($"Filósofo {id} terminou o jantar");
}

Solução 4 — Tentar e desistir (Monitor.TryEnter)

O filósofo pega o garfo da esquerda e tenta pegar o da direita por um tempo limitado. Se não conseguir, devolve o da esquerda, espera um pouco e tenta de novo. Monitor.TryEnter(objeto, 50) é a versão "com tempo limite" do lock: retorna true se conseguiu em até 50 ms, ou false se desistiu.

void Filosofo(int id)
{
    int esquerdo = id;
    int direito = (id + 1) % N;

    for (int refeicao = 1; refeicao <= 3; refeicao++)
    {
        Thread.Sleep(100);                       // pensando

        bool comeu = false;
        while (!comeu)
        {
            lock (garfos[esquerdo])
            {
                Thread.Sleep(50);
                if (Monitor.TryEnter(garfos[direito], 50))
                {
                    try
                    {
                        Console.WriteLine($"Filósofo {id} comendo ({refeicao}ª refeição)");
                        Thread.Sleep(100);       // comendo
                        comeu = true;
                    }
                    finally
                    {
                        Monitor.Exit(garfos[direito]);
                    }
                }
            }

            if (!comeu)
            {
                Console.WriteLine($"Filósofo {id} desistiu e devolveu o garfo {esquerdo}");
                Thread.Sleep(Random.Shared.Next(10, 200));   // espera ALEATÓRIA
            }
        }
    }
    Console.WriteLine($"Filósofo {id} terminou o jantar");
}

Comparando as soluções

SoluçãoCondição quebradaComem juntosPonto fraco
Ingênua Nenhuma — Deadlock
1. Lock da mesa Posse e espera 1 Desperdiça concorrência
2. Ordem dos garfos Espera circular até 2 Todo o código precisa respeitar a ordem
3. Garçom (N − 1) Espera circular até 2 Coordenador central
4. Tentar e desistir Posse e espera até 2 Livelock / starvation; mais difícil de prever

Existem soluções mais sofisticadas, como a de Chandy e Misra (1984), em que os garfos ficam "sujos" depois de usados e um filósofo só entrega um garfo limpo a quem pede. Ela evita deadlock e starvation sem coordenador central e funciona para qualquer número de filósofos, mas é bem mais complexa de implementar.

Para refletir