其中有個和先前範例不一樣的地方,是用比較新的
HttpClient 類別來取代
WebClient。
當你實際執行此應用程式,並以瀏覽器開啟網址
http://<主機名稱>/api/DemoDeadlock 時,會發現網頁像當掉一樣,等了老半天都沒有任何回應。因為此時這個 ASP.NET 應用程式已經鎖死(deadlock)了。
為什麼會鎖死呢?
欲解答這個問題,我們必須在深入一些細節。
SynchronizationContext
先說一個基礎觀念:對於像 Windows Forms 或 ASP.NET 這類有 UI(使用者介面)的應用程式,任何與 UI 相關的操作(例如更新某個 TextBox 的文字內容)都必須回到 UI 執行緒上面進行。
拿 Windows Forms 來說吧,當某個背景執行緒的工作已經返回結果,而我們想要將此結果更新於 UI 時,就必須撰寫額外的程式碼,呼叫
控制項的 Invoke 方法 來將控制流切換至 UI 執行緒,以確保在 UI 執行緒上面進行更新 UI 的操作。
async 和
await 的一個好處便在於它使用了
SynchronizationContext 來確保
await 之後的延續工作總是在呼叫
await 敘述之前的同步環境中執行。如此一來,在任何非同步方法中需要更新 UI 時,我們就不用額外寫程式碼來切換至 UI 執行緒了。那麼,什麼是
SynchronizationContext 呢?
這裡的
SynchronizationContext 是
System.Threading 命名空間裡的一個類別,它代表了當時的同步環境資訊,其用途在於簡化非同步工作之間的執行緒切換操作。
讀過前面幾個小節,你已經知道當我們使用
await 來等待某個非同步工作時,
await 會把當時所在的程式碼區塊一分為二,並記住當時所在的位置,以便等到非同步工作完成時能夠再恢復並繼續執行後半部的程式碼。這個「記住當時所在的位置」,其實就是捕捉當時所在的執行緒環境(context)。
說得更明確些,這裡會利用
SynchronizationContext.Current 屬性來取得當下的環境資訊:若它不是
null,就會以它作為當前的環境資訊;若是
null,則會以當前的
TaskScheduler(工作排程器)物件來決定其後續的執行緒環境。換言之,這個「環境資訊」其實就是保留了先前同步區塊所在的執行緒環境(所以說成「同步環境」也行),以便在
await 所等待的非同步工作完成之後,能夠恢復到原始的(先前的)同步環境中繼續執行後續的程式碼。
在 Console 應用程式中,
SynchronizationContext.Current 必為
null,所以在碰到
await 關鍵字時,會使用當前的
TaskScheduler 物件來決定後續的執行緒環境。而預設的
TaskScheduler 會使用執行緒集區(thread pool)來安排工作。這也就解釋了,為什麼先前的〈觀察執行緒切換過程〉一節中的程式執行結果,
await 敘述之後的程式碼會執行於另一條執行緒。但請注意,依執行緒集區內部的演算法而定,有時候它認為使用新的執行緒會更更有效率,有時則可能會決定使用既有的執行緒。
.NET 會根據應用程式的類型來自動設定適當的
SynchronizationContext 物件。如果是 ASP.NET 應用程式,執行緒所關聯的環境資訊會是
AspNetSynchronizationContext 類型的物件。如果是 WPF 應用程式,執行緒所關聯的環境資訊則會是
DispatchSynchronizationContext 物件。如果是 Windows Forms 應用程式,則為
WindowsFormsSynchronizationContext。
除了 Console 應用程式,上述提及的各類 UI 應用程式的
SynchronizationContext 物件都有一個限制:一次只能等待一個同步區塊的程式碼——這句話有點抽象,我們在下一節用程式碼來理解。
鎖死的原因與解法
現在讓我們來試著回答前面的問題:為什麼底下的寫法會令 ASP.NET 程式鎖死?
1 public class DemoDeadlockController : ApiController
2 {
3 [HttpGet]
4 public HttpResponseMessage DownloadPage()
5 {
6 var task = MyDownloadPageAsync("http://huan-lin.blogspot.com");
7 var content = task.Result;
8 return Request.CreateResponse(string.Format("網頁長度: {0}", content.Length));
9 }
10
11 private async Task<string> MyDownloadPageAsync(string url)
12 {
13 var client = new HttpClient();
14 var task = client.GetStringAsync(url);
15 // 這裡會獲取當前的 SynchronizationContext
16 string content = await task; // 這裡在 task 完成後,會 deadlock!
17 return content;
18 }
19 }
note: 如果你有親自實驗過本章前面的範例程式,並且了解 await 對控制流所產生的作用,那麼接下來的說明就不至於太難理解。
請注意第 7 行是個阻斷式操作,也就是控制流會停在那裡,等到非同步工作完成並返回,才能繼續往下執行。這裡等待的是
MyDownloadPageAsync 非同步方法,而此方法裡面有個
await 敘述(倒數第四行)。如上個小節提過的,碰到
await,便會嘗試取得當前的同步環境,而 ASP.NET 應用程式的同步環境是個
AspNetSynchronizationContext 類型的物件。
然而,先前的第 7 行所在的執行緒已經進入等待狀態,亦即當時的
SynchronizationContext 所關聯的執行緒已經卡住了,正在等待
MyDownloadPageAsync 完成之後才能繼續執行。此時,當
MyDownloadPageAsync 裡面的
await 敘述所等待的非同步工作已經返回,並準備使用先前獲取的
SynchronizationContext 物件來繼續執行剩下的程式碼時,由於當前的
SynchronizationContext 物件已經被占用,便只能等待它被用完後釋放。如此一來,便產生了兩邊互相等待的情形——程式鎖死。
解法一:使用 ConfigureAwait(false)
解決方法之一,可以呼叫
Task 類別的
ConfigureAwait 方法。此方法接受一個
bool 型別的參數
continueOnCapturedContext,若為
false,即可指定某個非同步工作「不要」使用先前獲取的
SynchronizationContext 來繼續恢復執行
await 底下的程式碼。如下所示:
1 private async Task<string> MyDownloadPageAsync(string url)
2 {
3 var client = new HttpClient();
4 string content = await client.GetStringAsync(url).ConfigureAwait(false);
5 return content;
6 }
這通常意味著,在
await 關鍵字所修飾的非同步工作完成後,要繼續恢復執行原先暫停的程式碼區塊時,會以另一條執行緒來完成這個後續工作。
解法二:從頭到尾都使用非同步方法
這個解法更好,也就是從 controller 開始就採用非同步方法。如下所示:
1 public class DemoDeadlockController : ApiController
2 {
3 [HttpGet]
4 public HttpResponseMessage DownloadPage()
5 public async Task DownloadPageAsync()
6 {
7 var task = MyDownloadPageAsync("http://huan-lin.blogspot.com");
8 var content = await task; // 這裡一樣採用非同步等待。
9 return Request.CreateResponse(string.Format("網頁長度: {0}", content.Length));
10 }
11
12 private async Task MyDownloadPageAsync(string url)
13 {
14 using (var client = new HttpClient()) // 先前範例都省略 using,這裡補上。
15 {
16 string content = await client.GetStringAsync(url);
17 return content;
18 }
19 }
20 }
也就是說,從頭到尾都使用非同步等待,因此也就不至於有卡住並互相等待對方的情形出現了。
無恥連結:試閱或購買本書 ^_^