ASP.NET 程式鎖死與 SynchronizationContext

上次介紹了 C# 的 async 與 await 語法,這次要來看一個讓 ASP.NET 程式當掉的小實驗。

註:本文摘自《.NET 本事-非同步程式設計》第三章。文中說的「上一節」,指的是上一篇文章
2019-01-08 隨電子書更新:請注意,本節範例僅適用於傳統的 ASP.NET 4.x。由於 ASP.NET Core 框架已經不再使用 SynchronizationContext,所以並不會有本文範例所提到的現象。也就是說,若將本節範例程式貼到 ASP.NET Core 專案中,執行時並不會出現鎖死的狀況。如果你只在意 ASP.NET Core,可暫且跳過本節。

ASP.NET 程式當掉了!

有一條  .NET 非同步程式設計的準則是這麼說的:盡量從頭到尾都一致,亦即一旦使用了非同步呼叫,最好一路都是非同步,而不要中途由非同步呼叫改成同步(阻斷式)呼叫,例如使用 Task.WaitTask.Result,因為那可能會讓你的程式鎖死(當掉)。

這裡參照上一節的 Console 應用程式範例,依樣畫葫蘆,把它改成像底下的 ASP.NET Web API 應用程式,看看結果會怎麼樣。

此範例程式的專案:Ch03/Ex06_AspNetAppDeadlock.csproj

其中有個和先前範例不一樣的地方,是用比較新的 HttpClient 類別來取代 WebClient

當你實際執行此應用程式,並以瀏覽器開啟網址 http://<主機名稱>/api/DemoDeadlock 時,會發現網頁像當掉一樣,等了老半天都沒有任何回應。因為此時這個 ASP.NET 應用程式已經鎖死(deadlock)了。

為什麼會鎖死呢?

欲解答這個問題,我們必須在深入一些細節。

SynchronizationContext

先說一個基礎觀念:對於像 Windows Forms 或 ASP.NET 這類有 UI(使用者介面)的應用程式,任何與 UI 相關的操作(例如更新某個 TextBox 的文字內容)都必須回到 UI 執行緒上面進行。

拿 Windows Forms 來說吧,當某個背景執行緒的工作已經返回結果,而我們想要將此結果更新於 UI 時,就必須撰寫額外的程式碼,呼叫控制項的 Invoke 方法 來將控制流切換至 UI 執行緒,以確保在 UI 執行緒上面進行更新 UI 的操作。

asyncawait 的一個好處便在於它使用了 SynchronizationContext 來確保 await 之後的延續工作總是在呼叫 await 敘述之前的同步環境中執行。如此一來,在任何非同步方法中需要更新 UI 時,我們就不用額外寫程式碼來切換至 UI 執行緒了。那麼,什麼是 SynchronizationContext 呢?

這裡的 SynchronizationContextSystem.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 程式鎖死?


請注意第 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 開始就採用非同步方法。如下所示:


也就是說,從頭到尾都使用非同步等待,因此也就不至於有卡住並互相等待對方的情形出現了。

無恥連結:試閱或購買本書  ^_^

參考資料

ASP.NET 程式鎖死與 SynchronizationContext ASP.NET 程式鎖死與 SynchronizationContext Reviewed by Michael Tsai on 1/08/2019 Rating: 5
技術提供:Blogger.