死锁的四个必要条件是互斥条件、持有并等待、不可剥夺、循环等待,缺一不可;打破任一条件即可防止死锁,其中按固定顺序加锁是最有效预防手段。

死锁的四个必要条件,缺一不可
死锁不是“偶尔卡住”,而是满足四个条件后必然发生的系统僵局:互斥条件、持有并等待、不可剥夺、循环等待。只要其中任意一个被打破,死锁就不可能发生。
-
互斥条件:比如lock语句块、Mutex、Monitor等,资源一次只能被一个线程进入 -
持有并等待:线程已拿到lockA,又去申请lockB,但lockB正被别人拿着 -
不可剥夺:C# 中没有强制释放锁的机制(不像数据库可 kill session),lock只能靠线程自己退出作用域或抛异常后释放 -
循环等待:线程1等线程2的lockB,线程2又等线程1的lockA—— 这是最常见、最易复现的死锁形态
用 Monitor.TryEnter 加超时,快速止损
这不是根治,但能防止线程无限挂起,让问题暴露得更早、更可控。相比裸 lock,它把“等不到就卡死”变成“等不到就放弃+报错”。
- 超时时间别设太短(如
TimeSpan.FromMilliseconds(10)),否则高负载下容易误判;也别设太长(如TimeSpan.FromMinutes(5)),失去意义 - 推荐从
TimeSpan.FromSeconds(2)起步,在日志中记录失败次数和堆栈,用于定位热点锁 - 注意:
TryEnter成功后必须配对调用Monitor.Exit,否则会泄漏锁(lock语法糖自动处理这点,TryEnter不自动)
private static readonly object _sharedLock = new object();
public static bool TryUpdateData()
{
if (Monitor.TryEnter(_sharedLock, TimeSpan.FromSeconds(2)))
{
try
{
// 执行临界区操作
return true;
}
finally
{
Monitor.Exit(_sharedLock); // 必须显式释放!
}
}
else
{
// 记录日志:在 2 秒内无法获取 _sharedLock,可能竞争激烈或已死锁
Log.Warn("Failed to acquire lock within timeout");
return false;
}
}按固定顺序加锁,从源头掐断循环等待
这是最有效、成本最低的预防手段。只要所有线程都按同一顺序请求锁(比如总是先 lockA 再 lockB),就不可能形成 A→B→A 的环。
- 给锁对象命名要有含义和顺序感,例如
_lockForOrder、_lockForInventory,再按业务语义排序(订单优先于库存) - 避免在不同方法里“凭感觉”加锁:方法 A 里先锁 B 再锁 A,方法 B 里先锁 A 再锁 B → 死锁高发区
- 如果必须跨多个资源加锁,建议封装成单一协调锁(如
_globalResourceLock),或用ReaderWriterLockSlim替代多粒度lock
排查死锁:从 C# 代码到 SQL,分层抓证据
死锁不只发生在 C# 层,常是“C# 线程 + 数据库事务”联合触发。要分层看:
- C# 层:启用
ThreadPool.GetAvailableThreads和Thread.CurrentThread.ManagedThreadId日志,观察线程是否长期卡在某个lock或WaitOne调用上 - SQL 层:捕获错误号
1205(死锁受害者),配合sys.dm_exec_requests和sys.dm_tran_locks查谁在等什么资源 -
工具辅助:SQL Server Profiler 或 XEvent 捕获
deadlock graph,图中箭头方向直接标出“谁在等谁” - 关键提示:不要只看异常日志里的“死锁”,要看前后 5 秒内的所有 SQL 执行顺序和锁模式(U、X、S),往往问题出在看似无关的 UPDATE 前置语句上
C# 死锁真正难的不是写对 lock,而是多个模块协作时没人统一管锁顺序,或者把数据库事务和内存锁混在同一逻辑里——这种耦合一旦形成,单点修复几乎无效。










