C#中async的死锁分析和解决方案


死锁示例

如果你开发一个简单的Windows Form程序,点击Button去使用async异步获取一个数据,然后显示在Label上,类似这样的代码

private void button1_Click(object sender, EventArgs e)
{
    var task = GetContentAsync();
    var content = task.Result;

    this.label1.Text = content;
}


public async Task<string> GetContentAsync()
{
    var http = new HttpClient();
    var result  = await http.GetStringAsync("http://www.imzjy.com");

    var first50 = result.Substring(0, 50);
    return first50;
}

当你点击Button的时候会发现程序直接卡死了。

死锁原因分析

C#中的async/await隐藏了很多的细节,一个简单的await其实让函数发生了一次重入,重入对于多线程代码来说其实很正常。但是C#将这些藏了起来。你看上去像一个函数,其实被分成了两段,而且执行这两段代码的线程还可能不一样。

上面代码真正的执行过程是这样的:

private void button1_Click(object sender, EventArgs e)
{
    //1. calling GetContentAsync
    var task = GetContentAsync();
    Debug.WriteLine($"Continuation:{Environment.CurrentManagedThreadId}");

    //4. .Result(or GetAwait().GetResult()) which waiting for GetContentAsync to complete. 
    //OOPS:   DEADLOCK!!!
    //REASON: task.Result waiting the http.GetStringAsync complete and return;
    //REASON: GetContentAsync wait button1_Click release the synchronization context;
    var content = task.Result;

    this.label1.Text = content;
}


public async Task<string> GetContentAsync()
{
    var http = new HttpClient();
    //2. automatic capture synchronization context   :: auto capture caused the issue.
    //3. due to await applied, yield thread to caller(button1_Click)
    var result  = await http.GetStringAsync("http://www.imzjy.com");

    //WHY AUTO CAPTURE? capture the synchronization context makes following accessing UI control became possible.
    //textBox1.Text = first50;    

    var first50 = result.Substring(0, 50);
    return first50;
}

对于GetContentAsync函数来说,在await之前其实是同步的代码,当await之后,线程直接返回给button1_Clickawait时候发生了两件事:

然后在第4步,当button1_Click中去获取上面这个Task返回值的时候出现了死锁,button1_ClickGetContentAsync相互等待:

  1. var content = task.Result; button1_Click等待任务await http.GetStringAsync("http://www.imzjy.com");完成
  2. await http.GetStringAsync("http://www.imzjy.com");等待当前线程(UI线程)的同步上下文SychronizationContext

由于上面两个方法相互等待,所以产生了死锁。

为什么自动捕获当前线程同步上下文

GetContentAsync自动捕获的是当前UI线程的同步上下文,通过偷偷的捕获当前UI线程的同步上下文可以让你在GetContentAsync方法中await之后可以更新UI控件。如果你在GetContentAsync中不需要更新UI控件,那么我们就不必捕获同步上下文,那么也就不存在这个问题。

解决方案 1

