上次介紹了 C# 的 async 與 await 語法,這次要來看一個讓 ASP.NET 程式當掉的小實驗。
讓我們再做一個小實驗,把先前的 Console 應用程式範例改成像底下的 ASP.NET Web API 應用程式,看看結果會怎麼樣。
此範例程式的專案:Ch03/Ex06_AspNetAppDeadlock.csproj
然而,先前的第 7 行所在的執行緒已經進入等待狀態,亦即當時的
解法一:使用
解決方法之一,可以呼叫
這通常意味著,在
要提醒的是,使用
也就是說,從頭到尾都使用非同步等待,因此也就不至於有卡住並互相等待對方的情形出現了。
當你按下 button1,應用程式就會當掉。
你可能注意到了,這裡的事件處理常式
解法二:
如果你不滿意剛才的解法,另一個選擇是使用
說明:
解法三:
再看另一種解法:
注意第 5 行程式碼,在呼叫
以上介紹的幾種解決 UI 執行緒鎖死的方法,若沒有特殊原因,我會優先選擇簡單易懂的解法,也就是盡量採用 C# 提供的
摘錄內容到此結束。
無恥連結:試閱或購買本書
註:本文摘自《.NET 本事-非同步程式設計》第三章。文中若有提到「上一節」,指的是上一篇文章。文章內容已針對部落格單篇文章的編排而調整內容,2020-03-18 隨電子書更新:潤飾、補充,使內容更完整、精確,且符合 ASP.NET Core 。另外增加了 Windows Forms 應用程式的 deadlock 範例。
故與電子書有些出入。
ASP.NET 程式當掉了!
.NET 非同步程式設計有個通則:盡量從頭到尾都一致,亦即一旦使用了非同步呼叫,最好一路都是非同步,而不要中途由非同步呼叫改成同步(阻斷式)呼叫,例如使用Task.Wait
或 Task.Result
,因為那可能會讓你的程式鎖死(當掉)。讓我們再做一個小實驗,把先前的 Console 應用程式範例改成像底下的 ASP.NET Web API 應用程式,看看結果會怎麼樣。
👉 請注意,本文範例僅適用於傳統的 ASP.NET 4.x。若將本節範例程式放到 ASP.NET Core 環境,執行時並不會出現鎖死的狀況。
此範例程式的專案:Ch03/Ex06_AspNetAppDeadlock.csproj
其中有個和先前範例不一樣的地方,是用比較新的
當你實際執行此應用程式,並以瀏覽器開啟網址
為什麼會鎖死呢?
欲解答這個問題,我們必須在深入一些細節。
先說一個準則:對於像 Windows Forms、WPF、和 ASP.NET 這類有 UI(使用者介面)的應用程式,任何與 UI 相關的操作(例如更新某個 TextBox 的文字內容)都必須回到 UI 執行緒上面進行。
就拿 WPF 應用程式來說吧,當某個背景執行緒的工作已經返回結果,而我們想要將此結果顯示於 UI 物件時,就必須想辦法回到 UI 物件所在的執行緒(主執行緒)上面來進行。像這種情形,我們就說 UI 物件對特定執行緒有黏著性,也就是所謂的「執行緒黏著性」(thread affinity)。
那麼,什麼是
這裡的
讀過前面幾個小節,你已經知道當我們使用
說得更明確些,這裡會利用
在 Console 應用程式中,
除了 Console 應用程式,上述提及的各類 UI 應用程式的
請注意第 7 行是個阻斷式操作(純粹為了示範,並非建議寫法),也就是控制流會停在那裡,等到非同步工作完成並返回,才能繼續往下執行。這裡等待的是
HttpClient
類別來取代 WebClient
。當你實際執行此應用程式,並以瀏覽器開啟網址
http://<主機名稱>/api/DemoDeadlock
時,會發現網頁像當掉一樣,等了老半天都沒有任何回應。因為此時這個 ASP.NET 應用程式已經鎖死(deadlock)了。為什麼會鎖死呢?
欲解答這個問題,我們必須在深入一些細節。
SynchronizationContext
先說一個準則:對於像 Windows Forms、WPF、和 ASP.NET 這類有 UI(使用者介面)的應用程式,任何與 UI 相關的操作(例如更新某個 TextBox 的文字內容)都必須回到 UI 執行緒上面進行。就拿 WPF 應用程式來說吧,當某個背景執行緒的工作已經返回結果,而我們想要將此結果顯示於 UI 物件時,就必須想辦法回到 UI 物件所在的執行緒(主執行緒)上面來進行。像這種情形,我們就說 UI 物件對特定執行緒有黏著性,也就是所謂的「執行緒黏著性」(thread affinity)。
async
和 await
的一個好處便在於它使用了 SynchronizationContext
來確保 await
之後的延續工作總是在呼叫 await
敘述之前的同步環境中執行。如此一來,在任何非同步方法中需要更新 UI 時,我們就不用額外寫程式碼來切換至 UI 執行緒了。那麼,什麼是
SynchronizationContext
呢? 這裡的
SynchronizationContext
是 System.Threading
命名空間裡的一個類別,它代表了當時的同步環境,其用途在於簡化非同步工作之間的執行緒切換操作。讀過前面幾個小節,你已經知道當我們使用
await
來等待某個非同步工作時,await
會把當時所在的程式碼區塊一分為二,並記住當時所在的位置,以便等到非同步工作完成時能夠再恢復並繼續執行後半部的程式碼。這個「記住當時所在的位置」,其實就是捕捉當時所在的執行緒環境(context)。說得更明確些,這裡會利用
SynchronizationContext.Current
屬性來取得當下的環境資訊:若它不是 null
,就會以它作為當前的環境資訊;若是 null
,則會以當前的 TaskScheduler
(工作排程器)物件來決定其後續的執行緒環境。換言之,這個「環境資訊」其實就是保留了先前同步區塊所在的執行緒環境(所以說成「同步環境」也行),以便在 await
所等待的非同步工作完成之後,能夠恢復到原始的(先前的)同步環境中繼續執行後續的工作。具體來說,這個「回到原始的同步環境中繼續執行後續的工作」要如何達成呢?一般的情況下,我們不太需要自行處理這個問題,若真的需要,則可以透過SynchronizationContext
的Post
方法。至於Post
方法要怎麼使用,這裡先不細說,稍後有一個 Windows Forms 的範例就會看到它的用法。
在 Console 應用程式中,
SynchronizationContext.Current
必為 null
,所以在碰到 await
關鍵字時,會使用當前的 TaskScheduler
物件來決定後續的執行緒環境。而預設的 TaskScheduler
會使用執行緒集區(thread pool)來安排工作。這也就解釋了,為什麼先前的〈觀察執行緒切換過程〉一節中的程式執行結果,await
敘述之後的程式碼會執行於另一條執行緒。但請注意,依執行緒集區內部的演算法而定,有時候它認為使用新的執行緒會更更有效率,有時則可能會決定使用既有的執行緒。SynchronizationContext
類別有一些虛擬方法,子類別可以改寫它們,以符合特定類型的應用程式。.NET 則會根據應用程式的類型來自動指派適當的 SynchronizationContext
類型。如果是 WPF 應用程式,執行緒所關聯的環境資訊會是 DispatcherSynchronizationContext
類型的物件。如果是 Windows Forms 應用程式,則為 WindowsFormsSynchronizationContext
。
傳統的 ASP.NET 4.x 應用程式有 AspNetSynchronizationContext
,但是到了 ASP.NET Core 時代則沒有這個類別,因為已經不需要它了。就如稍早提過的,本節範例程式的寫法,在 ASP.NET Core 上面並不會有鎖死的問題。
除了 Console 應用程式,上述提及的各類 UI 應用程式的
SynchronizationContext
物件都有一個限制:一次只能等待一個同步區塊的程式碼——這句話有點抽象,我們在下一節用程式碼來理解。鎖死的原因與解法
現在讓我們來試著回答前面的問題:為什麼底下的寫法會令 ASP.NET 程式鎖死?請注意第 7 行是個阻斷式操作(純粹為了示範,並非建議寫法),也就是控制流會停在那裡,等到非同步工作完成並返回,才能繼續往下執行。這裡等待的是
MyDownloadPageAsync
非同步方法,而此方法裡面有個 await
敘述(倒數第四行)。如上個小節提過的,碰到 await
,便會嘗試取得當前的同步環境,而 ASP.NET 應用程式的同步環境是個 AspNetSynchronizationContext
類型的物件。然而,先前的第 7 行所在的執行緒已經進入等待狀態,亦即當時的
SynchronizationContext
所關聯的執行緒已經卡住了,正在等待 MyDownloadPageAsync
完成之後才能繼續執行。此時,當 MyDownloadPageAsync
裡面的 await
敘述所等待的非同步工作已經返回,並準備使用先前獲取的 SynchronizationContext
物件來繼續執行剩下的程式碼時,由於當前的 SynchronizationContext
物件已經被占用,便只能等待它被用完後釋放(即稍早提過的「一次只能等待一個同步區塊的程式碼」)。如此一來,便產生了兩邊互相等待的情形——程式鎖死。
解法一:使用 ConfigureAwait(false)
解決方法之一,可以呼叫 Task
類別的 ConfigureAwait
方法。此方法接受一個 bool
型別的參數 continueOnCapturedContext
,若為 false
,即可指定某個非同步工作「不要」使用先前獲取的 SynchronizationContext
來繼續恢復執行 await
底下的程式碼。如下所示:這通常意味著,在
await
關鍵字所修飾的非同步工作完成後,要繼續恢復執行原先暫停的程式碼區塊時,會以另一條執行緒來完成這個後續工作。要提醒的是,使用
ConfigureAwait(false)
並非解決此問題的最佳方法,而且它有個副作用:你可能會需要為一連串的非同步呼叫都加上 ConfigureAwait(false)
。最好的辦法,就是不要阻斷(block)非同步呼叫,也就是從頭到尾都保持非同步呼叫。解法二:從頭到尾都使用非同步方法
此問題的正確解法是:從頭到尾都使用非同步呼叫,也就是從 controller 開始就採用非同步方法。如下所示:也就是說,從頭到尾都使用非同步等待,因此也就不至於有卡住並互相等待對方的情形出現了。
我的程式當掉了—— Windows Forms 範例
前述範例程式的鎖死問題,如果你想看看 Windows Forms 的版本,可以用以下程式碼來實驗:當你按下 button1,應用程式就會當掉。
解法一:從頭到尾都使用非同步方法
最簡單的解法就是前面提過的,讓程式碼從頭到尾都採用非同步呼叫。只要在 button1 的按鈕事件處理常式前面加上async
關鍵字,然後在函式裡面用 await
來取得 GetStringAsync
方法的執行結果就行了。如下所示:你可能注意到了,這裡的事件處理常式
button1_Click
是宣告成 async void
。前面提過,非同步方法如果不需要回傳執行結果給呼叫端,應宣告為 async Task
,而不是 async void
。不過,事件處理常式算是個特例。以這個範例程式來說,這樣的解法雖不是頂漂亮,但寫法簡單,也不至於產生嚴重的副作用。
解法二:SynchronizationContext
的 Post
方法
如果你不滿意剛才的解法,另一個選擇是使用 SynchronizationContext
的 Post
方法。如下所示:說明:
- 第 1 行:現在
button1_Click
恢復成原本的樣子,沒有加上async
方法,當然在函式裡面就不可能使用await
來取得非同步工作的結果了。 - 第 3 行:先取得當前的同步執行環境,保存於變數
uiContext
。 - 第 5 行透過
Task.ContinueWith()
方法來接續非同步工作完成之後的處理,而這接續的處理就寫在第 6~11 行的委派裡面。 - 第 7 行利用
SynchronizationContext
的Post
方法來確保傳入的委派方法會回到 UI 執行緒上面執行。
Task.ContinueWith()
多載方法有許多版本,請參考線上說明文件以獲取更完整的資訊。本書第 4 章也會介紹 ContinueWith
的用法。
解法三:ContinueWith
搭配 TaskScheduler
再看另一種解法:TaskScheduler
類別的靜態方法 FromCurrentSynchronizationContext
會從當前的同步環境取得工作排程器,而這個排程器在執行其佇列中的工作時,便是執行於它所屬的那個同步環境。因此,我們可以在 UI 執行緒上面呼叫 FromCurrentSynchronizationContext
方法來取得工作排程器,然後透過它來更新 UI,便安全無虞了。注意第 5 行程式碼,在呼叫
Task
的 ContinueWidth
方法來指定接續工作時有傳入 uiScheduler
,也就是先前取得的那個在 UI 執行緒上面的工作排程器。因此,ContinueWidth
方法所建立的工作便會由我們指定的排程器來執行,也就是執行於 UI 執行緒。以上介紹的幾種解決 UI 執行緒鎖死的方法,若沒有特殊原因,我會優先選擇簡單易懂的解法,也就是盡量採用 C# 提供的
async
與 await
語法。
摘錄內容到此結束。
無恥連結:試閱或購買本書
參考資料
- Don't Block on Async Code by Stephen Cleary
- Concurrency in C# Cookbook by Stephen Cleary
- Programming C# 8.0 by Ian Griffiths
- Essential C# 5.0 by Mark Michaelis
- Should I expose synchronous wrappers for asynchronous methods? by Stephen Toub