死锁需确认而非仅凭卡顿判断,dotnet-trace通过捕获WaitHandleWait事件可早于线程堆栈发现阻塞,该事件由Monitor.Enter等触发,含WaitMilliseconds和HandleAddress,能精准定位等待时长与锁持有者关联。

死锁不是“卡住”就等于它,得先确认是不是真死锁——用 dotnet-trace 抓 WaitHandleWait 事件比看线程堆栈更早、更准。
dotnet-trace 怎么抓到线程在等锁
WaitHandleWait 是 .NET 运行时发出的底层事件,只要线程调用了 Monitor.Enter、Mutex.WaitOne、AutoResetEvent.WaitOne 等同步等待,就会触发该事件。它不依赖你有没有源码或符号,也不需要进程暂停,是生产环境里最早暴露阻塞行为的信号。
- 安装后运行
dotnet-trace ps找目标进程 PID - 执行
dotnet trace collect --process-id <pid> --providers "Microsoft-Windows-DotNETRuntime-ThreadPool:4:4",这个 provider 包含 WaitHandleWait - 默认采集 60 秒,期间复现问题;结束后生成
trace.nettrace - 用
dotnet trace convert trace.nettrace转成 JSON,搜索"EventName": "WaitHandleWait",看哪些线程反复触发、等待时长是否持续增长
WaitHandleWait 日志里藏着什么关键信息
每个 WaitHandleWait 事件带 WaitMilliseconds 和 HandleAddress,前者告诉你等了多久(>1000ms 就值得怀疑),后者能和 dump 中的锁对象地址对上——这是把“谁在等”和“谁在持”串起来的唯一链路。
-
WaitMilliseconds接近超时值(比如 5000)且重复出现 → 很可能Monitor.TryEnter失败后没走降级逻辑,而是在重试 - 多个线程的
HandleAddress完全一致 → 锁竞争热点,再结合clrstack -a查哪个线程正持有该地址的 SyncBlock - 如果
WaitMilliseconds恒为 0 或极小值(如 1~10),大概率是自旋锁(SpinLock.Enter)或短时争用,不算死锁但可能影响吞吐
为什么不能只靠 dotnet-dump 分析死锁
dotnet-dump 只能拍快照,它告诉你“此刻线程停在哪”,但没法回答“它从什么时候开始等”“等了几次”“有没有放弃过”。一个线程在 Monitor.Enter 上挂了 3 秒后超时退出,dump 里根本看不到这次等待——它只记录最终状态。
- dump 适合查“当前谁还拿着锁”,不适合查“谁刚放弃过锁又重试”
- async/await 场景下,
Task.Wait()导致的阻塞在 dump 里可能表现为Thread.Sleep或WaitOne,但看不出上下文是否在 UI 线程或 ASP.NET 请求上下文里 - Linux 上没 WinDbg,
dotnet-dump+lldb组合才能补全调用链,但必须提前开启COMPlus_DbgEnableMiniDump=1,否则 dump 缺少托管堆关键信息
排查时最容易被忽略的三个点
真正卡住的线程,往往不在你最怀疑的地方。
- 别只盯
lock语句——Task.Run(() => DoWork()).Result在 ASP.NET Core 中会直接让整个请求线程池线程挂起,现象和死锁一模一样 - FileStream 构造函数也能“卡死”:传了
FileShare.None又多线程打开同一文件,线程会停在NativeOverlapped,WaitReason 显示Executive,但这不是 .NET 锁,是 Windows 内核句柄等待 - 日志里没打时间戳 + 线程 ID 的锁操作,等于没日志;光看“进入 lock”没意义,必须同时记录“释放 lock”和耗时,否则无法判断是慢还是卡


















