利用 SynchronizationContext 讓 ASP.NET 背景執行緒取用 HttpContext 資訊 | The Will Will Web

The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

利用 SynchronizationContext 讓 ASP.NET 背景執行緒取用 HttpContext 資訊

我們在寫 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: 對大量資料進行加工處理,處理時間超過 50ms

            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. 上述程式的優點,就在於我們可以每處理 1,000 筆資料就檢查一次用戶端是否斷線,但你必須透過 SynchronizationContext 才有辦法取用到 System.Web.HttpContext.Current 的值。

  2. 由於透過 SynchronizationContext 取用執行緒的時候,必須確保原始的執行緒沒有人在用,否則可能會引發 Deadlock (死結) 或執行效率低落的結果。

  3. 由於 SynchronizationContextPost()Send() 這兩個 API,其中 Post 是「非同步」的版本,而 Send 是「同步」的版本。

    同步的 Send API 可能比較好理解,他會先取得 SynchronizationContext 中的執行緒資訊,執行完 SendOrPostCallback 委派之後,才會往下執行。

    非同步的 Post API 其實是將 SendOrPostCallback 委派加入到工作排程器(TaskScheduler)中,並不會立刻執行,而是等到 SynchronizationContext 中的執行緒有空的時候才會執行,因此可能會遇到送出多個 SendOrPostCallback 之後才突然執行好幾個 SendOrPostCallback 的狀況。以我們上述 ASP.NET 的例子來說,我們需要即時判斷用戶端是否斷線,才能繼續往下走,所以用「同步」的 Send API 才比較符合真實的情境要求。

相關連結