CodeGym /课程 /C# SELF /其他问题: Livelock 和 Starvation

其他问题: Livelock 和 Starvation

C# SELF
第 57 级, 课程 3
可用

1. 介绍

Livelock —— 在多线程程序里,两个或更多线程试图避免互相死锁(Deadlock),结果却一直互相响应对方的动作,永远不往前走。 跟 Deadlock 不同,Deadlock 那种线程僵住啥也干不成;Livelock 看起来程序还在“动着”:线程在跑,但没有任何有意义的工作在完成。

就像你和同事在窄走廊里一直往后退让对方先走,结果谁也没过去。外面看着好笑,但对程序来说很糟糕。

Livelock 示例:门口的程序员

想象两个非常礼貌的程序员在窄走廊相遇。他们同时到门口,都决定让对方先走:往旁边退一下。两个人又发现情况没变,就再次退让——无限循环。没人停下,大家都在动,但没人通过门。

在多线程程序里就是:线程看到资源被占了,就退一步,再检查,再退一步,如此循环。

2. Livelock 在实践中

我们来把这种情况在代码里表现出来。假设有两个资源(比如两个银行账户),两个线程同时想互相转账,并且都试图不“死锁”。

class Account
{
    public int Balance { get; set; }
    public object LockObj { get; } = new object();
}

public class Program
{
    static void Transfer(Account from, Account to, int amount)
    {
        while (true)
        {
            bool lockedFrom = false;
            bool lockedTo = false;
            try
            {
                Monitor.TryEnter(from.LockObj, 100, ref lockedFrom); 
                Monitor.TryEnter(to.LockObj, 100, ref lockedTo);

                if (lockedFrom && lockedTo)
                {
                    from.Balance -= amount;
                    to.Balance += amount;
                    break; // 转账完成!
                }
            }
            finally
            {
                if (lockedFrom) Monitor.Exit(from.LockObj);
                if (lockedTo) Monitor.Exit(to.LockObj);
            }
            // 没成功?退一步再试——很礼貌!
            Thread.Sleep(1);
        }
    }
    // ... 启动两个线程,分别调用 Transfer(A, B, ...); Transfer(B, A, ...);
}

在这个例子里,两个线程都会反复尝试获取锁。如果两边总是看到锁被占,就一直退让,最终就是“礼貌地”互相让步,却没任何转账发生。靠调度器的随机性来解决不是稳妥的办法。要加随机暂停和 backoff,别把短小的空循环套在 Monitor.TryEnter 和 Thread.Sleep 上。

对比 Livelock 和 Deadlock

Deadlock Livelock
线程 停止等待 积极运行但在等待
资源 被永久占有 明显没有被永久占有
CPU 几乎不被占用 可能被占满到 100%
解决办法 超时、锁顺序 随机性、暂停、backoff

3. Starvation(饥饿):当队列到不了资源

Starvation(字面就是“饥饿”)—— 指一个或多个线程总被忽视,拿不到需要的资源,因为其他线程一直抢在前面。

和 Deadlock、Livelock 不同,这里没人被永久阻塞,也没人无限退让——只是有些线程一直拿不到“那一块蛋糕”。

现实类比

想象食堂有两条队:一条是“VIP”(比如员工通道),一条是“普通”。VIP 队短,但总有人先到。普通的人排半小时,看着 VIP 一次次被优先服务——这就是 Starvation。

4. Starvation 在 C#:实践和调试

看一个和 lock 有关的场景:一个优先级很高的线程一直能先进入临界区,另一个线程总是“落后”:

private static readonly object _locker = new object();

static void Greedy()
{
    while (true)
    {
        lock (_locker)
        {
            Console.WriteLine("贪婪线程获取了资源...");
            Thread.Sleep(10); // 持锁更久
        }
        Thread.Sleep(1);
    }
}

static void Poor()
{
    while (true)
    {
        lock (_locker)
        {
            Console.WriteLine("可怜的线程尝试进入...");
        }
    }
}

如果同时跑这两个线程,Greedy(“贪婪”)会长时间持有锁并且很快再次拿到它,而 Poor(“可怜”)几乎进不去,实际上就“饿着”资源。在真实场景里,starvation 可能不那么明显:比如某线程优先级低,或者经常被操作系统抢占。

starvation 常出现在哪儿?

  • 低优先级线程。
  • 任务队列组织不当(没有公平的 FIFO 策略)。
  • ReaderWriterLockSlim 的默认配置:如果写者很少到来而读者不断进来,写者会一直饿着,因为读者占着 Read Lock,不让人拿到 Write Lock。

5. 为什么 Starvation 不总是 bug,但总是个问题

Starvation 不像 Deadlock 那样把程序搞瘫痪,但会导致不公平和不可预测的结果:数据处理不均匀,有些任务等不到完成,整体性能下降。这在服务器端特别致命——每个请求都应该有公平的机会被处理。

如何发现并诊断 Livelock 和 Starvation

  • CPU 占用猛涨,但任务没推进——可能是 Livelock。
  • 线程好像在跑,但有些任务从不完成——可能是 Starvation。
  • 日志:如果日志显示某些线程或任务从没拿到资源——那就是饿死了。

如何避免 Livelock

  • 别只靠像 Monitor.TryEnter 这类“好说话”的 non-blocking 尝试。失败后加上随机暂停(Thread.Sleep(Random.Next(1, 10)))),避免线程时间点完全对齐。
  • 不要用永远短小的循环去抢锁——否则“退一步再试”的循环会变成无尽的忙等。
  • 有时候换个算法更简单:引入“长者优先”规则,让只有某个线程能让步,另一个不能一直退让。

如何避免 Starvation

  • 注意线程优先级:后台作业或高优先级任务不应永远压制其他任务。
  • 使用公平的队列(ConcurrentQueue<T>、channel、任务队列),保证 FIFO 服务顺序。
  • 在 ReaderWriterLockSlim 中可以阻止新读者以便给写者机会,确保写者能拿到 Write Lock。
  • 用超时和日志:如果某线程拿锁太久没成功,就打个警告。
  • 缩短锁持有时间,把临界区拆小。

Deadlock、Livelock 和 Starvation 对比表

场景 Deadlock Livelock Starvation
线程状态 死死地卡住 跑来跑去但没用 有的在工作,有的没法工作
资源访问 没有 没有 不均衡,有时没有
CPU 不被占用 被大量占用 被部分占用
是否致命错误? 是 是 有时是
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION