WinDbg 查看线程阻塞和锁竞争
高并发下响应变慢,第一反应是线程卡在同步原语上。用 WinDbg 加载
dump后,
!threads能快速列出所有托管线程状态,重点关注
State列为
Wait:或
Preemptive但实际在等锁的线程。
接着用
!syncblk查看同步块持有情况,输出里
MonitorHeld非 0 的条目说明有线程正持有锁;再配合
!dlk(需先加载
SOS.dll)可自动识别死锁链——但要注意:它只检测 CLR 层面的
Monitor.Enter/
lock,对
SpinLock、
ReaderWriterLockSlim或 native mutex 不敏感。
~*e !clrstack查每个线程的托管调用栈,确认是否卡在
Monitor.Wait、
WaitHandle.WaitOne或
Task.Wait若看到大量线程停在
System.Threading.Monitor.ObjWait,大概率是某个共享资源被单一线程长期独占 注意
!threads -state中的
Background线程可能也在争抢同一把锁,不能只盯主线程
PerfView 抓取高并发下的 CPU 和 GC 热点
WinDbg 擅长“静态快照”,PerfView 更适合“动态采样”。启动时勾选
CPU Stack+
GC Heap Alloc,采样时间建议 ≥30 秒,避免噪声干扰。导出
Events视图后,重点看两个维度:
CPU 热点:展开
Microsoft-Windows-DotNETRuntime/MethodJITVerbose或直接看
Stacks页的
Hot Path,高频出现
ConcurrentDictionary`2.TryGetValue或
StringBuilder.Append不一定错,但若伴随大量
GC/Start事件,则可能是分配压力引发的间接竞争。
GC 压力信号:观察
GC/End事件间隔是否小于 1 秒,且
Gen 2次数突增——这常意味着大对象堆(LOH)频繁分配,而 LOH 分配在高并发下会触发全局锁
gc_heap::allocate_large,成为隐形瓶颈。 PerfView 默认不捕获
ThreadPool队列长度,需手动开启
Microsoft-Windows-DotNETRuntime/ThreadPool/WorkerThreadAdjustment事件 对比
Alloc By Type和
Alloc By Stack,能定位是哪个业务逻辑路径在疯狂 new 对象 导出
GCStats表格时,注意
PauseMSec列总和占比,若 >15%,说明 GC 已显著拖慢吞吐
WinDbg + PerfView 联动定位 async/await 隐形阻塞
async 方法没用
await但写了
async修饰符,或在
ConfigureAwait(false)缺失场景下调度回 UI/ASP.NET 上下文,都会导致线程池饥饿。PerfView 中若发现
ThreadPool/WorkerThreadAdjustment频繁触发扩容,同时
ThreadPool/QueuedWorkItem队列深度持续 >100,就要怀疑 await 后续执行被卡住。
此时切回 WinDbg,用
!dumpheap -type System.Threading.Tasks.Task查看未完成的 Task 数量;再挑几个状态为
WaitingForActivation的 Task,用
!dumpobj <address></address>看其
m_stateFlags和
m_continuation字段——如果
m_continuation是
AsyncMethodBuilderCore实例,且其
m_stateMachine的字段显示 awaiter 仍处于
IsCompleted == false,基本确认是 I/O 或定时器未触发回调。 检查是否误用
Task.Result或
Task.Wait()在 ASP.NET Core 同步上下文中,这会直接阻塞整个请求线程 PerfView 的
Stacks页中搜索
TaskAwaiter.HandleNonSuccessAndDebuggerNotification,它的调用频次异常高,往往对应 await 后续逻辑执行缓慢 WinDbg 里
!dumpheap -stat若显示大量
System.Object[]或
System.Byte[],可能是 JSON 序列化/反序列化过程中 buffer 复用失败,引发额外分配和锁争用
容易被忽略的托管与非托管混合瓶颈
高并发服务常调用 native DLL(如加密、图像处理),这类调用不会出现在
!clrstack中,但会卡住线程。WinDbg 里用
~*k看所有线程的原生栈,若某线程栈顶是
ntdll!NtWaitForSingleObject或
kernel32!WaitForMultipleObjects,且没有对应的托管帧,就得怀疑 native 层阻塞。
PerfView 可通过开启
Windows Kernel/Process Thread事件并勾选
SampleProfile,把 native 栈也纳入采样范围。此时
Stacks页会出现
ntdll!RtlEnterCriticalSection或
msvcr120!malloc这类符号——前者说明 native 代码用了临界区且持有时间过长,后者则暗示频繁 malloc/free 引发的 heap lock 竞争。 托管代码调用 native 函数时,务必检查 P/Invoke 声明是否加了
[DllImport(..., CallingConvention = CallingConvention.Cdecl)],否则调用约定不匹配会导致栈损坏,表现为随机线程挂起 若 native DLL 使用了静态全局变量或单例,高并发下调用它极易触发隐式锁,这种锁 WinDbg 和 PerfView 都无法直接标记归属,只能靠排除法 + 源码审查 PerfView 导出的
GCStats中若
Gen 0PauseMSec 极短但
Gen 2却很长,且 native 栈频繁出现
HeapAlloc,说明托管内存压力已传导至 native heap 管理层 实际排查时,别指望一次抓 dump 或一次采样就定位根因。线程状态、GC 频率、native 调用耗时这三者往往互相掩盖,得来回切换工具交叉验证。尤其是 async 场景下,Task 状态、SynchronizationContext、线程池队列深度这三者的关联性,必须同时看全才能看清真相。
