本文将介绍如何理解死锁,死锁的常见类型,如何解决死锁,如何调试死锁,以及避免死锁的最佳实践。
C# 中的死锁是指两个或多个线程在执行过程中被冻结,因为它们正在等待彼此完成。例如,线程 A 正在等待由线程 B 持有的 locker2,线程 B 不能完成并释放 locker2,因为它正在等待由线程 A 持有的 locker1。例如死锁案例 – 嵌套锁:
internal class Program
{
static void Main(string[] args)
{
Object locker1 = new Object();
Object locker2 = new Object();
var tasks = new List<Task>();
var task1 = Task.Run(() =>
{
lock (locker1)
{
Thread.Sleep(1000);
lock (locker2)
{
Console.WriteLine("Task 1 Finished.");
}
}
});
tasks.Add(task1);
var task2 = Task.Run(() =>
{
lock (locker2)
{
Thread.Sleep(1000);
lock (locker1)
{
Console.WriteLine("Task 2 Finished.");
}
}
});
tasks.Add(task2);
Task.WaitAll(tasks.ToArray());
Console.WriteLine("All Taskes Finished.");
}
}
task1 获取 locker1 休眠 1 秒,然后等待 locker2 被释放。task2 获取 locker2 后休眠 1 秒,然后等待 locker1 被释放。它们都无限期等待,并导致死锁。Task.WaitAll 等待 task1 和 task2 两个任务都完成,这也导致死锁,因为 task1 和 task2 永远无法完成。
上面这种死锁场景,只要稍稍努力,就很容易发现。然而,在更复杂的场景下,死锁是很难一下子被发现。在了解更复杂的场景前,我们先来看一下如何调试死锁。
在 Visual Studio 运行上面代码将导致挂起。点击 Debug -> Break All,然后点击 Debug -> Windows -> Threads,双击 Threads 里的对应的项,你可以看到如下内容,
WaitAll 卡死:
lock(locker2) 卡死:
lock(locker1) 卡死:
这就是死锁在调试中的样子。正如你所看到的,主线程被卡在 Task.WaitAll 上。其它两个线程被卡在内部 lock 语句上。
你使用过“Tasks 窗口”?点击 Debug->Windows->Tasks 可以调出“Tasks 窗口”。它显示当前正在运行的所有任务。包括它们的状态、开始时间、持续时间、代码中的位置等等。它最酷的一点就是能自动检测死锁。下面是调试死锁的效果:
lock(loker2) 卡死:
lock(locker1) 卡死:
使用“任务窗口”的注意事项是,它只能显示 Task。这就是为什么你在这里看不到 UI 线程,用 Thread 和 ThreadPool 类创建的线程也是看不到的。为了感受它的全部威力,你可以看这个更复杂的多线程场景:Visual Studio .NET Debugging - Parallel Stacks and Tasks。
解决嵌套死锁问题,最简单的方式是:不要在锁中使用锁。但这并不总是有效的。例如,假设每个锁代表一个 Account。我们希望对账户上的每个操作使用锁定。当我们对这两个账户执行操作时(比如转账),我们希望同时锁定这两个账户。
如果我们按照相同的顺序嵌套锁,就不会出现死锁问题。模拟转账账户锁定问题:
public class Account
{
public Int32 Id { get; set; }
}
internal class Program
{
static void Main(string[] args)
{
var acc1 = new Account() { Id = 1};
var acc2 = new Account() { Id = 2 };
var tasks = new List<Task>();
var task1 = Transfer(acc1, acc2, 500);
tasks.Add(task1);
var task2 = Transfer(acc2, acc1, 1000);
tasks.Add(task2);
Task.WaitAll(tasks.ToArray());
Console.WriteLine("All Taskes Finished.");
}
private static Task Transfer(Account acc1, Account acc2, Int32 amount)
{
var locker1 = acc1.Id < acc2.Id ? acc1 : acc2;
var locker2 = acc1.Id < acc2.Id ? acc2 : acc1;
return Task.Run(() =>
{
lock(locker1)
{
Thread.Sleep(1000);
lock(locker2)
{
Console.WriteLine($"Finished transferring amount: {amount}");
}
}
});
}
}
由于所有转账的外部锁都是相同的,所以不存在死锁。其中一个线程将在外部锁中等待,直到第一个线程完成,然后继续。
解决这个问题的另一种方法时在等待释放锁时,使用超时机制。如果在一段时间内锁没有被释放,则取消操作。它可以被再次放入队列或类似的地方,收稍后再执行。模拟转账方法更改如下:
private static Task Transfer(Account acc1, Account acc2, Int32 amount)
{
return Task.Run(() =>
{
while (true)
{
try
{
if (Monitor.TryEnter(acc1, TimeSpan.FromMilliseconds(100)))
{
if (Monitor.TryEnter(acc2,TimeSpan.FromMilliseconds(100)))
{
Console.WriteLine($"Finished transferring amount: {amount}");
return;
}
}
}
finally
{
var
if (Monitor.IsEntered(acc1))
{
Monitor.Exit(acc1);
}
if (Monitor.IsEntered(acc2))
{
Monitor.Exit(acc2);
}
}
}
});
}
记住,lock 语句实际上是 Monitor.Enter 和 Monitor.Exit 的语法糖。使用 Monitor.TryEnter 可以将 Timeout 作为参数传递。这意味着,如果锁定未能在超时内获取,则返回 False。
从理论上讲,使用这种方法可能总是无法执行某个操作–当两个线程同时获取外部锁时,则无法获取内部锁。但实际上,这几乎时不可能的。线程切换机制将在每次不同的时间进行。
值得一提的是,现代应用程序中可以通过 Event Sourcing 模式来完全避免死锁。
上面的死锁问题,只要稍稍努力,很容易识别。但有些场景下,一下子识别死锁还是比较困难的。事实上,在 C# 中要识别死锁,你应该查找如下地方:
当你看到调试器的执行点卡在上面任何一个点时,很有可能出现死锁。