死锁的四个必要条件,缺一不可
死锁不是“偶尔卡住”,而是满足四个条件后必然发生的系统僵局:
互斥条件、
持有并等待、
不可剥夺、
循环等待。只要其中任意一个被打破,死锁就不可能发生。
互斥条件:比如
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,而是多个模块协作时没人统一管锁顺序,或者把数据库事务和内存锁混在同一逻辑里——这种耦合一旦形成,单点修复几乎无效。
