.NET 程式鎖死與 SynchronizationContext

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

註:本文摘自《.NET 本事-非同步程式設計》第三章。文中若有提到「上一節」,指的是上一篇文章。文章內容已針對部落格單篇文章的編排而調整內容,
故與電子書有些出入。
2020-03-18 隨電子書更新:潤飾、補充,使內容更完整、精確,且符合 ASP.NET Core 。另外增加了 Windows Forms 應用程式的 deadlock 範例。

ASP.NET 程式當掉了!

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

讓我們再做一個小實驗,把先前的 Console 應用程式範例改成像底下的 ASP.NET Web API 應用程式,看看結果會怎麼樣。
👉 請注意,本文範例僅適用於傳統的 ASP.NET 4.x。若將本節範例程式放到 ASP.NET Core 環境,執行時並不會出現鎖死的狀況。

public class DemoDeadlockController : ApiController
{
[HttpGet]
public HttpResponseMessage DownloadPage()
{
var task = MyDownloadPageAsync("https://www.huanlintalk.com");
var content = task.Result;
return Request.CreateResponse($"網頁長度: {content.Length}");
}
private static HttpClient _httpClient = new HttpClient();
private async Task<string> MyDownloadPageAsync(string url)
{
string content = await _httpClient.GetStringAsync(url);
return content;
}
}
此範例程式的專案:Ch03/Ex06_AspNetAppDeadlock.csproj

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

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

為什麼會鎖死呢?

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

SynchronizationContext

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

就拿 WPF 應用程式來說吧,當某個背景執行緒的工作已經返回結果,而我們想要將此結果顯示於 UI 物件時,就必須想辦法回到 UI 物件所在的執行緒(主執行緒)上面來進行。像這種情形,我們就說 UI 物件對特定執行緒有黏著性,也就是所謂的「執行緒黏著性」(thread affinity)。

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

那麼,什麼是 SynchronizationContext 呢?

這裡的 SynchronizationContextSystem.Threading 命名空間裡的一個類別,它代表了當時的同步環境,其用途在於簡化非同步工作之間的執行緒切換操作。

讀過前面幾個小節,你已經知道當我們使用 await 來等待某個非同步工作時,await 會把當時所在的程式碼區塊一分為二,並記住當時所在的位置,以便等到非同步工作完成時能夠再恢復並繼續執行後半部的程式碼。這個「記住當時所在的位置」,其實就是捕捉當時所在的執行緒環境(context)。

說得更明確些,這裡會利用 SynchronizationContext.Current 屬性來取得當下的環境資訊:若它不是 null,就會以它作為當前的環境資訊;若是 null,則會以當前的 TaskScheduler(工作排程器)物件來決定其後續的執行緒環境。換言之,這個「環境資訊」其實就是保留了先前同步區塊所在的執行緒環境(所以說成「同步環境」也行),以便在 await 所等待的非同步工作完成之後,能夠恢復到原始的(先前的)同步環境中繼續執行後續的工作。
具體來說,這個「回到原始的同步環境中繼續執行後續的工作」要如何達成呢?一般的情況下,我們不太需要自行處理這個問題,若真的需要,則可以透過 SynchronizationContextPost 方法。至於 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 程式鎖死?

public class DemoDeadlockController : ApiController
{
[HttpGet]
public HttpResponseMessage DownloadPage()
{
var task = MyDownloadPageAsync("https://www.huanlintalk.com");
var content = task.Result;
return Request.CreateResponse($"網頁長度: {content.Length}");
}
private static HttpClient _httpClient = new HttpClient();
private async Task&lt;string&gt; MyDownloadPageAsync(string url)
{
var task = _httpClient.GetStringAsync(url);
// 這裡會獲取當前的 SynchronizationContext
string content = await task; // 這裡在 task 完成後,會 deadlock!
return content;
}
}

請注意第 7 行是個阻斷式操作(純粹為了示範,並非建議寫法),也就是控制流會停在那裡,等到非同步工作完成並返回,才能繼續往下執行。這裡等待的是 MyDownloadPageAsync 非同步方法,而此方法裡面有個 await 敘述(倒數第四行)。如上個小節提過的,碰到 await,便會嘗試取得當前的同步環境,而 ASP.NET 應用程式的同步環境是個 AspNetSynchronizationContext 類型的物件。

然而,先前的第 7 行所在的執行緒已經進入等待狀態,亦即當時的 SynchronizationContext 所關聯的執行緒已經卡住了,正在等待 MyDownloadPageAsync 完成之後才能繼續執行。此時,當 MyDownloadPageAsync 裡面的 await 敘述所等待的非同步工作已經返回,並準備使用先前獲取的 SynchronizationContext 物件來繼續執行剩下的程式碼時,由於當前的 SynchronizationContext 物件已經被占用,便只能等待它被用完後釋放(即稍早提過的「一次只能等待一個同步區塊的程式碼」)。如此一來,便產生了兩邊互相等待的情形——程式鎖死。

解法一:使用 ConfigureAwait(false)

解決方法之一,可以呼叫 Task 類別的 ConfigureAwait 方法。此方法接受一個 bool 型別的參數 continueOnCapturedContext,若為 false,即可指定某個非同步工作「不要」使用先前獲取的 SynchronizationContext 來繼續恢復執行 await 底下的程式碼。如下所示:

private async Task<string> MyDownloadPageAsync(string url)
{
var client = new HttpClient();
string content = await client.GetStringAsync(url).ConfigureAwait(false);
return content;
}
這通常意味著,在 await 關鍵字所修飾的非同步工作完成後,要繼續恢復執行原先暫停的程式碼區塊時,會以另一條執行緒來完成這個後續工作。

要提醒的是,使用 ConfigureAwait(false) 並非解決此問題的最佳方法,而且它有個副作用:你可能會需要為一連串的非同步呼叫都加上 ConfigureAwait(false) 。最好的辦法,就是不要阻斷(block)非同步呼叫,也就是從頭到尾都保持非同步呼叫。

解法二:從頭到尾都使用非同步方法

此問題的正確解法是:從頭到尾都使用非同步呼叫,也就是從 controller 開始就採用非同步方法。如下所示:

public class DemoDeadlockController : ApiController
{
[HttpGet]
public async Task DownloadPageAsync()
{
var task = MyDownloadPageAsync("https://www.huanlintalk.com");
var content = await task; // 這裡一樣採用非同步等待。
return Request.CreateResponse($"網頁長度: {content.Length}");
}
private static HttpClient _httpClient = new HttpClient();
private async Task MyDownloadPageAsync(string url)
{
string content = await _httpClient.GetStringAsync(url);
return content;
}
}

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

我的程式當掉了—— Windows Forms 範例

前述範例程式的鎖死問題,如果你想看看 Windows Forms 的版本,可以用以下程式碼來實驗:

private void button1_Click(object sender, EventArgs e)
{
label1.Text = GetStringAsync().Result;
}
static HttpClient _httpClient = new HttpClient();
private async Task<string> GetStringAsync()
{
return await _httpClient.GetStringAsync("https://www.google.com");
}

當你按下 button1,應用程式就會當掉。

解法一:從頭到尾都使用非同步方法

最簡單的解法就是前面提過的,讓程式碼從頭到尾都採用非同步呼叫。只要在 button1 的按鈕事件處理常式前面加上 async 關鍵字,然後在函式裡面用 await 來取得 GetStringAsync 方法的執行結果就行了。如下所示:

private async void button1_Click(object sender, EventArgs e)
{
label1.Text = await GetStringAsync();
}

你可能注意到了,這裡的事件處理常式 button1_Click 是宣告成 async void。前面提過,非同步方法如果不需要回傳執行結果給呼叫端,應宣告為 async Task,而不是 async void。不過,事件處理常式算是個特例。以這個範例程式來說,這樣的解法雖不是頂漂亮,但寫法簡單,也不至於產生嚴重的副作用。

解法二:SynchronizationContextPost 方法

如果你不滿意剛才的解法,另一個選擇是使用 SynchronizationContextPost 方法。如下所示:

private void button1_Click(object sender, EventArgs e)
{
var uiContext = SynchronizationContext.Current;
GetStringAsync().ContinueWith(task =>
{
uiContext.Post(delegate
{
label1.Text = task.Result;
}, null);
});
}

說明:
  1. 第 1 行:現在 button1_Click 恢復成原本的樣子,沒有加上 async 方法,當然在函式裡面就不可能使用 await 來取得非同步工作的結果了。
  2. 第 3 行:先取得當前的同步執行環境,保存於變數 uiContext
  3. 第 5 行透過 Task.ContinueWith() 方法來接續非同步工作完成之後的處理,而這接續的處理就寫在第 6~11 行的委派裡面。
  4. 第 7 行利用 SynchronizationContextPost 方法來確保傳入的委派方法會回到 UI 執行緒上面執行。

Task.ContinueWith() 多載方法有許多版本,請參考線上說明文件以獲取更完整的資訊。本書第 4 章也會介紹 ContinueWith 的用法。

解法三:ContinueWith 搭配 TaskScheduler

再看另一種解法:

private void btnSolution3_Click(object sender, EventArgs e)
{
var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();
var task = GetStringAsync();
task.ContinueWith(t => label1.Text = t.Result, uiScheduler);
}

TaskScheduler 類別的靜態方法 FromCurrentSynchronizationContext 會從當前的同步環境取得工作排程器,而這個排程器在執行其佇列中的工作時,便是執行於它所屬的那個同步環境。因此,我們可以在 UI 執行緒上面呼叫 FromCurrentSynchronizationContext 方法來取得工作排程器,然後透過它來更新 UI,便安全無虞了。

注意第 5 行程式碼,在呼叫 TaskContinueWidth 方法來指定接續工作時有傳入 uiScheduler,也就是先前取得的那個在 UI 執行緒上面的工作排程器。因此,ContinueWidth 方法所建立的工作便會由我們指定的排程器來執行,也就是執行於 UI 執行緒。

以上介紹的幾種解決 UI 執行緒鎖死的方法,若沒有特殊原因,我會優先選擇簡單易懂的解法,也就是盡量採用 C# 提供的 async 與 await 語法。

摘錄內容到此結束。
無恥連結:試閱或購買本書

參考資料

Post Comments

技術提供:Blogger.