c# 如何用c#实现哲学家进餐问题 Dining Philosophers Problem

用Monitor实现哲学家进餐问题的核心是打破循环等待:第4位哲学家反向取叉(先右后左),配合try/finally确保双锁安全释放、CancellationToken控制退出、Thread.Sleep模拟行为,并避免lock无法嵌套加锁的缺陷。

Monitor 实现最简可运行的哲学家进餐

核心是避免死锁:五个哲学家不能同时拿起左边叉子。最稳妥的做法是让其中一个哲学家「反向拿叉」——先右后左,打破循环等待条件。

关键点:Monitor.EnterMonitor.Exit 必须成对出现,且必须用 try/finally 保证释放;所有叉子对象要预先创建并共享;哲学家线程需有明确退出机制(如 CancellationToken)。

  • 每个叉子用一个 object 实例表示,放在数组里:private static readonly object[] forks = Enumerable.Range(0, 5).Select(_ => new object()).ToArray();
  • i 位哲学家默认先拿 forks[i](左),再拿 forks[(i + 1) % 5](右)
  • 但第 4 位(索引 4)改为先拿 forks[(i + 1) % 5](右),再拿 forks[i](左),打破对称性
  • Thread.Sleep 模拟思考/进食时间,否则会跑得太快看不出竞争效果
using System;
using System.Threading;

class Program
{
    private static readonly object[] forks = Enumerable.Range(0, 5).Select(_ => new object()).ToArray();
    private static readonly CancellationTokenSource cts = new();

    static void Main()
    {
        var philosophers = Enumerable.Range(0, 5)
            .Select(i => new Thread(() => Philosopher(i)))
            .ToArray();

        foreach (var t in philosophers) t.Start();
        Thread.Sleep(5000);
        cts.Cancel();
        foreach (var t in philosophers) t.Join();
    }

    static void Philosopher(int id)
    {
        while (!cts.Token.IsCancellationRequested)
        {
            Console.WriteLine($"Philosopher {id} is thinking...");
            Thread.Sleep(100);

            // 左右叉子索引
            int left = id;
            int right = (id + 1) % 5;

            // 最后一位哲学家反向取叉,避免死锁
            if (id == 4)
            {
                Monitor.Enter(forks[right]);
                Monitor.Enter(forks[left]);
            }
            else
            {
                Monitor.Enter(forks[left]);
                Monitor.Enter(forks[right]);
            }

            try
            {
                Console.WriteLine($"Philosopher {id} is eating...");
                Thread.Sleep(200);
            }
            finally
            {
                Monitor.Exit(forks[left]);
                Monitor.Exit(forks[right]);
            }
        }
    }
}

为什么不用 lock 语句而用 Monitor

lock(obj) 底层就是 Monitor.Enter/Exit,但它只支持单个对象加锁。哲学家要同时持有两个叉子,必须显式控制两把锁的获取顺序和异常安全释放 —— lock 无法嵌套锁定两个不同对象而不留隐患。

  • 如果写成 lock(forks[left]) { lock(forks[right]) { ... } },在第二个 lock 失败时,第一个锁不会自动释放
  • Monitor.TryEnter 可设超时,适合做“尝试拿叉失败就放弃”策略(避免饥饿),而 lock 会一直阻塞
  • 真实系统中,你可能需要检查 Monitor.TryEnter(fork, timeout) 返回值,失败就 Thread.Sleep 后重试

Wait / Pulse 版本:更贴近原始问题语义

原始哲学家问题强调「只有左右叉都可用时才开始吃」,而不是强行抢锁。这时该用 Monitor.Wait 让线程等待条件成立,用 Monitor.PulseAll 唤醒所有等待者。

你需要为每把叉子维护一个「是否空闲」状态,并用一个全局锁保护状态检查。哲学家进入「想吃」状态后,轮询检查两把叉子是否都空闲;若不满足,Monitor.Wait 挂起自己;一旦某人吃完放下叉子,就 Monitor.PulseAll 唤醒所有人重新判断。

  • 状态变量必须是 bool[] 或类似结构,且读写必须被同一把锁保护
  • 每次 Wait 前必须在 while 循环里检查条件,防止虚假唤醒
  • PulseAll 开销比 Pulse 大,但这里无法预知谁该被唤醒,只能全唤

容易被忽略的三个实际坑

很多示例跑起来看似正常,但一加压或换环境就出问题。真正上线要考虑这些:

  • 没有设置线程名称或 ID 日志,导致并发行为无法追踪 —— 建议在 Console.WriteLine 中带上 Thread.CurrentThread.ManagedThreadId
  • 未处理 ThreadAbortException 或中断,.NET 6+ 中 Thread.Abort 已废弃,必须依赖 CancellationToken 配合 Monitor.TryEnter 超时退出
  • 所有 fork 对象都是静态的,但如果程序是 long-running service,要注意生命周期管理 —— 叉子对象本身无状态,但若未来扩展为带计数器或超时逻辑,就得考虑 Dispose 模式

哲学家问题不是为了造轮子,而是训练对锁顺序、条件竞争、唤醒丢失的直觉。代码越短,越要盯住那几行 Enter/Exit 的配对和位置。