修改GetContentAsync,让http.GetStringAsync("http://www.imzjy.com”);自动捕获上下文时候捕获不到。破坏了上面的死锁条件2。

private void button1_Click(object sender, EventArgs e)
{
    var task = GetContentAsync();
    var content = task.Result;

    this.label1.Text = content;
}


public async Task<string> GetContentAsync()
{
    var syncContext = WindowsFormsSynchronizationContext.Current;  //save SynchronizationContext
    WindowsFormsSynchronizationContext.SetSynchronizationContext(null); //set SynchronizationContext to null

    var http = new HttpClient();
    var result  = await http.GetStringAsync("http://www.imzjy.com");

    WindowsFormsSynchronizationContext.SetSynchronizationContext(syncContext); //restore the SynchronizationContext

    var first50 = result.Substring(0, 50);
    return first50;
}

优点:
1. 调用方代码不需要改变

缺点:
1. 调用者线程(UI线程)会在var content = task.Result;阻塞,直到GetContentAsync返回,导致界面在此期间无响应。
2. 如果异步方法类似http.GetStringAsync("http://www.imzjy.com")需要更新界面(使用UI线程)会出现问题
3. 改的代码比较多3行。
4. WindowsFormsSynchronizationContext.SetSynchronizationContext(null);可能有副作用。

解决方案 2

修改调用方式,将调用放到Thread pool中,这样await http.GetStringAsync("http://www.imzjy.com");的auto capture就不会获取到当前UI线程的SynchronizationContext,破坏了上面的死锁条件2。

private void button1_Click(object sender, EventArgs e)
{
    //put the GetContentAsync into thread pool, so that 
    //http.GetStringAsync("http://www.imzjy.com"); 
    //will capture the SynchronizationContext from thread pool's excection environemnt
    var task = Task<string>.Run(GetContentAsync); 


    var content = task.Result;
    this.label1.Text = content;
}


public async Task<string> GetContentAsync()
{
    var http = new HttpClient();
    var result  = await http.GetStringAsync("http://www.imzjy.com");

    var first50 = result.Substring(0, 50);
    return first50;
}

优点:
1. async方法不需要改变。

缺点:
1. 调用者线程(UI线程)会在var content = task.Result;阻塞,直到GetContentAsync返回,导致界面在此期间无响应。
2. 如果异步方法类似http.GetStringAsync("http://www.imzjy.com")需要更新界面(使用UI线程)会出现问题

解决方案 3

通过ConfigureAwait来改变自动捕获SynchronizationContext行为,破坏了上面的死锁条件2。

private void button1_Click(object sender, EventArgs e)
{
    var task = GetContentAsync();

    var content = task.Result;
    this.label1.Text = content;
}


public async Task<string> GetContentAsync()
{
    var http = new HttpClient();
    //tell await not to capture SynchronizationContext
    var result  = await http.GetStringAsync("http://www.imzjy.com”)
                            .ConfigureAwait(continueOnCapturedContext: false);

    var first50 = result.Substring(0, 50);
    return first50;
}

优点:
1. 调用方(caller)不需要改变
2. 避免了此处无用的自动捕获线程上下文。

缺点:
1. 调用者线程(UI线程)会在var content = task.Result;阻塞,直到GetContentAsync返回,导致界面在此期间无响应。
2. 如果异步方法类似http.GetStringAsync("http://www.imzjy.com")需要更新界面(使用UI线程)会出现问题

解决方案 4

把当前的事件处理函数也改成async的,这样破坏了死锁条件的1。button1_Click不在死等,所以也释放了上下文。

private async void button1_Click(object sender, EventArgs e)
{
    var task = GetContentAsync();
    var content = await task;

    this.label1.Text = content;
}

public async Task<string> GetContentAsync()
{
    var http = new HttpClient();
    var result  = await http.GetStringAsync("http://www.imzjy.com");

    var first50 = result.Substring(0, 50);
    textBox1.Text = first50;

    return first50;
}

优点:
1. async方法不需要改变。
2. 避免了UI无响应的问题。
3. GetContentAsyncawait之后可以更新UI界面。

缺点:
1. button1_Click改为了异步,对原来的方法有侵入性,甚至会改变整个调用链的行为,我最讨厌这点了。

适用

上面的死锁通常会发生在下面两个地方

  1. Windows Forms的UI线程中调用了异步的方法。
  2. ASP.NET的User Request Context执行环境,比如Controller中的方法。 代码细节

经验

异步方法实现者

  1. 分开提供同步和异步方法
  2. 只是自己做一些事,不需要bind到调用线程上的需要尽量.ConfigureAwait(continueOnCapturedContext:false)

对于异步方法使用者

  1. 看看是否提供了同步方法
  2. 考虑是否有机会将自己的代码转为异步代码
  3. 实在不行放到threadpool中去执行
  4. 即使你调用的类库的实现者使用了ConfigureAwait(false),但是你如果用Task包装了一下,这时候需要在返回Task对象上显示的调用ConfigureAwait(false),否则调用你包装代码的地方也可能发生死锁。因为你的包装方法会默认捕获当前线程的同步上下文(SychronizationContext)。

async/await中的异常处理

如果加上异常处理,那么async/await会变得更加复杂,因为异步方法在异步执行,所以可以放到不同的线程中,那么如果出现了异常会怎么样?简单来说:

  1. 异步代码中的异常如果存在TaskTask<T>被attach到了Task对象上
  2. 但是async void例外,由于没有Task对象可以attach,所以attach到了SynchronizationContext中活跃的线程中了。
  3. 异步方法调用(调用链)中的异常,会被Aggregate,然后生成一个AggregateException。你可以使用aggExp.Flatten()方法来方便查看这个调用链中所有异常--如果有多个的话。

完整代码示例

AsyncLockAndFixes

published at Feb. 26, 2020, 2:51 p.m.

Comments:

Sharing your thoughts: