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.
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
- Deadlock (impasse) — todos os filósofos pegam o garfo da esquerda ao mesmo tempo. Agora cada um espera o garfo da direita, que está na mão do vizinho, que também está esperando. Ninguém solta nada, ninguém come, o programa trava para sempre.
- Starvation (inanição) — o sistema não trava, mas um filósofo azarado nunca consegue os dois garfos, porque os vizinhos sempre pegam antes dele.
- Livelock — os filósofos percebem o impasse e soltam o garfo para tentar de novo... todos ao mesmo tempo, repetidamente. Estão "ocupados" o tempo todo, mas ninguém come. É como duas pessoas tentando passar no corredor e desviando para o mesmo lado várias vezes.
- Pouca concorrência — uma "solução" que funciona, mas deixa só um filósofo comer por vez, embora a mesa permita até dois (5 garfos, 2 por filósofo).
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:
- Exclusão mútua — um garfo só pode estar com um filósofo por vez.
- Posse e espera — o filósofo segura um garfo enquanto espera o outro.
- Sem preempção — ninguém pode tomar o garfo da mão de outro filósofo.
- 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");
}
- Quebra: posse e espera — ninguém fica segurando um garfo enquanto espera o outro.
- Vantagem: impossível ter deadlock; código trivial.
- Problema: só um filósofo come por vez, mesmo quando F0 e F2 (que não compartilham garfo nenhum) poderiam comer juntos. Compare o tempo total com as próximas soluções.
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");
}
- Quebra: espera circular. F0 e F4 disputam primeiro o garfo 0; um deles nem chega a pegar garfo nenhum, então o "círculo" de esperas nunca se fecha.
- Vantagem: simples, sem estruturas extras, e até dois filósofos comem juntos. É a técnica mais usada na prática: em uma transferência entre duas contas, bloqueie sempre primeiro a conta de menor id.
- Problema: exige que todo o código do sistema respeite a mesma ordem. Basta um trecho esquecido pegando na ordem inversa para o deadlock voltar.
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");
}
- Quebra: espera circular — para o círculo fechar, os 5 precisariam estar segurando um garfo, e o garçom nunca deixa os 5 entrarem.
- Vantagem: os filósofos continuam pegando "esquerda e depois direita", sem regra de ordem.
- Problema: o garçom é um ponto central que todos precisam consultar. Teste mudar para
new SemaphoreSlim(N): o deadlock volta.
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");
}
- Quebra: posse e espera — ninguém espera para sempre segurando um garfo.
- Vantagem: não depende de ordem nem de coordenador central.
- Problema: risco de livelock e de starvation. A espera aleatória é o que
"desencontra" os filósofos. Troque
Random.Shared.Next(10, 200)por um valor fixo, como100, e observe quantas vezes eles desistem.
Comparando as soluções
| Solução | Condição quebrada | Comem juntos | Ponto 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
- Rode as soluções 1, 2 e 3 e compare o tempo total. A Solução 2 costuma ser a mais rápida, porque dois filósofos comem juntos. Já a Solução 3, que em teoria também permite dois comendo juntos, costuma empatar com a Solução 1, que só deixa um. Por quê? (Dica: com 4 filósofos à mesa, cada um segurando o garfo da esquerda, quantos conseguem de fato pegar o da direita?)
- Na Solução 2, a ordem dos garfos impede o deadlock. Ela também garante que nenhum filósofo passe fome (starvation)? Por quê?
- Em um sistema bancário, que "garfos" uma transferência entre duas contas precisa pegar? Qual das soluções você usaria?
- O
SemaphoreSlimda Solução 3 e olockdos garfos estão fazendo papéis diferentes. Relacione com a regra da aula de Concorrência: capacidade x consistência.