其中有個和先前範例不一樣的地方,是用比較新的
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 程式鎖死?
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
底下的程式碼。如下所示:
這通常意味著,在
await
關鍵字所修飾的非同步工作完成後,要繼續恢復執行原先暫停的程式碼區塊時,會以另一條執行緒來完成這個後續工作。
解法二:從頭到尾都使用非同步方法
這個解法更好,也就是從 controller 開始就採用非同步方法。如下所示:
也就是說,從頭到尾都使用非同步等待,因此也就不至於有卡住並互相等待對方的情形出現了。
無恥連結:試閱或購買本書 ^_^