.NET .Result框架下,如何避免不同线程池饥饿与死锁的复杂陷阱?

摘要:.NET 异步里最常见的隐性炸弹:.Result.Wait 在老框架容易死锁,在 ASP.NET Core 更常见线程池饥饿。
这篇只讲一个知识点:在 .NET 代码里用 .Result(或 GetAwaiter().GetResult())同步阻塞异步任务,为什么在不同框架下会触发不同类型的事故。 问题背景 同样一行代码,在两个系统里出现了完全不同的故障: 老系统(ASP.NET MVC 5)请求直接卡死,不返回 新系统(ASP.NET Core)不是直接死锁,而是高峰期吞吐突然掉到很低,请求排队超时 两边都有这段写法: public string GetData() { return GetDataAsync().Result; } private async Task<string> GetDataAsync() { await Task.Delay(50); return "ok"; } 原理:同一个坑,两种后果 场景 1:ASP.NET Classic / WinForms / WPF(有 SynchronizationContext) 这类框架默认要求 continuation 回到原上下文(UI 线程或请求上下文)。 .Result 先把当前线程阻塞住,Task 完成后 continuation 又想回到这条线程,结果互相等待: 当前线程在 .Result 处阻塞 continuation 需要回到当前线程继续执行 当前线程被阻塞,continuation 进不来 死锁 所以你会看到"请求一直转圈"或"界面完全卡死"。 场景 2:ASP.NET Core(默认无 SynchronizationContext) 在默认配置下,ASP.NET Core 没有传统的请求级 SynchronizationContext,所以通常不会触发上面的经典互锁。 它会把线程池工作线程同步阻塞住。并发一上来,越来越多线程被卡在 .Result,线程池来不及补充,新请求拿不到线程,就出现线程饥饿: CPU 不一定高 数据库不一定慢 但接口耗时和超时数暴涨 这就是"看起来不像死锁,但系统几乎不可用"的典型表现。 最小对照示例 public sealed class DemoService { // ❌ 错误:同步包装异步 public int GetNumber() { return GetNumberAsync().Result; } // ✅ 正确:异步到底 public async Task<int> GetNumberAsync() { await Task.Delay(10); return 42; } } 如何避坑(只保留最关键三条) 不要在任何业务调用链上使用 .Result / .Wait() / GetAwaiter().GetResult()。 API、Service、Repository 全链路改成 async,不要做"同步方法包异步"。 如果历史包袱必须保留同步签名,就让边界层同步,内部仍然异步,避免层层阻塞传染。 一句结论 .Result 在老框架里更容易直接死锁,在 ASP.NET Core 里更容易演化成线程池饥饿;表现不同,本质相同,都是"阻塞等待异步"导致的。