譯文: async/await SynchronizationContext 上下文問題


async / await 使異步代碼更容易寫,因為它隱藏了很多細節。 許多這些細節都捕獲在 SynchronizationContext 中,這些可能會改變異步代碼的行為完全由於你執行你的代碼的環境(例如WPF,Winforms,控制台或ASP.NET)所控制。 若果嘗試通過忽略 SynchronizationContext 產生的影響,您可能遇到死鎖和競爭條件狀況。

SynchronizationContext 控制任務連續的調度方式和位置,並且有許多不同的上下文可用。 如果你正在編寫一個 WPF 應用程序,構建一個網站或使用 ASP.NET 的API,你應該知道你已經使用了一個特殊的 SynchronizationContext 。

 

SynchronizationContext in a console application

讓我們來看看控制台應用程序中的一些代碼:

public class ConsoleApplication
{
    public static void Main()
    {
        Console.WriteLine($"{DateTime.Now.ToString("T")} - Starting");
        var t1 = ExecuteAsync(() => Library.BlockingOperation());
        var t2 = ExecuteAsync(() => Library.BlockingOperation()));
        var t3 = ExecuteAsync(() => Library.BlockingOperation()));
 
        Task.WaitAll(t1, t2, t3);
        Console.WriteLine($"{DateTime.Now.ToString("T")} - Finished");
        Console.ReadKey();
    }
 
    private static async Task ExecuteAsync(Action action)
    {
        // Execute the continuation asynchronously
        await Task.Yield();  // The current thread returns immediately to the caller
                             // of this method and the rest of the code in this method
                             // will be executed asynchronously
 
        action();
 
        Console.WriteLine($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId}");
    }
}

其中 Library.BlockingOperation() 是一個第三方庫,我們用它來阻塞正在使用的線程。 它可以是任何阻塞操作,但是為了測試的目的,您可以使用 Thread.Sleep(2) 來代替實現。

運行程序,輸出結果為:
16:39:15 - Starting
16:39:17 - Completed task on thread 11
16:39:17 - Completed task on thread 10
16:39:17 - Completed task on thread 9
16:39:17 - Finished

在示例中,我們創建三個任務阻塞線程一段時間。 Task.Yield 強制一個方法是異步的,通過調度這個語句之后的所有內容(稱為_continuation_)來執行,但立即將控制權返回給調用者(Task.Yield 是告知調度者"我已處理完成,可以將執行權讓給其他的線程",至於最終調用哪個線程,由調度者決定,可能下一個調度的線程還是自己本身)。 從輸出中可以看出,由於 Task.Yield 所有的操作最終並行執行,總執行時間只有兩秒。

 

SynchronizationContext in an ASP.NET application

假設我們想在 ASP.NET 應用程序中重用這個代碼,我們將代碼 Console.WriteLine 轉換為 HttpConext.Response.Write 即可,我們可以看到頁面上的輸出:

public class HomeController : Controller
{
    public ActionResult Index()
    {
        HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Starting");
        var t1 = ExecuteAsync(() => Library.BlockingOperation()));
        var t2 = ExecuteAsync(() => Library.BlockingOperation()));
        var t3 = ExecuteAsync(() => Library.BlockingOperation()));
 
        Task.WaitAll(t1, t2, t3);
        HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Finished");
 
        return View();
    }
 
    private async Task ExecuteAsync(Action action)
    {
        await Task.Yield();
 
        action();
        HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId}");
    }
}

我們會發現,在瀏覽器中啟動此頁面后不會加載。 看來我們是引入了一個死鎖。那么這里到底發生了什么呢?

死鎖的原因是控制台應用程序調度異步操作與 ASP.NET 不同。 雖然控制台應用程序只是調度線程池上的任務,而 ASP.NET 確保同一 HTTP 請求的所有異步任務都按順序執行。 由於 Task.Yield 將剩余的工作排隊,並立即將控制權返回給調用者,因此我們在運行 Task.WaitAll 的時候有三個等待操作。 Task.WaitAll 是一個阻塞操作,類似的阻塞操作還有如 Task.Wait 或 Task.Result,因此阻止當前線程。

ASP.NET 是在線程池上調度它的任務,阻塞線程並不是導致死鎖的原因。 但是由於是順序執行,這導致不允許等待操作開始執行。 如果他們無法啟動,他們將永遠不能完成,被阻止的線程不能繼續。

此調度機制由 SynchronizationContext 類控制。 每當我們等待任務時,在等待的操作完成后,在 await 語句(即繼續)之后運行的所有內容將在當前 SynchronizationContext 上被調度。 上下文決定了如何、何時和在何處執行任務。 您可以使用靜態 SynchronizationContext.Current 屬性訪問當前上下文,並且該屬性的值在 await 語句之前和之后始終相同。

在控制台應用程序中,SynchronizationContext.Current 始終為空,這意味着連接可以由線程池中的任何空閑線程拾取,這是在第一個示例中能並行執行操作的原因。 但是在我們的 ASP.NET 控制器中有一個 AspNetSynchronizationContext,它確保前面提到的順序處理。

要點一:

不要使用阻塞任務同步方法,如 Task.Result,Task.Wait,Task.WaitAll 或 Task.WaitAny。 控制台應用程序的 Main 方法目前是該規則唯一的例外(因為當它們獲得完全異步時的行為會有所改變)。

 

解決方案

現在我們知道不應該使用 Task.WaitAll,讓我們修復我們的控制器的 Index Action:

public async Task<ActionResult> Index()
{
    HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Starting 
");
    var t1 = ExecuteAsync(() => Library.BlockingOperation()));
    var t2 = ExecuteAsync(() => Library.BlockingOperation()));
    var t3 = ExecuteAsync(() => Library.BlockingOperation()));
 
    await Task.WhenAll(t1, t2, t3);
    HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Finished 
");
 
    return View();
}

我們將 Task.WaitAll(t1,t2,t3)更改為非阻塞等待 Task.WhenAll(t1,t2,t3),這也要求我們將方法的返回類型從 ActionResult 更改為 async 任務。

更改后我們看到頁面上輸出如下結果:

16:41:03 - Starting
16:41:05 - Completed task on thread 60
16:41:07 - Completed task on thread 50
16:41:09 - Completed task on thread 74
16:41:09 - Finished
 
這看起來更好,但我們有另一個問題。 頁面現在需要六秒的加載,而不是我們在控制台應用程序中的兩秒。 輸出很好地顯示 AspNetSynchronizationContext 確實調度其在線程池上的工作,因為我們可以看到執行任務的不同線程。 但是由於這種上下文的順序性質,它們不會並行運行。 雖然我們解決了死鎖,我們的復制粘貼代碼仍然低於在控制台應用程序中使用的效率。

要點二:

永遠不要假設異步代碼是以並行方式執行的,除非你顯式地將其設置為並行執行。 用 Task.Run 或 Task.Factory.StartNew 調度異步代碼來使他們並行運行。

 

第二次嘗試

我們使用新的的規則:

private async Task ExecuteAsync(Action action)
{
    await Task.Yield();
 
    action();
    HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId} 
");
}

to:

private async Task ExecuteAsync(Action action)
{
    await Task.Run(action);
    HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId} 
");
}

Task.Run 在沒有 SynchronizationContext 的情況下在線程池上調度給定的操作。 這意味着在任務內運行的所有內容都將 SynchronizationContext.Current 設置為 null。 結果是所有入隊操作都可以由任何線程自由選取,並且它們不必遵循ASP.NET上下文指定的順序執行順序。 這也意味着任務能夠並行執行。

注意 HttpContext 不是線程安全的,因此我們不應該在 Task.Run 中訪問它,因為這可能在 html 輸出上產生奇怪的結果。 但是由於上下文捕獲,Response.Write 被確保發生在 AspNetSynchronizationContext(這是在 await 之前的當前上下文)中,確保對 HttpContext 的序列化訪問。

這次的輸出結果為:

16:42:27 - Starting
16:42:29 - Completed task on thread 9
16:42:29 - Completed task on thread 12
16:42:29 - Completed task on thread 14
16:42:29 - Finished
 

不僅僅如此

SynchronizationContext 可以做的不僅僅是調度任務。 AspNetSynchronizationContext 也確保正確的用戶設置在當前正在執行的線程(記住,它是在整個線程池中安排工作),它使得  HttpContext.Current 可用。
在我們的代碼中這些都是沒有必要的,因為我們能夠使用 Controller 的 HttpContext 屬性。 如果我們想要提取我們超級有用的 ExecuteAsync 到一個幫助類,這變得很明顯:
class AsyncHelper
{
    public static async Task ExecuteAsync(Action action)
    {
        await Task.Run(action);
        HttpContext.Current.Response.Write($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId} 
");
    }
}

我們剛剛將 HttpContext.Response 更改為靜態可用的 HttpContext.Current.Response 。 這仍然可以工作,這得益於 AspNetSynchronizationContext,但如果你嘗試在 Task.Run 中訪問 HttpContext.Current ,你會得到一個 NullReferenceException,因為 HttpContext.Current 沒有設置。

 

忘掉上下文

正如我們在前面的例子中看到的,上下文捕獲可以非常方便。 但是在許多情況下,我們不需要為 "continuation" 恢復的上下文。 上下文捕獲是有代價的,如果我們不需要它,最好避免這個附加的邏輯。 假設我們要切換到日志框架,而不是直接寫入加載的網頁。 我們重寫我們的幫助:

class AsyncHelper
{
    public static async Task ExecuteAsync(Action action)
    {
        await Task.Run(action);
        Log.Info($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId}");
    }
}

現在在 await 語句之后,AspNetSynchronizationContext 中沒有我們需要的東西,因此在這里不恢復它是安全的。 在等待任務之后,可以使用 ConfigureAwait(false) 禁用上下文捕獲。 這將告訴等待的任務調度其當前 SynchronizationContext 的延續。 因為我們使用 Task.Run,上下文是 null,因此連接被調度在線程池上(沒有順序執行約束)。

使用 ConfigureAwait(false) 時要記住的兩個細節:

  • 當使用 ConfigureAwait(false) 時,不能保證 "continuation" 將在不同的上下文中運行。 它只是告訴基礎設施不恢復上下文,而不是主動切換到其他的東西(使用 Task.Run 如果你想擺脫上下文)。
  • 禁用上下文捕獲僅限於使用 ConfigureAwait(false) 的 await 語句。 在下一個 await(在同一方法中,在調用方法或被調用的方法)語句中,如果沒有另外說明,上下文將被再次捕獲和恢復。 所以你需要添加 ConfigureAwait(false) 到所有 await 語句,以防你不依賴上下文。

 

TL; DR;

由於異步代碼的 SynchronizationContext,異步代碼在不同環境中的表現可能不同。 但是,當遵循最佳做法時,我們可以將遇到問題的幾率減少到最低限度。 因此,請確保您熟悉 async/await 最佳實踐並堅持使用它們。

 

原文: Context Matters


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM