1. C# 上的 Deadlock 示例
Deadlock(死锁)是指一组线程中,每个线程占有一个资源并等待另一个被同组中其他线程占有的资源释放 —— 结果没人等到任何东西。
我们通过实践来理解。假设有两个锁对象和两个线程。每个线程先锁住一个对象,然后尝试获取第二个。
using System;
using System.Threading;
class Program
{
static readonly object lockerA = new object();
static readonly object lockerB = new object();
static void Main()
{
Thread thread1 = new Thread(Thread1Work);
Thread thread2 = new Thread(Thread2Work);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
Console.WriteLine("两个线程都完成了工作(如果没有发生 Deadlock)");
}
static void Thread1Work()
{
lock (lockerA)
{
Console.WriteLine("线程 1:已获取 lockerA");
Thread.Sleep(100); // 给第二个线程机会去获取 lockerB
lock (lockerB)
{
Console.WriteLine("线程 1:已获取 lockerB");
}
}
}
static void Thread2Work()
{
lock (lockerB)
{
Console.WriteLine("线程 2:已获取 lockerB");
Thread.Sleep(100); // 给第一个线程机会去获取 lockerA
lock (lockerA)
{
Console.WriteLine("线程 2:已获取 lockerA");
}
}
}
}
多次运行这段代码 —— 你几乎肯定会遇到程序“挂起”。Deadlock 发生了!每个线程都持有自己的锁并等待对方释放第二个锁。
Deadlock 的可视化
下面是示意图:
sequenceDiagram
participant 线程1
participant 线程2
participant lockerA
participant lockerB
线程1->>lockerA: 获取 lockerA
线程2->>lockerB: 获取 lockerB
线程1->>lockerB: 等待 lockerB 释放
线程2->>lockerA: 等待 lockerA 释放
结果是:线程 1 持有 lockerA 并等待 lockerB,线程 2 持有 lockerB 并等待 lockerA。没人让步 —— 程序就挂了。
2. 如何发现 Deadlock?
为什么会发生?
要理解原因,可以看几个要素:
- 多个锁同时持有:当线程同时获取多个锁。
- 不一致的锁顺序:如果线程以不同顺序获取锁,就可能导致 deadlock。
- 可能的等待:线程在等待被其他线程占用的资源释放。
- 无法强制抢占:在 C# 中一个线程不能“抢走”另一个线程的锁 —— 只能等待。
经典陷阱:如果有 N 个线程和 N 个资源,并且线程以不同顺序锁定资源,你就很接近 deadlock 了。
Deadlock 的典型迹象
从外面看,Deadlock 通常表现为程序“挂起”。线程存活,但都在等待资源访问,互相无限期等待。
典型迹象:
- 程序突然不再响应(有时只在特定负载下出现)。
- 调试器显示线程卡在 lock、Monitor.Enter、WaitOne、EnterReadLock 或类似同步操作上。
- 系统工具(比如 Task Manager 或 Rider/Visual Studio)显示 CPU 使用率为 0% —— “一切都停住了”。
- 日志里没有错误,但也没有任何活动。
如果你用 JetBrains Rider 或 Visual Studio,用调试器看线程在哪儿卡住(Stack Trace)。如果看到多个线程在相互等待 lock/Mutex.WaitOne —— 恭喜(或者表示同情):这是 deadlock!
常见的 Deadlock 场景
不一致的锁顺序。 就像我们上面的例子:线程 1 先锁 lockerA,再锁 lockerB;线程 2 则相反。陷阱就做好了。
嵌套锁。 当在一个 lock 块里面又对另一个对象做 lock。
方法间交叉的资源。 特别难追踪的是锁散布在不同方法/类里并以不同组合存在。资源超过两个时情形会更复杂。
3. 防止 Deadlock
好消息: 可以防止 deadlock!
坏消息: 需要在多线程设计时更小心。
下面是一些实用建议(记住它们 —— 面试时你会加分!):
总是以相同顺序获取锁
如果有多个对象可能会被一起锁住 —— 定义一个全局顺序(比如按名字或索引)并始终遵循它。
示例 — 正确的锁获取顺序
static void SafeLock(object objA, object objB)
{
// 比较哈希码 —— 保证线程总以相同顺序锁对象
object first = objA.GetHashCode() < objB.GetHashCode() ? objA : objB;
object second = objA.GetHashCode() < objB.GetHashCode() ? objB : objA;
lock (first)
{
lock (second)
{
// 临界区
}
}
}
这里 —— 如果 objA “小于” objB,两个线程都会先进入 lock(objA),然后进入 lock(objB)。
使用超时
如果线程在合理时间内无法获取锁 —— 让它抛出异常或优雅退出。可以用 Monitor.TryEnter。
带 Monitor.TryEnter 的示例
bool lockTakenA = false;
bool lockTakenB = false;
try
{
Monitor.TryEnter(lockerA, 500, ref lockTakenA);
if (!lockTakenA)
{
// 在 500 ms 内未能获取锁 —— 放弃以避免相互死锁
return;
}
Monitor.TryEnter(lockerB, 500, ref lockTakenB);
if (!lockTakenB)
{
return;
}
// 临界区...
}
finally
{
if (lockTakenB)
Monitor.Exit(lockerB);
if (lockTakenA)
Monitor.Exit(lockerA);
}
如果获取锁失败 —— 不要挂起!
尽量最小化锁持有时间
在临界区内只保留最少必要的代码。
不要混用不同的同步机制
如果你同时使用 lock、Mutex、Semaphore、ReaderWriterLockSlim —— 容易弄混造成相互死锁的风险增加。
4. 关于 Mutex、Semaphore 和 ReaderWriterLockSlim 的 Deadlock
相互死锁不仅会出现在经典的 lock 或 Monitor 上,也会出现在其他同步机制上。
与 Mutex 的 Deadlock
static Mutex mutexA = new Mutex();
static Mutex mutexB = new Mutex();
void Work1()
{
mutexA.WaitOne();
Thread.Sleep(100);
mutexB.WaitOne();
// 临界区
mutexB.ReleaseMutex();
mutexA.ReleaseMutex();
}
如果第二个线程以相反顺序获取它们 —— 就会发生 deadlock。
与 ReaderWriterLockSlim 的 Deadlock
尽管 ReaderWriterLockSlim 很灵活,但如果线程已经持有一种锁再尝试获取另一种锁,它并不能保护你免于相互死锁。
例如,如果线程持有读锁(EnterReadLock)然后尝试进入写锁(EnterWriteLock)—— deadlock 很可能发生,因为写锁在所有读锁释放之前无法被获取。
5. 在真实应用中如何避免 Deadlock
来看一个在演示应用里的场景。假设我们做一个电商模拟器,同时运行着:
- 更新库存的线程(写者)
- 检查商品是否有货的线程(读者)
- 管理员(特殊线程),既读又写
不好的做法:
lock (stockLock)
{
// 更新库存
lock (userLock)
{
// 对用户做点什么
}
}
// ... 在别处却相反!
lock (userLock)
{
lock (stockLock) { }
}
好的做法:
达成约定 —— 所有线程都先尝试锁定 stockLock,然后再锁定 userLock。
6. 在工作和面试中如何避免 Deadlock
在真实项目中:
- 设计时画出线程和锁的关系图(可以在纸上或白板上)。
- 与团队共享锁顺序约定。
- 使用静态分析工具(Rider/Visual Studio, ReSharper)—— 它们能发现潜在的 deadlock。
- 阅读 Microsoft Docs 上关于 Deadlock 的文档。
在面试中:
- 展示你理解问题及其经典症状。
- 提到主要的防护方法:统一顺序、超时(TryEnter)、最小化临界区。
- 给出带 TryEnter 的例子并解释为什么在 finally 释放所有已获取的资源很重要。
7. 常见错误和意外的 Deadlock
错误 #1:第三方库里不明显的锁。
经典场景 —— 在临界区里做日志记录。线程卡住,但你可能根本想不到是外部代码惹的祸。
错误 #2:对同一资源的重复锁定。
这是可重入死锁(reentrant deadlock):线程尝试进入已被自己锁定的锁,但机制不支持重入。
错误 #3:在临界区里使用异步方法。
尤其危险的是用 await:线程可能在最不合适的时候“放弃”执行上下文,从而把系统弄得僵住。
GO TO FULL VERSION