死锁源于线程按不同顺序获取同一组锁,典型场景包括跨方法持锁、异步回调重入、事务与业务锁混用;应避免锁内调用外部方法或await,强制锁获取顺序,优先使用Monitor.TryEnter()设超时,并配对Exit()。

死锁发生的典型代码模式
死锁不是随机出现的,基本都源于两个线程按不同顺序获取同一组锁。比如线程 A 先锁 lockA 再等 lockB,而线程 B 先锁 lockB 再等 lockA——只要时机凑巧,双方就卡住不动。
常见触发场景包括:跨方法调用时隐式持锁、异步回调里重新进入加锁逻辑、数据库事务 + 业务锁混合使用。
- 别在持有锁期间调用外部方法(尤其是可能再加锁的),例如
logger.Log()或HttpClient.SendAsync() - 避免在
lock块里 await 异步操作——这会释放线程但不释放锁,后续恢复时可能重入或阻塞 - 如果必须多把锁,强制约定获取顺序,比如按对象哈希码排序:
var locks = new[] { lockA, lockB }.OrderBy(x => RuntimeHelpers.GetHashCode(x)).ToArray();
用 Monitor.TryEnter() 主动规避无限等待
Monitor.Enter() 是阻塞式的,一旦拿不到锁就一直挂起;而 Monitor.TryEnter() 可设超时,失败后能及时退出或降级处理,是防死锁最直接的手段。
它不改变锁语义,只是把“等不到就卡死”变成“等不到就放弃”,适合对一致性要求不绝对严格的路径(如缓存更新、日志聚合)。
- 超时值别设太短(比如
1毫秒),否则容易误判;也别设太长(比如TimeSpan.MaxValue),等于变相回到Enter - 返回
false后不要重试原逻辑,应走备选分支,例如返回旧缓存值、记录告警、或抛出InvalidOperationException - 务必配对调用
Monitor.Exit(),哪怕只在try块里获得锁——using不适用于Monitor
bool acquired = false;
try
{
acquired = Monitor.TryEnter(lockObj, TimeSpan.FromMilliseconds(50));
if (!acquired) throw new TimeoutException("Failed to acquire lock in time");
// ... critical section
}
finally
{
if (acquired) Monitor.Exit(lockObj);
}
排查时优先看线程堆栈里的 Monitor.Enter 和 WaitHandle.WaitOne
死锁发生后,进程不会崩溃,但 CPU 占用低、响应停滞。用 Visual Studio 附加到进程,或用 dotnet-dump analyze 抓快照,重点搜 Monitor.Enter、WaitOne、Task.Wait 这几处阻塞点。
真正难的是区分“真死锁”和“单纯慢”:前者所有相关线程都停在锁等待上;后者可能只是某线程在做耗时 I/O 或计算。
- 在 VS 的“调试 → 窗口 → 并行堆栈”里,找多个线程同时卡在
Monitor.Enter且锁对象地址相同(比如都是0x000002...abc) - 注意
async/await后续执行可能切换线程,导致堆栈里看不到原始lock,但能看到TaskAwaiter.UnsafeOnCompleted或ExecutionContext.Run - 启用
DOTNET_DiagnosticsEventPipeProviders=Microsoft-DotNETCore-EventPipe::0x8000000000000000:4环境变量可导出更细粒度的锁竞争事件
别迷信 async 能自动解死锁
很多人以为把同步方法改成 async 就安全了,其实只要底层还在用 lock 或 Monitor,死锁风险一点没少。async 只是释放线程,不释放锁。
真正的异步替代方案是无锁结构(如 ConcurrentDictionary)、不可变数据 + 函数式更新,或者用 SemaphoreSlim.WaitAsync() 替代 lock——但后者仍需注意顺序问题。
-
lock无法 await,强行套await Task.Run(() => { lock(...) {...} })只是把死锁搬到后台线程,问题照旧 -
ConcurrentQueue和ConcurrentDictionary内部用分段锁+无锁算法,适合高并发读写,但不支持复杂原子操作(如“查+删+返回”三步合并) - 若必须自定义同步逻辑,优先考虑
SemaphoreSlim(支持取消和超时),而非手写Monitor,尤其在 ASP.NET Core 这类线程受限环境里
实际遇到死锁,最耗时间的往往不是修复,而是确认它是不是真的死锁——因为现象相似的还有长时间 GC、磁盘 I/O 阻塞、或数据库连接池耗尽。先看堆栈是否真卡在锁上,再动手改代码。











