这篇文章讲解在 EF Core 调用链里使用 .Result(或 GetAwaiter().GetResult()),为什么在不同 .NET 框架下会表现成两种事故。
问题背景
同样一行代码,在两个系统里出现了完全不同的故障:
两边都有这段写法:
csharp
public Order? GetOrder(long id)
{
// 典型坑:同步方法里阻塞异步 EF Core 调用
return _db.Orders.FirstOrDefaultAsync(x => x.Id == id).Result;
}
原理:同一个坑,两种后果
场景 1:ASP.NET Classic / WinForms / WPF(有 SynchronizationContext)
这类框架默认要求 continuation 回到原上下文(UI 线程或请求上下文)。
.Result 先把当前线程阻塞住,Task 完成后 continuation 又想回到这条线程,结果互相等待:
- 当前线程在
.Result处阻塞 - continuation 需要回到当前线程继续执行
- 当前线程被阻塞,continuation 进不来
- 死锁
所以你会看到"请求一直转圈"或"界面完全卡死"。
场景 2:ASP.NET Core(默认无 SynchronizationContext)
在没有带 SynchronizationContext 的代码中, ASP.NET Core 不会触发上面的经典死锁,但 .Result 依然很危险。
它会把线程池工作线程同步阻塞住。并发一上来,越来越多线程被卡在 .Result,线程池来不及补充,新请求拿不到线程,就出现线程饥饿:
- CPU 不一定高
- 数据库不一定慢
- 但接口耗时和超时数暴涨
这就是"看起来不像死锁,但系统几乎不可用"的典型表现。
EF Core 里的最小复现
csharp
public sealed class OrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db)
{
_db = db;
}
// ❌ 错误:同步包装异步
public Order? GetById(long id)
{
return _db.Orders.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id).Result;
}
// ✅ 正确:异步到底
public Task<Order?> GetByIdAsync(long id, CancellationToken ct)
{
return _db.Orders.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
}
}
如何避坑(只保留最关键三条)
- 不要在任何 EF Core 调用链上使用
.Result/.Wait()/GetAwaiter().GetResult()。 - API、Service、Repository 全链路改成
async,不要做"同步方法包异步"。 - 除非外部接口强制要求同步签名,且你能控制并发量,否则优先异步。
一句结论
.Result 在老框架里更容易直接死锁,在 ASP.NET Core 里更容易演化成线程饥饿;表现不同,本质相同,都是"阻塞等待异步"导致的。