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
要點二:
永遠不要假設異步代碼是以並行方式執行的,除非你顯式地將其設置為並行執行。 用 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
不僅僅如此
在我們的代碼中這些都是沒有必要的,因為我們能夠使用 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