2019年6月12日 星期三

在非同步工作中使用 ConfigureAwait ,讓非同步方法當作同步方法呼叫,卻不會造成執行緒封鎖的做法

在非同步工作中使用 ConfigureAwait ,讓非同步方法當作同步方法呼叫,卻不會造成執行緒封鎖的做法

根據微軟官方文件上的說明,ConfigureAwait 代表的意思是 : 設定用來等候這個 Task 的 awaiter,而傳遞的參數為 continueOnCapturedContext ,這是一個 Boolean 型別,若該參數值為 true 表示嘗試將接續封送處理回原始擷取的內容,否則為 false。所以,當想要使用同步呼叫方式來呼叫非同步作業的時候,理論上應該是可以解決掉因為當非同步作業處理結束之後,要回到 同步內容 SynchronizationContext 後,卻不會造成執行緒封鎖的問題,不過,需要使用呼叫這個方法的時候,使用 false 引數, ConfigureAwait(false)
現在,來實際體驗一下,在此建立一個 WPF 的專案,因為 WPF 專案內的 UI 執行緒內會有使用到 同步內容 SynchronizationContext,用來測試要如何使用 ConfigureAwait ,但是卻不會造成執行緒封鎖的問題,在這裡的範例專案原始碼,可以從 GitHub 中取得。
在這個 WPF 專案內,建立出三個按鈕,分別用來展示出如何正確使用 ConfigureAwait(false),但是卻不會造成執行緒封鎖的問題。
xaml
<Window x:Class="HowConfigureAwait.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:HowConfigureAwait"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="50"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        <Button
            x:Name="btnWillBlock" Content="會封鎖" Click="BtnWillBlock_Click"
            FontSize="20"/>
        <Button
            Grid.Column="1"
            x:Name="btnWillNotBlock" Content="不會封鎖(非同步工作使用ConfigureAwait)" Click="BtnWillNotBlock_Click"
            FontSize="20"/>
        <Button
            Grid.Column="0" Grid.Row="1"
            x:Name="btnWillNotBlockByAsyncMethod" Content="會封鎖(非同步方法使用ConfigureAwait)" Click="BtnWillNotBlockByAsyncMethod_Click"
            FontSize="20"/>
        <TextBlock
            Grid.Row="2" Grid.ColumnSpan="2"
            x:Name="tbMessage" FontSize="20" Foreground="Red"/>
    </Grid>
</Window>

沒有使用 ConfigureAwait(false),而直接把非同步方法當作同步方式來呼叫

首先,左上角的按鈕,名稱為:會封鎖,當按下這個按鈕,將會使用同步方式 Method1Async().Result 來呼叫設計的非同步方法 async Task<string> Method1Async()。在 Method1Async 非同步方法內,當執行這個 new HttpClient().GetStringAsync(url); 敘述之後,將會得到一個非同步工作物件,接著執行 await task.ConfigureAwait(true) 這個敘述。所以,底下的程式碼將會執行到 string result = task.Result; 敘述之後將會造成執行緒封鎖,因為,當剛剛的 new HttpClient().GetStringAsync(url); 非同步工作執行完畢之後,會需要回到 UI 執行緒下的 同步內容 SynchronizationContext,可是,因為 UI 執行緒下因為執行了 task.Result ,所以,UI 執行緒就會就會進入到封鎖階段,等候非同步工作結束執行,非同步工作執行完畢之後,也想要回到 UI 執行緒,這樣就造成了打死結了。
C Sharp / C#
private async void BtnWillBlock_Click(object sender, RoutedEventArgs e)
{
    string result = Method1Async().Result;
    Debug.WriteLine(result);
}
async Task<string> Method1Async()
{
    Console.WriteLine($"呼叫 Method1Async 之前,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    Task<string> task = new HttpClient().GetStringAsync(url);
    await task.ConfigureAwait(true);
    string result = task.Result;
    Console.WriteLine($"呼叫 Method1Async 之後,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    return " @@恭喜你 - Method1Async({result}) 已經執行完畢了@@ ";
}

呼叫非同步工作且使用 ConfigureAwait(false),而直接把非同步方法當作同步方式來呼叫

現在,來看看右上角的按鈕,名稱為:不會封鎖(非同步工作使用ConfigureAwait),當按下這個按鈕之後,將不會造成整個應用程式進入凍結的狀態,也就是沒有打死結。在這個按鈕事件被呼叫之後,同樣的使用同步方式來呼叫非同步方法,這裡使用的敘述是 Method2Async().Result
在非同步方法 async Task<string> Method2Async() 內,與上一個按鈕中使用到的 async Task<string> Method1Async() 非同步方法十分類似,唯一的差異就是要等候非同步工作執行完畢的時候,使用了 await task.ConfigureAwait(false); 敘述,也就是指定 await 這個運算子關鍵字,不需要捕捉當前的 同步內容 SynchronizationContext 物件,所以,當非同步工作執行完成之後,也就不會回到 同步內容 SynchronizationContext 的執行緒下。在這樣的程式碼,當非同步方法內的敘述 string result = task.Result; 雖然這個敘述是使用同步的方式來等待非同步的執行結果,但是,當非同步工作完成之後,這個敘述將會得到此非同步工作的執行結果,也不會造成專案凍結與執行緒封鎖的問題。
C Sharp / C#
private void BtnWillNotBlock_Click(object sender, RoutedEventArgs e)
{
    string result = Method2Async().Result;
    Debug.WriteLine(result);
    tbMessage.Text = result;
}
async Task<string> Method2Async()
{
    Console.WriteLine($"呼叫 Method2Async 之前,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    Task<string> task = new HttpClient().GetStringAsync(url);
    await task.ConfigureAwait(false);
    string result = task.Result;
    Console.WriteLine($"呼叫 Method2Async 之後,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    return $" @@恭喜你 - Method2Async({result}) 已經執行完畢了@@ ";
}

呼叫非同步方法且使用 ConfigureAwait(false),而直接把非同步方法當作同步方式來呼叫

接著,來看看左上角的按鈕,名稱為:會封鎖(非同步方法使用ConfigureAwait),當按下這個按鈕之後,將整個應用程式進入凍結的狀態,也就是打死結了。
來看看這個按鈕事件程式碼,當這個事件被呼叫之後, Task<string> task = Method3Async() 會使用這個方法來取得 Method3Async 非同步方法的工作物件,緊接著使用 task.ConfigureAwait(false) 敘述,指定這個非同步方法不要捕捉當前的 同步內容 SynchronizationContext,當然,當執行完畢之後,也就不會透過 同步內容 SynchronizationContext 回到當時的 UI 執行緒下。而在非同步方法 Method3Async ,將會與第一個,左上角的按鈕,會封鎖 按鈕相同,使用一般的等候工作做法,通常都會使用這樣的敘述 string result = await new HttpClient().GetStringAsync(url) 是相同的。
不幸的是,這樣的設計方式卻會造成整個應用程式凍結、打死結的狀態,可是,為什麼會這樣的,在這裡不是有使用到 task.ConfigureAwait(false) 敘述嗎?但是還是會打死結,問題地底出在哪裡呢?
請在 Method3Async 方法內的 await task.ConfigureAwait(true); 敘述上設定一個除錯中斷點,執行這個專案,並且按下左下角的按鈕,現在這個程式執行到剛剛設定的中斷點上,接著,按下 F10 不進入函式,然後,就看到了悲慘狀況,整個應用程式凍結了;因此,在按鈕事件上,要呼叫非同步方法的時候,使用 task.ConfigureAwait(false) 敘述是沒有問題,因為整個專案不是凍結在按鈕事件的任何一個敘述,而是凍結在非同步方法內的非同步工作呼叫上 await task.ConfigureAwait(true) (這個敘述與 await task 相同的執行結果) 這個敘述。更進一步的解釋那就是,當呼叫這個敘述的時候,當前的執行緒依舊還是 UI 執行緒,並且因為 這個敘述 await task.ConfigureAwait(true) 指定要捕捉 同步內容 SynchronizationContext 物件,並於非同步工作完成之後,使用這個 同步內容 SynchronizationContext 物件回到 UI 執行緒下繼續來執行,結果就是同樣一句話,當前 UI 執行緒正處於封鎖狀態,所以,整個專案就打死結了。
C Sharp / C#
private void BtnWillNotBlockByAsyncMethod_Click(object sender, RoutedEventArgs e)
{
    Task<string> task = Method3Async();
    task.ConfigureAwait(false);
    string result = task.Result;
    Debug.WriteLine(result);
}
async Task<string> Method3Async()
{
    Console.WriteLine($"呼叫 Method1Async 之前,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    Task<string> task = new HttpClient().GetStringAsync(url);
    await task.ConfigureAwait(true);
    string result = task.Result;
    Console.WriteLine($"呼叫 Method1Async 之後,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    return " @@恭喜你 - Method1Async({result}) 已經執行完畢了@@ ";
}

結論

透過上面的實際範例程式碼展示,想要使用 ConfigureAwait(false) ,指定不要捕捉 同步內容 SynchronizationContext 物件,避免造成執行緒封鎖 Thread Block,就請記得要在 非同步工作 後面加上 ConfigureAwait(false) 敘述,而不要在 非同步方法 後面加上 ConfigureAwait(false) 敘述。
看不懂這句話的話,請要再度細細看過上面的內容,若還是無法理解,這就需要多看些書或者上課來補充這方面的相關知識囉。




沒有留言:

張貼留言