2019年6月4日 星期二

如何在 C# 內,把非同步方法當作同步方法呼叫,卻不會造成執行緒封鎖的做法,徹底解法教學

如何在 C# 內,把非同步方法當作同步方法呼叫,卻不會造成執行緒封鎖的做法,徹底解法教學

現在,在 .NET 開發環境中,原先許多以同步方式處理的 API,現在大多已經提供了非同步的 API,而且這些非同步的 API 並不是使用 APM 或者 EAP 的模式,而是使用 TAP 的工作非同步設計方式。不過,相信絕大部分有在使用 C# 程式語言來進行專案開發的人,都會對於如何充分、正確的使用非同步工作,存在著許多迷惘與困惑,甚至,經常性的會遇到造成執行程式停滯的狀態,無法繼續執行的模式,也就是造成了執行緒封鎖的狀態。若進行 Windows Forms, Web Forms, WPF, ASP.NET MVC 這些類型的專案,因為在其開發框架中有使用到了 SynchronizationContext 同步內容 這個物件,一不小心就會造成程式進入封鎖狀態下。
會造成這樣的模式,大多是把非同步的 API,使用同步的方式來呼叫,例如:有個 async Task<string> MyMethodAsync() 這個非同步方法,並沒有使用 await MyMethodAsync() 這樣的敘述來執行非同步方法,而是使用了 MyMethodAsync().Wait() 或者 MyMethodAsync().Result 的封鎖當前執行緒之同步方式方式來呼叫,這樣的會,當在有 SynchronizationContext 開發框架的模式下,就會造成執行緒封鎖 Thread Block 的狀態。
另外,在某些情況或者是開發框架下所提供的方法內,無法使用 await 來等待一個非同步工作或者非同步方法,例如:當有個方法一定要為 string MyMethod() ,不能夠在前面只加上 async ,也就是寫成 async string MyMethod() 這樣的寫法 (因為,若某個方法前面加上了 async 修飾詞,該方法的回傳值一定要為這三種型別的其中一個: Task , Task , void ),這樣是不正確的,也就是若寫成底下的程式碼,將會造成編譯器產生錯誤訊息 : CS1983 非同步方法的傳回類型必須為 void、Task、Task、task-like 類型、IAsyncEnumerable 或 IAsyncEnumerator SynchronizationContextBlock
C Sharp / C#
async string MyMethod()
{
    return "";
}
若有個方法沒有使用 async 修飾詞,但是會回傳 Task 或者 Task,例如 Task<string> MyTaskAsync()這樣的方法,將會稱為這是一個 非同步工作
若有個方法有使用 async,也就是說,會回傳 Task 或者 Task,例如 async Task<string> MyMethodAsync() 這樣的方法,也就是說,在這個方法內通常會有使用到 await 運算子這個關鍵字,在這裡將會稱為這是一個 非同步方法
底下是微軟文件上對於 async 的說明: 使用 async 修飾詞可將方法、Lambda 運算式或匿名方法指定為非同步。 如果您在方法或運算式上使用這個修飾詞,則它是指「非同步方法」。
await 只能用於 async 關鍵字所修改的非同步方法中。 這種方法是使用 async 修飾詞所定義,且通常包含一或多個 await 運算式,我們稱之為「非同步方法」。
await 運算子會套用至非同步方法中的工作,以在執行方法中插入暫停點,直到等候的工作完成為止。
至於,為什麼會造成這樣的問題,並不是這篇文章要討論的,在這篇文章中,將會說明當某些環境下,一定需要使用同步方式來呼叫各種方法,無法使用 async await 這樣的方式,或者,想不要使用 async await 的方式來呼叫這些非同步 API ;若有這樣的需求,就可以在這篇文章看到各種不同的解決方案,使用同步方式來呼救非同步工作方法,但是卻不會造成執行緒封鎖的問題。

說明要使用的非同步方法程式碼

