長話短說,本文帶大家抓住非同步程式設計async/await語法糖的牛鼻子: SynchronizationContext

引言

C#非同步程式設計語法糖async/await,使開發者很容易就能編寫非同步程式碼。

零散看過很多文章,很多是填鴨式灌輸 (有的翻譯文還有偏差)。



遵守以上冷冰冰的②③條的原則,可以確保我們的非同步程式按照預期運作,但是我們常看到違背這2條原則引發的死鎖現場。

由async/await引起的死鎖現場

UI例子:

點選按鈕觸發一個HTTP請求,用請求的返回值修改UI控制元件, 以下程式碼會引發deadlock (類似狀態出現在WinForm、WPF)

public static async Task<JObject> GetJsonAsync(Uri uri)
{
using (var client = new HttpClient())
{
var jsonString = await client.GetStringAsync(uri);
return JObject.Parse(jsonString);
}
} // 上層呼叫方法
public void Button1_Click(...)
{
var jsonTask = GetJsonAsync(...);
textBox1.Text = jsonTask.Result;
}

ASP.NET web例子:

從api發起遠端HTTP請求,等待請求的結果,以下程式碼也會引發deadlock

public static async Task<JObject> GetJsonAsync(Uri uri)
{
using (var client = new HttpClient())
{
var jsonString = await client.GetStringAsync(uri);
return JObject.Parse(jsonString);
}
}
// 上層呼叫方法
public class MyController : ApiController
{
public string Get()
{
var jsonTask = GetJsonAsync(...);
return jsonTask.Result.ToString();
}
}

️ 解決以上死鎖有2種程式設計寫法:

  1. 不再混用非同步、同步寫法, 始終使用async/await語法糖編寫非同步程式碼
  2. 在等待的非同步任務時應用ConfigureAwait(false)方法

SynchronizationContext就是解決這類死鎖的牛鼻子,大多數時候SynchronizationContext是在非同步程式設計後默默工作的,但是瞭解這個物件對於理解Task、await/sync 工作原理大有裨益。

本文會解釋:

  1. async/await工作機制
  2. SynchronizationContext在非同步程式設計語法糖中的意義
  3. 為什麼會有deadlock

1. await/async語法糖工作機制

微軟提出了Task執行緒包裝類和 await/async簡化了非同步程式設計的方式:

第②步:呼叫非同步方法GetStringAsync時,開啟非同步任務;

第⑥步:遇到await關鍵字,框架會捕獲呼叫執行緒的同步上下文(SynchronizationContext)物件,附加給非同步任務;同時,控制權上交到上層呼叫函式;

第⑦步:非同步任務完成,通過IO完成埠通知上層執行緒,

第⑧步:通過捕獲的執行緒同步上下文執行後繼程式碼塊;

2.SynchronizationContext的意義

先看下MSDN中關於SynchronizationContext的定義:

提供在各種同步模型中傳播同步上下文的基本功能。此類實現的同步模型的目的是允許公共語言執行庫的內部非同步/同步操作使用不同的同步模型正常執行。

☹️這完全不是人能看懂的解釋,我給出的解釋是:線上程切換過程中儲存呼叫執行緒的上下文, 用於在非同步任務完成後使用此執行緒同步上下文執行後繼程式碼

這個執行緒同步上下文的意義在哪?

我們大家都知道:WinForm和WPF都有類似的原則: 長耗時的任務在後臺計算,將非同步結果返回給UI執行緒

這個時候我們就需要捕獲UI執行緒的SynchronizationContext,並將這個物件傳入後臺執行緒。

public static void DoWork()
{
//On UI thread
var sc = SynchronizationContext.Current; ThreadPool.QueueUserWorkItem(delegate
{
// do work on ThreadPool
sc.Post(delegate
{
// do work on the original context (UI)
}, null);
});
}

SynchronizationContext標識了程式碼執行的執行緒環境,每個執行緒都有自己的SynchronizationContext,通過SynchronizationContext.Current可以獲取當前執行緒的同步上下文。

在非同步執行緒切換場景中,使用SynchronizationContext ,就可以返回到呼叫執行緒。

不同的.NET框架因各自獨特的需求有不同SynchronizationContext子類(通常是重寫父類虛方法):

  • ASP.NET有AspNetSynchronizationContext
  • Windows Form有WindowsFormSynchronizationContext
  • WPF 有DispatcherSynchronizationContext
  • ASP.NET Core、控制檯程式不存在SynchronizationContext,SynchronizationContext.Current=null

AspNetSynchronizationContext維護了HttpContext.Current、使用者身份和文化,但在ASP. NET Core這些資訊天然依賴注入,故不再需要SynchronizationContext;另一個好處是不再獲取同步上下文對效能也是一種提升。

因此,對於ASP.NET Core程式,ConfigureAwait(false)不是必需的,然而,在基礎庫時最好還是使用ConfigureAwait(false),因為你保不準上層會混用同步/非同步程式碼。

3.引言程式碼為什麼發生deadlock

觀察引言程式碼,控制權返回到上層呼叫函式時,執行流使用Result/(Wait方法)等待任務結果,Result/Wait()會導致呼叫執行緒同步阻塞(等待任務完成), 而非同步任務執行完成後,會嘗試利用捕獲的同步上下文執行後繼程式碼,這樣形成死鎖。

正因為如此,我們提出:

  • 在呼叫函式始終使用await方法,這樣呼叫執行緒是非同步等待任務完成,後繼程式碼可以在該執行緒同步上下文上執行
  • 對非同步任務應用ConfigureAwait(false)方法

ConfigureAwait(bool):true 表示嘗試在捕獲的原呼叫執行緒SynchronizationContext 中執行後繼程式碼;false 不再嘗試在捕獲的執行緒SynchronizationContext中執行後繼程式碼。 ConfigureAwait(false) 能解決[因呼叫執行緒同步阻塞]引發的死鎖,但是同步阻塞沒有利用非同步程式設計的優點,不是很推薦。

你會看到,這兩種緩解死鎖的方案其實 都是針對SynchronizationContext

ASP.NET Core和控制檯程式,因為捕獲的SynchronizationContext=null, 會選擇一個執行緒同步上下文來執行,不會死鎖。

總結

微軟為加快開發效率上著實費了心力,.NET提供的await/async語法糖簡化了非同步程式設計方式,

在非同步程式設計中,SynchronizationContext決定了後繼程式碼在哪裡執行的環境,深入理解這個物件的背景和不同框架的實現方式,能幫助我們避免編寫死鎖程式碼。