最近在写WinUI 3时,经常遇到在各种事件中调用异步方法的场景,在事件处理方法中等待异步调用完成需要使用await关键字并将事件处理方法标志为async。几乎部分事件处理方法都是没有返回值的,即返回值为void。此前有在一些地方看到过async void修饰的方法有一些问题,所以想着不用await/async关键词,而改用Task.Result或者Task.Wait()来阻塞当前代码,以实现await的效果,没想到直接卡死了整个程序,深入探究了一番发现了卡死的原因。本文内容主要来源于以下两篇文章:
- Don’t Block on Async Code (stephencleary.com)
- Await, and UI, and deadlocks! Oh my! – .NET Parallel Programming (microsoft.com)
以一个按钮事件为例:
// My "library" method.
public static async Task<JObject> GetJsonAsync(Uri uri)
{
// (real-world code shouldn't use HttpClient in a using block; this is just example code)
using (var client = new HttpClient())
{
var jsonString = await client.GetStringAsync(uri);
return JObject.Parse(jsonString);
}
}
// My "top-level" method.
public void Button1_Click(...)
{
var jsonTask = GetJsonAsync(...);
textBox1.Text = jsonTask.Result;
}
在点击此按钮后,整个程序卡死无响应,其卡死的原因是因为上面的代码会造成死锁。
为什么会产生死锁呢?代码中并没有资源争抢的操作,死锁是产生在哪里呢?
在C#中,由async修饰的方法,即异步方法,会被编译器编译为状态机。当遇到调用异步方法时,CLR将会捕获调用的上下文,保存在状态机中,以便异步方法完成后可以继续在相同的上下文中运行。简单来说就是将调用异步方法前的环境保存下来,然后去做别的事情,等异步方法调用完成后,再把保存的环境恢复,就可以继续运行后续的代码了。
在这个例子中,当遇到await client.GetStringAsync(uri)时,CLR保存了当前的上下文(UI上下文,因为事件处理程序都是在UI处理线程中完成的)到GetJsonAsync异步方法的状态机中去,调用其它线程运行GetStringAsync方法,然后UI线程就去处理后续的程序了(因为在调用GetJsonAsync时没有等待,而在调用GetStringAsync时等待了),接着遇到了jsonTask.Result,就在这里以同步方式阻塞了UI上下文来等待GetJsonAsync完成。这个时候,UI上下文被UI线程占用了并一直在等GetJsonAsync完成,而在GetJsonAsync中,GetStringAsync完成后,需要恢复上下文继续运行后续代码,它需要的上下文正是UI上下文,于是两个线程形成了互相等待,死锁就产生了。详细的过程如下:
- 事件处理方法调用 GetJsonAsync(在 UI上下文中)
- GetJsonAsync 通过调用 HttpClient.GetStringAsync(仍在上下文中)启动 REST 请求
- GetStringAsync 返回未完成的task,指示 REST 请求未完成
- GetJsonAsync 等待 GetStringAsync 返回的task。上下文将被捕获,稍后将用于继续运行 GetJsonAsync 方法。 GetJsonAsync 返回未完成的task,表明 GetJsonAsync 方法未完成
- 顶级方法等待 GetJsonAsync 返回的任务完成。这会阻塞上下文线程
- 最终,REST 请求将完成,这完成了 GetStringAsync 返回的task
- GetJsonAsync 的现在已准备好继续运行后续代码,并且它将等待上下文可用,以便可以在上下文中执行。
- 死锁产生,顶级方法正在阻塞上下文线程,等待 GetJsonAsync 完成,而 GetJsonAsync 正在等待上下文空闲以便完成。
所以,在事件处理程序中或者在UI线程中,尽量不要使用同步阻塞的方式调用异步方法,即不要试图使用Task.Result和Task.Wait()来保持事件处理程序的同步性。
在ASP.NET Core中,也是类似的道理,不要在Endpoint处理程序中以同步方式调用异步方法,它也会导致死锁的产生,只不过它的上下文将会变成每个请求的上下文。
实际上,async void与async Task两种形式的无返回值异步方法,其区别在于是否能够等待。一个异步方法编译形成的状态机会把其内部代码根据await关键词分割成数个部分,每个部分的完成是通过await等待的那个task中的GetAwaiter()方法调用结果决定的,一个部分完成后,由状态机决定下一步的动作。因而,若一个方法由async void修饰,那么状态机就无法得知其完成与否,因此不会等待它的完成。而一个方法若由async Task修饰,状态机就可以根据返回的task获知其是否完成,从而可以决定等待与否。
调用使用async void修饰的异步方法,称之为“发起并遗忘(Fire-and-forget)”,它只管调用,而不管其后续执行状态和结果,通常只在异步事件处理程序中使用。同时,调用方无法捕获从该方法引发的异常,此类未经处理异常有可能导致整个应用程序崩溃1。
调用使用async Task修饰的异步方法,称之为“等待并继续(Wait-and-continue)”,在发起调用后会继续跟踪任务的执行状态和结果。如果返回 Task 或 Task<TResult> 的方法引发异常,则该异常会存储在返回的task中。 在await 该 task时,将重新引发异常。
综上,在事件处理方法中等待异步调用完成最好使用await关键字并将事件处理方法标志为async(几乎都为async void),这种方式并没有什么问题,也是推荐的做法。