在這篇文章中的範例,將會使用 WPF 建立起一個專案,並且將會在螢幕上規劃出六個按鈕,每個按鈕皆會有一個 callback 按鈕事件程式碼,當按鈕按下之後,將會要取網路上抓呼叫一個整數相加的 Web API;由於這個呼叫 Web API 的需求將會使用 HttpClient 類別所提供的 GetStringAsync 非同步工作 API,因此,將會設計一個非同步方法,在此方法內使用 await 運算子 ( 當然,在此非同步方法簽章前面要加上 async ),已在執行把這個 HttpClient.GetStringAsync 非同步工作的呼叫中插入暫停點,直到等候該工作完成。
在這裡將會命名這個非同步方法為 MethodAsync,其範例程式碼如下,為了要能夠更清楚的了解到當時執行的執行緒 Thread 為何?因此,會在執行 await 前後加入一個輸出到螢幕上的除錯資訊,這裡將會顯示當前使用的執行緒 ID。這是一個相當單純的非同步方法的設計,也是各位 C# 程式設計師日常會使用到的程式碼。
要這麼設計,是因為要使用 await 來等候一個非同步工作,因此,需要在方法前面加上 async,而又因為回傳值為 string,所以進一步地將 string 回傳型別修改成為 Task<string>
這是一個相當簡單與容易的程式設計方法,可是,接下來就是要探討這麼簡單的方同步方法,在使用上卻會造成專案執行上的災難,也就是造成執行緒打了死結,結果就是該程序無法繼續往下執行下去。
C Sharp / C#
async Task<string> MethodAsync()
{
    Console.WriteLine($"呼叫 MethodAsync 之前,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    //await Task.Delay(1000);
    await new HttpClient().GetStringAsync(url);
    Console.WriteLine($"呼叫 MethodAsync 之後,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    return " @@恭喜你 - MethodAsync 已經執行完畢了@@ ";
}
底下螢幕截圖將會是這個範例程式碼執行的結果,這個範例原始碼可以從 GitHub 取得。

會產生執行緒 Thread 被封鎖 Block 也就是打死結 Deadlock 的用法

在左上角的按鈕,也就是 [直接呼叫非同步方法,會封鎖] 這個按鈕,當按下這個按鈕,將會執行底下程式碼。
這裡並不會在該方法簽章前面加上 async 修飾詞,但是要能夠在這樣的按鈕事件方法內,呼叫一個非同部方法,這個時候,就只能夠使用把非同步方法以同步的方式來呼叫;由於這個非同步方法 MethodAsync() 會回傳 Task<string> 型別,因此,在可以在這個方同步方法上使用 .Result 屬性來取得此非同步方法的回傳結果。
在這裡還是要再度強調,若把非同步方法當作同步方法來呼叫,也就是使用了 Wait() 方法或者使用了 Result 這個屬性,將會造成當前的執行緒進入封鎖 Block 階段,並且會等到非同步方法執行完畢,才會繼續執行下一個 C# 敘述程式碼。
不過,因為這是一個 WPF 的應用程式 (當然, Windows Forms, Web Forms, ASP.NET MVC, Xamarin 類型的專案也會有這樣相同的問題),其該程式在執行的時候,若當前執行的執行緒 Thread 為 主執行緒 或者 UI 執行緒,則該 SynchronizationContext.Current 屬性上是會有值的,而不是 null ;因此,當使用 .Wait() / .Result 這樣的同步方式執行一個非同步方法(注意,這裡指的是非同步方法,不是非同步工作喔,也就是該方法的方法簽章上有加上 async 修飾詞,更技術上的講法,編譯器會針對有 async 的非同步方法,產生許多輔助程式碼,以便完成該方法內的 await 運算子可以插入暫停點以便等候該非同步作業完成),並且當前執行緒為主執行緒,就會造成了主執行緒打死結的情況(各位讀者,你知道為什麼會這樣嗎?歡迎在留言板上寫下你的看法)。
C Sharp / C#
private void BtnWillBlock_Click(object sender, RoutedEventArgs e)
{
    // 請解釋這裡為什麼會產生執行緒封鎖 Block 的狀態
    string result = MethodAsync().Result;
    txtbkMessage.Text = "BtnWillBlock_Click 執行完畢 " + result;
}
所以,當按下這個按鈕,整個程式就凍結了,在 Visual Studio 2019 輸出視窗上,只有看到這個內容輸出。若使用 VS 2019 的除錯工具進行逐步除錯,則會看到當程式執行到 await new HttpClient().GetStringAsync(url); 敘述之後,就再也沒有反映了。
呼叫 MethodAsync 之前,執行緒 ID 1

把非同步方法當作同步方法來使用的解法 1 : 直接呼叫非同步工作,並等候或者取得結果

第一個解法就是如同右上角的按鈕 [直接呼叫非同步工作,為什麼不會封鎖] 的按鈕事件做法,在這裡同樣的在這個按鈕事件中,不要在方法簽章上加上 async 修飾詞,也就是無法在該方法內使用 await 關鍵字,當然,也就使能夠使用同步方法的方式來執行非同步工作或者方法囉。
在此,直接把 非同步方法 MethodAsync() 中用到的 非同步工作 new HttpClient().GetStringAsync(url),直接使用同步方式的呼叫,在此,使用的是 .Wait() 方法。
C Sharp / C#
private void BtnWhyWillNotBlock_Click(object sender, RoutedEventArgs e)
{
    new HttpClient().GetStringAsync(url).Wait();
    txtbkMessage.Text = "BtnWhyWillNotBlock_Click 執行完畢";
}
出乎意外的,這樣的呼叫竟然沒有造成整體程序進入執行緒封鎖狀態,也就是打死結,而是會在螢幕上顯示出 [BtnWhyWillNotBlock_Click 執行完畢,Web API 結果:17] 這樣的內容。
不會造成執行緒封鎖的理由相當的簡單,那就是現在執行的是非同步工作作業,而不是執行非同步非法,因此,不會有 await 這樣插入暫停點與繼續執行的問題。這樣的解釋似乎滿簡單的,不過,理由就是這樣,若想要了解這句話內部的涵義,這可能需要深入到 TPL 以工作為基礎的非同步程式設計 Task-based asynchronous programming 相關知識去了解囉。
總之記得,當前的執行緒有 SynchronizationContext 同步內容,並且有需要要把非同步作業以同步方式來執行,對於非同步工作不會造成問題,然而,對於非同步方法會造成當前執行緒打被封鎖,整體應用程式無法繼續執行。

把非同步方法當作同步方法來使用的解法 2 : 使用 Task.Run 把呼叫非同步方法程式碼包裹起來,並使用 Task.Run 的 Wait() 方法等待作業結束

請點選按鈕 [不會封鎖,使用 Task.Run 包裹起來],可以看到這樣的按鈕事件內的程式碼是不會造成該程序凍結,並且可以從 Visual Studio 2019 輸出 Output 視窗中,看到底下的輸出內容,從這個輸出日誌內容,將會初步了解到整個程序的執行順序。
呼叫 BtnWillNotBlock_Click 之前,執行緒 ID 1
呼叫 await MethodAsync(); 之前,執行緒 ID 3
呼叫 MethodAsync 之前,執行緒 ID 3
呼叫 MethodAsync 之後,執行緒 ID 8
呼叫 await MethodAsync(); 之後,執行緒 ID 8
呼叫 BtnWillNotBlock_Click 之後,執行緒 ID 1
也就是在執行 Task.Run().Result 敘述,使用同步呼叫方式來取得非同步執行結果的前後,所使用的執行緒就是主執行緒,也就是 [執行緒 ID 1]
對於 使用 Task.Run 所傳入的委派方法,這個委派方法是個非同步方法,因為在 Lambda 方法前面有指定 async 修飾詞,因此,可以在此 Lambda 匿名方法內使用 await 運算子關鍵字。但是,在這個委派方法內的所有非同步方法呼叫,是不會造成執行緒封鎖,理由相當的簡單,因為在這個委派方法內,所使用到的執行緒並不是 UI 執行緒,從上面的輸出日誌可以看出,這個時候的執行緒是 執行緒 ID 3 ,不是主執行緒,所以在這裡使用 await 運算子執行非同步方法,是不會有問題的。那麼,能夠理解為什麼這裡委派方法內的程式碼,不是使用到主執行緒嗎?理由也很簡單,那是因為使用了 Task.Run,Task.Run會從執行緒集區 Thread Pool 取得一個執行緒,來執行這個委派方法。
對於執行 Task.Run().Result 敘述也不會造成執行緒封鎖,記得,現在當前的執行緒是 UI執行緒,不過,因為 執行 Task.Run() 是一個非同步工作,不是非同步方法,因此,縱使在主執行下使用同步執行非同步作業,也不會有問題的。
C Sharp / C#
private void btnUsingTaskRunWillNotBlock_Click(object sender, RoutedEventArgs e)
{
    Console.WriteLine($"呼叫 BtnWillNotBlock_Click 之前,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    string result = Task.Run(async () =>
      {
          Console.WriteLine($"呼叫 await MethodAsync(); 之前,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
          string callResult = await MethodAsync();
          Console.WriteLine($"呼叫 await MethodAsync(); 之後,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
          return callResult;
      }).Result;
    Console.WriteLine($"呼叫 BtnWillNotBlock_Click 之後,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    txtbkMessage.Text = "BtnWillBlock_Click 執行完畢 " + result; 
}

把非同步方法當作同步方法來使用的解法 3 : 建立一個新的執行緒,以便可以使用同步方式來呼叫非同步方法

請點選按鈕 [不會封鎖,使用 新的執行緒 包裹起來],可以看到這樣的按鈕事件內的程式碼是不會造成該程序凍結,並且可以從 Visual Studio 2019 輸出 Output 視窗中,看到底下的輸出內容,從這個輸出日誌內容,將會初步了解到整個程序的執行順序。
呼叫 await MethodAsync(); 之前,執行緒 ID 3
呼叫 MethodAsync 之前,執行緒 ID 3
呼叫 MethodAsync 之後,執行緒 ID 10
呼叫 await MethodAsync(); 之後,執行緒 ID 3
0x3784 執行緒以返回碼 0 (0x0) 結束。
呼叫 BtnUsingNewThreadWillNotBlock_Click 之後,執行緒 ID 1
其實,從上一個做法 [使用 Task.Run 把呼叫非同步方法程式碼包裹起來,並使用 Task.Run 的 Wait() 方法等待作業結束] 可以得到一個精神,那就是只要當前的執行緒不是主執行緒或者UI執行緒,這樣來使用 await 來執行非同步方法的話,就不會造成執行緒封鎖。
所以,在這裡將會建立一個執行緒(這是模擬 Task.Run 的作法),在該執行緒的委派方法內來執行非同步方法,不過,這裡有兩點要特別注意的,第一個是,需要使用 Thread.Join() 方法來等待執行緒結束,因為執行緒結束執行了,就可以得到非同步方法的執行結果;第二個是想要取得執行緒內執行結果,需要透過共用變數,在這裡將會使用一個區域變數 result 來做到;最後,因為該執行緒使用的是 new Thread 產生一個執行緒物件,所以,需要手動啟動執行該執行緒,否則,該執行緒是不會執行的。
C Sharp / C#
private void BtnUsingNewThreadWillNotBlock_Click(object sender, RoutedEventArgs e)
{
    string result = "";
    Thread thread = new Thread(async x =>
    {
        Console.WriteLine($"呼叫 await MethodAsync(); 之前,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
        result = MethodAsync().Result;
        Console.WriteLine($"呼叫 await MethodAsync(); 之後,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    })
    { IsBackground = true };
    thread.Start();
    thread.Join();
    Console.WriteLine($"呼叫 BtnUsingNewThreadWillNotBlock_Click 之後,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    txtbkMessage.Text = "BtnUsingNewThreadWillNotBlock_Click 執行完畢 " + result;
}

把非同步方法當作同步方法來使用的解法 4 : 自行重新設定 SynchronizationContext,以便可以使用同步方式來呼叫非同步方法

請點選按鈕 [不會封鎖,使用重新設定 ResetSynchronizationContext],可以看到這樣的按鈕事件內的程式碼是不會造成該程序凍結,並且可以從 Visual Studio 2019 輸出 Output 視窗中,看到底下的輸出內容,從這個輸出日誌內容,將會初步了解到整個程序的執行順序。
呼叫 BtnResetSynchronizationContextWillNotBlock_Click 之前,執行緒 ID 1
呼叫 MethodAsync 之前,執行緒 ID 1
呼叫 MethodAsync 之後,執行緒 ID 7
呼叫 BtnResetSynchronizationContextWillNotBlock_Click 之後,執行緒 ID 1
前面的兩種解法個人認為有一個滿嚴重的問題,那就每次要使用同步方式來呼叫非同步方法的時候,都需要花費一個額外的執行緒來避免主執行緒不會造成封鎖的狀態,對於某些開發框架之下,例如:ASP.NET MVC 的專案下,過度消耗執行緒,並不是一個很好的做法,那會造成整體網站的可用執行緒減少,因此,需要找出另外一個比較好的解法。
現在,可以使用底下的作法,雖然會增加一些額外的程式碼,不過卻不需要額外花費一個執行緒來解決執行緒封鎖的問題;當想要使用同步方式來呼叫非同步方法的時候,先使用 SynchronizationContext.Current 屬性取得當前執行緒(主執行緒或者 Request 執行緒)的 SynchronizationContext,將其儲存起來,接著將 SynchronizationContext 物件儲存起來,接著使用 SynchronizationContext.SetSynchronizationContext(null) 將 SynchronizationContext 設定為空值,也就是不存在,此時,就可以放心的使用同步方式來呼叫非同步方法或者非同步工作了,這樣的作法是不會造成執行緒封鎖的。在完成以同步方式呼叫非同步方法的時候,就可以使用 SynchronizationContext.SetSynchronizationContext(synchronizationContext) 將剛剛儲存起來的 SynchronizationContext 物件,設定到當前執行緒上。
C Sharp / C#
private void BtnResetSynchronizationContextWillNotBlock_Click(object sender, RoutedEventArgs e)
{
    Console.WriteLine($"呼叫 BtnResetSynchronizationContextWillNotBlock_Click 之前,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    SynchronizationContext synchronizationContext = SynchronizationContext.Current;
    SynchronizationContext.SetSynchronizationContext(null);
    string result = MethodAsync().Result;
    SynchronizationContext.SetSynchronizationContext(synchronizationContext);
    Console.WriteLine($"呼叫 BtnResetSynchronizationContextWillNotBlock_Click 之後,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    txtbkMessage.Text = "BtnResetSynchronizationContextWillNotBlock_Click 執行完畢 " + result;
}

把非同步方法當作同步方法來使用的解法 5 : 設計一個泛型類別,以便可以使用同步方式來呼叫非同步方法

請點選按鈕 [不會封鎖,使用 Task Library],可以看到這樣的按鈕事件內的程式碼是不會造成該程序凍結,並且可以從 Visual Studio 2019 輸出 Output 視窗中,看到底下的輸出內容,從這個輸出日誌內容,將會初步了解到整個程序的執行順序。
呼叫 BtnUsingTaskLibraryWillNotBlock_Click 之前,執行緒 ID 1
呼叫 MethodAsync 之前,執行緒 ID 3
呼叫 MethodAsync 之後,執行緒 ID 8
呼叫 BtnUsingTaskLibraryWillNotBlock_Click 之後,執行緒 ID 1
這裡的作法與使用 Task.Run 的做法有些類似,其實都是使用額外新的執行緒來做到可以使用同步方式來呼叫非同步方法,現在將會建立一個泛型方法 InvokeAsyncMethod,在這裡會使用 Task.Factory.StartNew 來建立一個新的非同步工作,把傳入進來的非同步委派方法,使用 .Unwrap().GetAwaiter().GetResult() 來等待非同步方法的結束執行。
使用的方式可以像是這個按鈕事件中的用法 : InvokeAsyncMethod(() => MethodAsync()) 直接把非同步方法轉換成為同步方式呼叫,但是沒有使用到任何 Wait() 方法或者 Result 屬性。
C Sharp / C#
private void BtnUsingTaskLibraryWillNotBlock_Click(object sender, RoutedEventArgs e)
{
    Console.WriteLine($"呼叫 BtnUsingTaskLibraryWillNotBlock_Click 之前,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    string result = InvokeAsyncMethod(() => MethodAsync());
    Console.WriteLine($"呼叫 BtnUsingTaskLibraryWillNotBlock_Click 之後,執行緒 ID {Thread.CurrentThread.ManagedThreadId}");
    txtbkMessage.Text = "BtnUsingTaskLibraryWillNotBlock_Click 執行完畢 " + result;
}

T InvokeAsyncMethod<T>(Func<Task<T>> func)
{
    return Task.Factory.StartNew(func)
        .Unwrap()
        .GetAwaiter()
        .GetResult();
}





2 則留言: