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 | 不被占用 | 被大量占用 | 被部分占用 |
| 是否致命错误? | 是 | 是 | 有时是 |
GO TO FULL VERSION