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