1. 起因

一个 ASP.NET Framework 老项目里的接口偶发性卡死,日志显示请求进来了,但一直没有返回,也没报异常,最后靠线程 dump 才定位到是经典的 async/await 死锁场景。这类问题不算新鲜,但每隔一段时间总有人踩,记一下原理和排查方式。

2. 最经典的死锁写法

1
2
3
4
5
6
7
8
9
10
11
public ActionResult GetData()
{
var result = GetDataAsync().Result; // 同步等待异步方法
return Json(result);
}

public async Task<Data> GetDataAsync()
{
var data = await _repository.FetchAsync(); // await 之后想回到原来的上下文
return data;
}

在 ASP.NET Framework(用了 SynchronizationContext,也就是经典的 .NET Framework Web 环境,不是 ASP.NET Core)里,这段代码大概率会死锁。原因是:GetData 调用 .Result 会同步阻塞当前线程,等待 GetDataAsync 完成;而 GetDataAsync 内部 await _repository.FetchAsync() 之后,默认会尝试切回原来的 SynchronizationContext(也就是原来那个请求线程)去继续执行剩下的代码——但那个线程已经被 .Result 卡死在等待上了,两边互相等对方,形成死锁。

Task.ResultTask.Wait()GetAwaiter().GetResult() 只要是在有 SynchronizationContext 的环境里同步阻塞等待一个异步方法,都有同样的风险。

3. 为什么本地测试有时候不复现

这个坑最烦的地方是它不是必现的。如果异步方法内部很快执行完(比如 mock 数据、本地数据库延迟极低),await 之后线程可能还没被上层的 .Result 占住,就侥幸跑过去了。真正在生产环境网络有延迟、数据库压力大的时候才会稳定复现。团队里经常有人本地测试觉得”这样写也没问题啊”,上线之后才在高并发场景下暴露,排查成本很高。

4. 解法一:一路 async 到底

最推荐的做法就是不要在异步链路中间插入同步等待,从最外层的入口(controller action)开始就用 async Task<T>

1
2
3
4
5
public async Task<ActionResult> GetData()
{
var result = await GetDataAsync();
return Json(result);
}

ASP.NET MVC(4.5 以上)和 ASP.NET Core 的 controller action 都是支持 async Task<ActionResult> 的,没有理由在这一层还用同步方法包一层再调 .Result

5. 解法二:ConfigureAwait(false)

如果确实有历史包袱没法把整条链路都改成 async,可以在类库代码(不是 UI/请求处理层)里给 await 加上 ConfigureAwait(false)

1
2
3
4
5
public async Task<Data> GetDataAsync()
{
var data = await _repository.FetchAsync().ConfigureAwait(false);
return data;
}

ConfigureAwait(false) 告诉运行时,await 之后不需要切回原来的 SynchronizationContext,可以用线程池里任意线程继续执行后续代码,这样就不会去等被上层 .Result 占住的那个线程,死锁自然就解开了。这是一个折中方案,本质上是绕开问题而不是解决问题——真正干净的做法还是全链路 async。

需要注意的是 ASP.NET Core 已经没有默认的 SynchronizationContext 了,所以在纯 ASP.NET Core 项目里这个死锁场景基本不会出现,ConfigureAwait(false) 这套东西主要是 Framework 时代和桌面程序(WPF/WinForms,它们也有自己的 SynchronizationContext)遗留下来的知识点。但混合项目、类库要同时给 Framework 和 Core 项目用的情况还挺常见,写类库代码时加上 ConfigureAwait(false) 是个不吃亏的习惯。

6. 排查思路

真遇到这类卡死问题,用 Visual Studio 或者 dotnet-dump 抓一个线程 dump,找卡住的线程调用栈,看是不是停在 .Result 或者 .Wait() 上,栈的另一头是不是有一个 await 之后就再也没继续执行的 Task。这种”卡住但不报错、不超时”的现象,八成就是这个死锁模式。

7. 小结

这个坑本质上是”同步等待异步代码 + 需要切回原线程的上下文”两个条件凑一起才会触发,只要养成一路 async 到底、不在中间插同步阻塞的习惯,基本能避开。类库代码顺手加 ConfigureAwait(false),多一层保险。