1. 程式人生 > >.NET Core學習筆記(4)——謹慎混合同步和非同步程式碼

.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

&n