死锁定位需先用Visual Studio并行堆栈或dotnet-dump分析线程阻塞点,重点排查lock、Wait、Enter等同步调用及锁持有关系,结合日志还原获取顺序。

死锁一卡住,先看线程在等什么
程序卡死不等于死锁,但真死锁时,必有线程停在 lock、Monitor.Enter、Mutex.WaitOne 或 Task.Wait 这类同步点上,且互相等待。Visual Studio 调试器是最直接的入口:运行中点暂停 → 打开「并行堆栈」窗口 → 观察所有线程的调用链,重点找带 Wait、Enter、Acquire 字样的栈帧。
- 右键任一线程 → 「切换到线程」,VS 会自动跳转到它阻塞的那一行代码(比如
lock(obj)) - 注意「WaitReason」字段:如果是
Synchronization或Executive,大概率是锁竞争;如果是IOCompletionEx,那是正常异步 I/O 等待,别误判 - 别只盯一个线程——死锁至少涉及两个线程,必须对照看它们各自持有啥、又在等啥
FileStream 读写“卡死”?大概率不是死锁,是共享模式配错了
C# 文件操作本身不会触发传统死锁(因为没用 .NET 同步原语嵌套加锁),但 FileStream 构造失败或参数冲突会导致无限等待,现象和死锁几乎一样:CPU 低、线程数飙升、File.ReadAllBytes 没响应。
-
FileShare.None+ 多线程同时打开同一文件 → 第二个线程永远卡在构造函数里,直到第一个关闭句柄 -
FileAccess.Read+FileShare.ReadWrite→ 危险组合,可能被其他进程写锁拦截(如 Excel 正在编辑 .xlsx) - 安全组合优先选
FileAccess.Read+FileShare.Read;写入场景务必显式传FileOptions.Asynchronous,否则ReadAsync可能退化为同步阻塞
async/await 场景下,Result 和 .Wait() 是最隐蔽的死锁源
ASP.NET Core 或 WinForms 中,一旦在 UI 线程或请求上下文里调用 task.Result 或 task.Wait(),就等于让当前线程干等——而异步任务的 continuation 又需要回到这个线程执行,形成闭环阻塞。
- 典型错误:
var data = GetDataAsync().Result;或Task.Run(() => MyAsyncEnumerable).Result; -
await foreach隐式调用MoveNextAsync(),但默认捕获同步上下文(即等价于ConfigureAwait(true)),UI 线程里直接用极易卡死 - 调试时用 Windbg 查
!clrstack | findstr "Wait|Result",能快速揪出所有被同步等待拖住的线程
生产环境没 VS?用 dotnet-dump 抓现场线程快照
线上服务卡住,没法连调试器,就得靠内存转储分析。.NET 5+ 自带 dotnet-dump 工具,可导出线程状态、锁持有关系和托管堆信息。
- 先用
dotnet-dump ps找进程 ID,再执行dotnet-dump collect -p生成.dmp文件 - 加载后运行
dumpheap -stat看对象分布,threads列出所有线程,clrstack -a查每个线程栈帧里的锁对象地址 - 若发现多个线程都在等同一个
Monitor地址,且其中至少一个正持有另一把锁 → 基本锁定死锁路径
真正难的不是定位,而是还原锁获取顺序——尤其当锁对象是动态生成的(比如 lock(_dict[key])),或跨了不同类的实例方法。这时候光看栈没用,得结合日志打点,在 Monitor.Enter 前后记录线程 ID、锁对象哈希码和调用位置,否则永远只能猜。











