我們在寫 ASP.NET (.NET Framework) 的時候,可能會需要利用非同步作業在背景執行一些工作,同時可能也需要偶爾取用 HttpContext
相關資訊。事實上當我們在 ASP.NET (.NET Framework) 使用 await
等待一個 Task 時,會自動紀錄當下的 SynchronizationContext
,並在 Task 完成工作後,取得原始 執行緒 (Thread) 中的資訊。但是如果我們也想在 Task 中取用原本執行緒的內容 (例如 HttpContext
資訊),我們就需要學會如何自行利用 SynchronizationContext
來取用原始執行緒中的內容。今天這篇文章,我將透過一段簡易的程式碼與一個實際應用情境,說明 SynchronizationContext
的運作方式,讓你知道如何正確的使用它。
以下我先寫一段虛擬碼 (Pseudocode),假設我們想執行一段沒有支援非同步 API 的程式碼,這段程式碼由於資料處理的執行時間過長,有必要改寫為「非同步」的版本,以增加執行緒使用效率:
private static async Task<IEnumerable<WeatherForecast>> NewMethodAsync()
{
// 假設我們想執行一段沒有支援非同步 API 的程式碼
var result = await Task.Run(() =>
{
// 取得大量資料
var data = repository.GetAll();
int counter = 1;
foreach (var item in data)
{
// 這裡的 System.Web.HttpContext.Current 將會得到 null 空值,程式將會發生錯誤!
if (counter % 1000 == 0 && !System.Web.HttpContext.Current.Response.IsClientConnected)
{
log.Information("Client Disconnected");
break;
}
// TODO: 對大量資料進行加工處理
counter++;
}
// 回傳處理過的資料
return data;
});
return result;
}
上述程式碼,你將無法在非同步執行的 Task.Run()
裡面使用 System.Web.HttpContext.Current
屬性,因為 Task.Run()
在執行的時候,會透過 ThreadPool 取得一個背景執行緒來執行程式碼,該執行緒並沒有原本 HttpContext
的內容,因此無法得到預期的結果,而且程式將會發生例外狀況。
此時,我們就可以在 Task 開始執行之前,利用 SynchronizationContext.Current
取得一個同步內容物件,搭配 C# 的 Closure (閉包) 特性,將該物件帶到 Task.Run()
裡面使用。之後你就可以透過 Post() 或 Send() 方法,將 SendOrPostCallback 委派帶到 SynchronizationContext
同步內容中執行。
你可以這樣想像,所有透過 SendOrPostCallback 委派帶到 SynchronizationContext
同步內容中執行的程式碼,都可以順利的獲取原先從 SynchronizationContext.Current
儲存下來的執行緒內容(Thread.CurrentThread
),並藉此取得完整的 System.Web.HttpContext.Current
屬性值。如此一來你就可以順利取得 System.Web.HttpContext.Current.Response.IsClientConnected
屬性內容,在背景執行緒中取得目前用戶端是否已經斷線,如果斷線就立即中斷資料處理作業,以節省 CPU 資源耗用。
以下是修改過後的程式碼:
private static async Task<IEnumerable<WeatherForecast>> NewMethodAsync()
{
// 先將 SynchronizationContext.Current 保存下來
var sc = SynchronizationContext.Current;
var result = await Task.Run(() =>
{
// 取得大量資料
var data = repository.GetAll();
// 判斷用戶端是否已斷線的變數
bool isClientConnected = true;
int counter = 1;
foreach (var item in data)
{
// 模擬每筆資料的處理時間
await Task.Delay(50);
// 假設我們每 1,000 筆檢查一次用戶端狀態
if (counter % 1000 == 0)
{
// 我們需要靠 sc (同步內容) 取得原本的執行緒內容,並藉此還原 HttpContext.Current 物件
// 以我假設的情境來說,必須用 Send 同步的方式執行 SendOrPostCallback,執行完才能繼續
sc.Send(state => {
isClientConnected = System.Web.HttpContext.Current.Response.IsClientConnected;
}, null);
if (!isClientConnected)
{
// 注意:撰寫 Log 的時候,有時候會需要取得部分 HttpContext 資訊記錄起來
// 但我們因為還在背景執行緒中,因此不在 sc 裡面的時候依然無法取得 HttpContext 相關資訊
log.Information("Client Disconnected");
// 用戶端斷線就不繼續處理資料
break;
}
}
counter++;
}
// 回傳處理過的資料
return data;
});
return result;
}
重點說明:
-
上述程式的優點,就在於我們可以每處理 1,000 筆資料就檢查一次用戶端是否斷線,但你必須透過 SynchronizationContext
才有辦法取用到 System.Web.HttpContext.Current
的值。
-
由於透過 SynchronizationContext
取用執行緒的時候,必須確保原始的執行緒沒有人在用,否則可能會引發 Deadlock (死結) 或執行效率低落的結果。
-
由於 SynchronizationContext
有 Post() 與 Send() 這兩個 API,其中 Post
是「非同步」的版本,而 Send
是「同步」的版本。
同步的 Send
API 可能比較好理解,他會先取得 SynchronizationContext
中的執行緒資訊,執行完 SendOrPostCallback
委派之後,才會往下執行。意思也就是說,當 SynchronizationContext
中的執行緒沒空的時候,這段程式就會卡住一段時間,必須等到 Thread 有空才會執行下去。
非同步的 Post
API 其實是將 SendOrPostCallback
委派加入到工作排程器(TaskScheduler)中,這個 SendOrPostCallback
委派並不會立刻執行,而是等到 SynchronizationContext
中的執行緒有空的時候才會執行。意思也就是說,即便 SynchronizationContext
中的執行緒沒空的時候,這段 sc.Post()
方法並不會卡住,程式會繼續執行下去。因此,你有很大的機率會遇到送出多個 SendOrPostCallback
之後都沒有立即執行,而在執行好幾次 sc.Post()
方法後,傳入的 SendOrPostCallback
才突然執行好幾次的狀況。
以我們上述 ASP.NET 的例子來說,我們需要即時判斷用戶端是否斷線,才能繼續往下走,所以用「同步」的 Send
API 才比較符合真實的情境要求。
相關連結