24

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

 5 years ago
source link: https://www.imzjy.com/blog/dotnet-async-locks-and-solutions?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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时候发生了两件事:

  • 在返回之前偷偷做了个动作,那就是将当前线程的同步上下文(SychronizationContext)给捕获了。
  • 返回了一个未完成的任务,这里面抽象为Task

然后在第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


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK