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

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

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) 來代替實現。app

運行程序,輸出結果爲:
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}");
    }
}

咱們會發現,在瀏覽器中啓動此頁面後不會加載。 看來咱們是引入了一個死鎖。那麼這裏到底發生了什麼呢?async

死鎖的緣由是控制檯應用程序調度異步操做與 ASP.NET 不一樣。 雖然控制檯應用程序只是調度線程池上的任務,而 ASP.NET 確保同一 HTTP 請求的全部異步任務都按順序執行。 因爲 Task.Yield 將剩餘的工做排隊,並當即將控制權返回給調用者,所以咱們在運行 Task.WaitAll 的時候有三個等待操做。 Task.WaitAll 是一個阻塞操做,相似的阻塞操做還有如 Task.Wait 或 Task.Result,所以阻止當前線程。測試

ASP.NET 是在線程池上調度它的任務,阻塞線程並非致使死鎖的緣由。 可是因爲是順序執行,這致使不容許等待操做開始執行。 若是他們沒法啓動,他們將永遠不能完成,被阻止的線程不能繼續。網站

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

在控制檯應用程序中,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

相關文章
相關標籤/搜索