1. 起因
一个 ASP.NET Framework 老项目里的接口偶发性卡死,日志显示请求进来了,但一直没有返回,也没报异常,最后靠线程 dump 才定位到是经典的 async/await 死锁场景。这类问题不算新鲜,但每隔一段时间总有人踩,记一下原理和排查方式。
2. 最经典的死锁写法
1 | public ActionResult GetData() |
在 ASP.NET Framework(用了 SynchronizationContext,也就是经典的 .NET Framework Web 环境,不是 ASP.NET Core)里,这段代码大概率会死锁。原因是:GetData 调用 .Result 会同步阻塞当前线程,等待 GetDataAsync 完成;而 GetDataAsync 内部 await _repository.FetchAsync() 之后,默认会尝试切回原来的 SynchronizationContext(也就是原来那个请求线程)去继续执行剩下的代码——但那个线程已经被 .Result 卡死在等待上了,两边互相等对方,形成死锁。
Task.Result、Task.Wait()、GetAwaiter().GetResult() 只要是在有 SynchronizationContext 的环境里同步阻塞等待一个异步方法,都有同样的风险。
3. 为什么本地测试有时候不复现
这个坑最烦的地方是它不是必现的。如果异步方法内部很快执行完(比如 mock 数据、本地数据库延迟极低),await 之后线程可能还没被上层的 .Result 占住,就侥幸跑过去了。真正在生产环境网络有延迟、数据库压力大的时候才会稳定复现。团队里经常有人本地测试觉得”这样写也没问题啊”,上线之后才在高并发场景下暴露,排查成本很高。
4. 解法一:一路 async 到底
最推荐的做法就是不要在异步链路中间插入同步等待,从最外层的入口(controller action)开始就用 async Task<T>:
1 | public async Task<ActionResult> GetData() |
ASP.NET MVC(4.5 以上)和 ASP.NET Core 的 controller action 都是支持 async Task<ActionResult> 的,没有理由在这一层还用同步方法包一层再调 .Result。
5. 解法二:ConfigureAwait(false)
如果确实有历史包袱没法把整条链路都改成 async,可以在类库代码(不是 UI/请求处理层)里给 await 加上 ConfigureAwait(false):
1 | public async Task<Data> GetDataAsync() |
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),多一层保险。