手记

.NET Core学习笔记(4)——谨慎混合同步和异步代码

原则上我们应该避免编写混合同步和异步的代码,这其中最大的问题就是很容易出现死锁。让我们来看下面的例子:

        private void ButtonDelayBlock_Click(object sender, RoutedEventArgs e)
        {
            Delay100msAsync().Wait();
            this.buttonDelayBlock.Content = "Done";
        }
 
        private async Task Delay100msAsync()
        {
            await Task.Delay(100);
        }

这段代码取自Sample代码中的AsyncBlockSample工程,一个简单的WPF程序(.NET Core)。

https://github.com/manupstairs/AsyncAwaitPractice

在buttonDelayBlock按钮被点击后,会执行Dealy100msAsync方法,同时我们希望将该异步方法以同步的方式运行。在该方法完成后,将buttonDelayBlock按钮的文字设置为“Done”。

所以我们没有对Delay100msAsync方法应用await关键字,而是通过Wait方法同步等待执行结果。

非常遗憾在WPF之类的GUI程序中,我们点击buttonDelayBlock按钮后,程序将会进入死锁的状态。

这是因为一个未完成的Task被await时(Task.Delay(100)返回的Task),将捕获当前的context,用于Task完成时恢复执行接下来的操作。在GUI程序中,此时的context是当前 SynchronizationContext,而GUI程序中的 SynchronizationContext同一时间只能运行一个线程(在Sample里是UI线程)。

所以当Task.Delay(100)完成时,希望能够回到UI线程接着执行,但UI线程正通过Delay100msAsync.Wait()方法在等待Task完成。这踏马就跟吵架了都在等对方先低头,整个程序都不好了,然后就死了……

值得一提的是Console程序并不会出现上述死锁,这是因为Console程序中的SynchronizationContext可以通过ThreadPool来调度不同线程来完成Task,而不会像GUI程序卡在UI线程进退不得。这样不同的迷惑行为,即使是十分年长的猿类也瑟瑟发抖……

最理想的情况就是只编写异步代码。问题是除非编写UWP这样,从底层API调用就强制异步。不然很难避免旧有的同步API的使用。

更不用说成千上万的旧有代码的维护,迁移桌面程序到MS Store,已有GUI程序Win10 style化需求等等。混合同步和异步代码实在是难以避免的。像例子中需要等待异步方法完成,再根据结果执行的情况就更常见了。

解决上述死锁的一个方式是通过ConfigureAwait方法来配置context。

async Task MyMethodAsync()
{
  // Code here runs in the original context.
await Task.Delay(1000);
  // Code here runs in the original context.
await Task.Delay(1000).ConfigureAwait(continueOnCapturedContext: false);
  // Code here runs without the original
  // context (in this case, on the thread pool).
}

如注释所描述,第一个await Task.Delay方法前后的代码块会在相同的context中执行,因为Task完成后仍会返回原先的context。而第二个await Task.Delay则不再依赖原先的context。如果是在GUI程序中执行上面的代码,后续的代码将在ThreadPool,而不是之前的UI线程上执行。

在这种情况下如果出现了对UI元素的操作,便会出现祖传的跨线程操作Exception。

我们回到死锁的问题上,通过ConfigureAwait配置context的代码如下:

private async Task Delay100msWithoutContextAsync()
{
await Task.Delay(100).ConfigureAwait(false);
}
private void ButtonDelay_Click(object sender, RoutedEventArgs e)
{
Delay100msWithoutContextAsync().Wait();
this.buttonDelay.Content = "Done";
}

我们可以通过这种方式终结异步代码链的传递,将一小块的异步代码隐匿在旧有的同步代码中使用,当然仍需要十分小心。

这里还有一种略显繁琐且奇怪的方式来解决死锁问题:

        private void ButtonDelay2_Click(object sender, RoutedEventArgs e)
        {
            var text = buttonDelay2.Content.ToString();
            var length = Task.Run(async () => { return await GetLengthAsync(text); }).Result;
            buttonDelay2.Content = $"Total length is {length}";
        }
 
        private async Task<int> GetLengthAsync(string text)
        {
            await Task.Delay(3000);
            return text.Length;
        }

异步方法GetLengthAsync能返回传入字符串的长度,Task.Run(…)会通过ThreadPool来异步地执行一个Func<Task<int>>,且返回Task<int>,而Task<int>.Result属性又以同步的方式阻塞在这里等待结果。

与之前Wait最大的不同,是因为Task.Run利用了ThreadPool没有导致UI线程的死锁。

我们再回到通过ConfigureAwait配置context,等待异步方法结果的方式:

        private void ButtonDelay3_Click(object sender, RoutedEventArgs e)
        {
            var text = buttonDelay3.Content.ToString();
            var length = GetLengthWithoutContextAsync(text).Result;
            buttonDelay3.Content = $"Button 3 total length is {length}";
        }
 
        private async Task<int> GetLengthWithoutContextAsync(string text)
        {
            await Task.Delay(3000).ConfigureAwait(false);
            //Cannot access UI thead here, will throw exception
            //buttonDelay3.Content = $"Try to access UI thread";
            return text.Length;
        }

同样是等待Task<type>的Result,相对而言更推荐这种方式,结构清晰且更好理解。注释提到ConfigureAwait(false)之后的代码是不能访问UI线程的。

本篇讨论了混合同步和异步代码时的一些注意事项,还请各位大佬斧正。

Github:

https://github.com/manupstairs/AsyncAwaitPractice


0人推荐
随时随地看视频
慕课网APP