原則上我們應該避免編寫混合同步和異步的代碼,這其中最大的問題就是很容易出現死鎖。讓我們來看下面的例子:
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