C#中async/await之線程上下文工作原理
引言
接《async/await 在 C# 語言中是如何工作的?(上)》、《async/await 在 C# 語言中是如何工作的?(中)》,今天我們繼續(xù)介紹 SynchronizationContext 和 ConfigureAwait。
SynchronizationContext 和 ConfigureAwait
我們之前在 EAP 模式的上下文中討論過 SynchronizationContext,并提到它將再次出現(xiàn)。SynchronizationContext 使得調(diào)用可重用的輔助函數(shù)成為可能,并自動被調(diào)度回調(diào)用環(huán)境認為合適的任何地方。因此,我們很自然地認為 async/await 能“正常工作”,事實也的確如此?;氐角懊娴陌粹o單擊處理程序:
ThreadPool.QueueUserWorkItem(_ => { string message = ComputeMessage(); button1.BeginInvoke(() => { button1.Text = message; }); });
使用 async/await,我們可以這樣寫:
button1.Text = await Task.Run(() => ComputeMessage());
對 ComputeMessage 的調(diào)用被轉(zhuǎn)移到線程池中,這個方法執(zhí)行完畢后,執(zhí)行又轉(zhuǎn)移回與按鈕關(guān)聯(lián)的 UI 線程,設(shè)置按鈕的 Text 屬性就是在這個線程中進行的。
與 SynchronizationContext 的集成由 awaiter 實現(xiàn)(為狀態(tài)機生成的代碼對 SynchronizationContext 一無所知),因為當所表示的異步操作完成時,是 awaiter 負責實際調(diào)用或?qū)⑺峁┑?continuation 排隊。而自定義 awaiter 不需要考慮 SynchronizationContext。目前,Task、Task<TResult>、ValueTask、ValueTask<TResult> 的等待器都是 do。這意味著,默認情況下,當你等待一個任務(wù),一個 Task<TResult>,一個 ValueTask,一個 ValueTask<TResult>,甚至 Task. yield() 調(diào)用的結(jié)果時,awaiter 默認會查找當前的 SynchronizationContext,如果它成功地獲得了一個非默認的同步上下文,最終會將 continuation 排隊到該上下文。
如果我們查看 TaskAwaiter 中涉及的代碼,就可以看到這一點。以下是 Corelib 中的相關(guān)代碼片段:
internal void UnsafeSetContinuationForAwait(IAsyncStateMachineBox stateMachineBox, bool continueOnCapturedContext) { if (continueOnCapturedContext) { SynchronizationContext? syncCtx = SynchronizationContext.Current; if (syncCtx != null && syncCtx.GetType() != typeof(SynchronizationContext)) { var tc = new SynchronizationContextAwaitTaskContinuation(syncCtx, stateMachineBox.MoveNextAction, flowExecutionContext: false); if (!AddTaskContinuation(tc, addBeforeOthers: false)) { tc.Run(this, canInlineContinuationTask: false); } return; } else { TaskScheduler? scheduler = TaskScheduler.InternalCurrent; if (scheduler != null && scheduler != TaskScheduler.Default) { var tc = new TaskSchedulerAwaitTaskContinuation(scheduler, stateMachineBox.MoveNextAction, flowExecutionContext: false); if (!AddTaskContinuation(tc, addBeforeOthers: false)) { tc.Run(this, canInlineContinuationTask: false); } return; } } } ... }
這是一個方法的一部分,用于確定將哪個對象作為 continuation 存儲到任務(wù)中。它被傳遞給 stateMachineBox,如前所述,它可以直接存儲到任務(wù)的 continuation 列表中。但是,這個特殊的邏輯可能會將 IAsyncStateMachineBox 封裝起來,以合并一個調(diào)度程序(如果存在的話)。它檢查當前是否有非默認的 SynchronizationContext,如果有,它會創(chuàng)建一個 SynchronizationContextAwaitTaskContinuation 作為實際的對象,它會被存儲為 continuation;
該對象依次包裝了原始的和捕獲的 SynchronizationContext,并知道如何在與后者排隊的工作項中調(diào)用前者的 MoveNext。這就是如何在 UI 應(yīng)用程序中作為事件處理程序的一部分等待,并在等待完成后讓代碼繼續(xù)在正確的線程上運行。這里要注意的下一個有趣的事情是,它不僅僅關(guān)注一個 SynchronizationContext:如果它找不到一個自定義的 SynchronizationContext 來使用,它還會查看 Tasks 使用的 TaskScheduler 類型是否有一個需要考慮的自定義類型。和 SynchronizationContext 一樣,如果有一個非默認值,它就會和原始框一起包裝在 TaskSchedulerAwaitTaskContinuation 中,用作 continuation 對象。
但這里最值得注意的可能是方法主體的第一行:if (continueOnCapturedContext)。我們只在 continueOnCapturedContext 為 true 時才對 SynchronizationContext/TaskScheduler 進行這些檢查;如果這個值為 false,實現(xiàn)方式就好像兩者都是默認值一樣,會忽略它們。請問是什么將 continueOnCapturedContext 設(shè)置為 false?你可能已經(jīng)猜到了:使用非常流行的 ConfigureAwait(false)。
可以這樣說,作為 await 的一部分,ConfigureAwait(false) 做的唯一一件事是將它的參數(shù)布爾值作為 continueOnCapturedContext 值提供給這個函數(shù)(以及其他類似的函數(shù)),以便跳過對 SynchronizationContext/TaskScheduler 的檢查,表現(xiàn)得好像它們都不存在一樣。對于進程來說,這允許 Task 在它認為合適的地方調(diào)用其 continuation,而不是強制將它們排隊在某個特定的調(diào)度器上執(zhí)行。
我之前提到過 SynchronizationContext 的另一個方面,我說過我們會再次看到它:OperationStarted/OperationCompleted?,F(xiàn)在是時候了。這是沒那么受歡迎的特性:異步 void。除了 configureawait 之外,async void 可以說是 async/await 中最具爭議性的特性之一。它被添加的原因只有一個:事件處理程序。在 UI 應(yīng)用程序中,你可以編寫如下代碼:
button1.Click += async (sender, eventArgs) => { button1.Text = await Task.Run(() => ComputeMessage()); };
但如果所有的異步方法都必須有一個像 Task 這樣的返回類型,你就不能這樣做了。Click 事件有一個簽名 public event EventHandler? Click;,其中 EventHandler 定義為 public delegate void EventHandler(object? sender, EventArgs e);,因此要提供一個符合該簽名的方法,該方法需要是 void-returning。
有各種各樣的理由認為 async void 是不好的,為什么文章建議盡可能避免使用它,以及為什么出現(xiàn)了各種 analyzers 來標記使用 async void。最大的問題之一是委托推理??紤]下面的程序:
using System.Diagnostics; Time(async () => { Console.WriteLine("Enter"); await Task.Delay(TimeSpan.FromSeconds(10)); Console.WriteLine("Exit"); }); static void Time(Action action) { Console.WriteLine("Timing..."); Stopwatch sw = Stopwatch.StartNew(); action(); Console.WriteLine($"...done timing: {sw.Elapsed}"); }
人們很容易期望它輸出至少10秒的運行時間,但如果你運行它,你會發(fā)現(xiàn)輸出是這樣的:
Timing...
Enter
...done timing: 00:00:00.0037550
async lambda 實際上是一個異步 void 方法。異步方法會在遇到第一個暫停點時返回調(diào)用者。如果這是一個異步 Task 方法,Task 就會在這個時間點返回。但對于 async void,什么都不會返回。Time 方法只知道它調(diào)用了 action();委托調(diào)用返回;它不知道 async 方法實際上仍在“運行”,并將在稍后異步完成。
這就是 OperationStarted/OperationCompleted 的作用。這種異步 void 方法本質(zhì)上與前面討論的 EAP 方法類似:這種方法的初始化是 void,因此需要一些其他機制來跟蹤所有此類操作。因此,EAP 實現(xiàn)在操作啟動時調(diào)用當前 SynchronizationContext 的 OperationStarted,在操作完成時調(diào)用 OperationCompleted,async void 也做同樣的事情。與 async void 相關(guān)的構(gòu)建器是 AsyncVoidMethodBuilder。還記得在 async 方法的入口,編譯器生成的代碼如何調(diào)用構(gòu)建器的靜態(tài) Create 方法來獲得適當?shù)臉?gòu)建器實例嗎?AsyncVoidMethodBuilder 利用了這一點來掛鉤創(chuàng)建和調(diào)用 OperationStarted:
public static AsyncVoidMethodBuilder Create() { SynchronizationContext? sc = SynchronizationContext.Current; sc?.OperationStarted(); return new AsyncVoidMethodBuilder() { _synchronizationContext = sc }; }
類似地,當通過 SetResult 或 SetException 將構(gòu)建器標記為完成時,它會調(diào)用相應(yīng)的 OperationCompleted 方法。這就是像 xunit 這樣的單元測試框架如何能夠具有異步 void 測試方法,并仍然在并發(fā)測試執(zhí)行中使用最大程度的并發(fā),例如在 xunit 的 AsyncTestSyncContext 中。
有了這些知識,現(xiàn)在可以重寫我們的 timing 示例:
using System.Diagnostics; Time(async () => { Console.WriteLine("Enter"); await Task.Delay(TimeSpan.FromSeconds(10)); Console.WriteLine("Exit"); }); static void Time(Action action) { var oldCtx = SynchronizationContext.Current; try { var newCtx = new CountdownContext(); SynchronizationContext.SetSynchronizationContext(newCtx); Console.WriteLine("Timing..."); Stopwatch sw = Stopwatch.StartNew(); action(); newCtx.SignalAndWait(); Console.WriteLine($"...done timing: {sw.Elapsed}"); } finally { SynchronizationContext.SetSynchronizationContext(oldCtx); } } sealed class CountdownContext : SynchronizationContext { private readonly ManualResetEventSlim _mres = new ManualResetEventSlim(false); private int _remaining = 1; public override void OperationStarted() => Interlocked.Increment(ref _remaining); public override void OperationCompleted() { if (Interlocked.Decrement(ref _remaining) == 0) { _mres.Set(); } } public void SignalAndWait() { OperationCompleted(); _mres.Wait(); } }
在這里,我已經(jīng)創(chuàng)建了一個 SynchronizationContext,它跟蹤了一個待定操作的計數(shù),并支持阻塞等待它們?nèi)客瓿?。當我運行它時,我得到這樣的輸出:
Timing...
Enter
Exit
...done timing: 00:00:10.0149074
State Machine Fields
至此,我們已經(jīng)看到了生成的入口點方法,以及 MoveNext 實現(xiàn)中的一切是如何工作的。我們還了解了在狀態(tài)機上定義的一些字段。讓我們仔細看看這些。
對于前面給出的 CopyStreamToStream 方法:
public async Task CopyStreamToStreamAsync(Stream source, Stream destination) { var buffer = new byte[0x1000]; int numRead; while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0) { await destination.WriteAsync(buffer, 0, numRead); } }
下面是我們最終得到的字段:
private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine { public int <>1__state; public AsyncTaskMethodBuilder <>t__builder; public Stream source; public Stream destination; private byte[] <buffer>5__2; private TaskAwaiter <>u__1; private TaskAwaiter<int> <>u__2; ... }
< > 1 __state。是“狀態(tài)機”中的“狀態(tài)”。它定義了狀態(tài)機所處的當前狀態(tài),最重要的是下次調(diào)用 MoveNext 時應(yīng)該做什么。如果狀態(tài)為-2,則操作完成。如果狀態(tài)是-1,要么是我們第一次調(diào)用 MoveNext,要么是 MoveNext 代碼正在某個線程上運行。如果你正在調(diào)試一個 async 方法的處理過程,并且你看到狀態(tài)為-1,這意味著在某處有某個線程正在執(zhí)行包含在方法中的代碼。如果狀態(tài)大于等于0,方法會被掛起,狀態(tài)的值會告訴你在什么時候掛起。雖然這不是一個嚴格的規(guī)則(某些代碼模式可能會混淆編號),但通常情況下,分配的狀態(tài)對應(yīng)于從0開始的 await 編號,按照源代碼從上到下的順序排列。例如,如果 async 方法的函數(shù)體完全是:
await A(); await B(); await C(); await D();
你發(fā)現(xiàn)狀態(tài)值是2,這幾乎肯定意味著 async 方法當前被掛起,等待從 C() 返回的任務(wù)完成。
< > t__builder。這是狀態(tài)機的構(gòu)建器,例如用于 Task 的 AsyncTaskMethodBuilder,用于 ValueTask 的 AsyncValueTaskMethodBuilder<TResult>,用于 async void 方法的 AsyncVoidMethodBuilder,或用于 async 返回類型的 AsyncMethodBuilder(…)] 或通過 async 方法本身的屬性覆蓋的任何構(gòu)建器。如前所述,構(gòu)建器負責 async 方法的生命周期,包括創(chuàng)建 return 任務(wù),最終完成該任務(wù),并充當暫停的中介,async 方法中的代碼要求構(gòu)建器暫停,直到特定的 awaiter 完成。
編譯器完全按照參數(shù)名稱的指定來命名它們。如前所述,所有被方法主體使用的參數(shù)都需要被存儲到狀態(tài)機中,以便 MoveNext 方法能夠訪問它們。注意我說的是 "被使用"。如果編譯器發(fā)現(xiàn)一個參數(shù)沒有被異步方法的主體使用,它就可以優(yōu)化,不需要存儲這個字段。例如,給定下面的方法:
public async Task M(int someArgument) { await Task.Yield(); }
編譯器會將這些字段發(fā)送到狀態(tài)機:
private struct <M>d__0 : IAsyncStateMachine { public int <>1__state; public AsyncTaskMethodBuilder <>t__builder; private YieldAwaitable.YieldAwaiter <>u__1; ... }
請注意,這里明顯缺少名為 someArgument 的參數(shù)。但是,如果我們改變 async 方法,讓它以任何方式使用實參:
public async Task M(int someArgument) { Console.WriteLine(someArgument); await Task.Yield(); }
它顯示:
private struct <M>d__0 : IAsyncStateMachine { public int <>1__state; public AsyncTaskMethodBuilder <>t__builder; public int someArgument; private YieldAwaitable.YieldAwaiter <>u__1; ... }
<buffer>5__2;。這是緩沖區(qū)的 "局部",它被提升為一個字段,這樣它就可以在等待點上存活。編譯器相當努力地防止狀態(tài)被不必要地提升。注意,在源碼中還有一個局部變量 numRead,在狀態(tài)機中沒有相應(yīng)的字段。為什么?因為它沒有必要。這個局部變量被設(shè)置為 ReadAsync 調(diào)用的結(jié)果,然后被用作 WriteAsync 調(diào)用的輸入。在這兩者之間沒有 await,因此 numRead 的值需要被存儲。就像在一個同步方法中,JIT 編譯器可以選擇將這樣的值完全存儲在一個寄存器中,而不會真正將其溢出到堆棧中,C# 編譯器可以避免將這個局部變量提升為一個字段,因為它不需要在任何等待中保存它的值。一般來說,如果 C# 編譯器能夠證明局部變量的值不需要在等待中保存,它就可以省略局部變量的提升。
<>u__1和<>u__2。async 方法中有兩個 await:一個用于 ReadAsync 返回的 Task<int>,另一個用于 WriteAsync 返回的 Task。Task. getawaiter() 返回一個 TaskAwaiter,Task<TResult>. getawaiter() 返回一個 TaskAwaiter<TResult>,兩者都是不同的結(jié)構(gòu)體類型。由于編譯器需要在 await (IsCompleted, UnsafeOnCompleted) 之前獲取這些 awaiter,然后需要在 await (GetResult) 之后訪問它們,因此需要存儲這些 awaiter。由于它們是不同的結(jié)構(gòu)類型,編譯器需要維護兩個單獨的字段來做到這一點(另一種選擇是將它們裝箱,并為 awaiter 提供一個對象字段,但這會導致額外的分配成本)。不過,編譯器會盡可能地重復使用字段。如果我有:
public async Task M() { await Task.FromResult(1); await Task.FromResult(true); await Task.FromResult(2); await Task.FromResult(false); await Task.FromResult(3); }
有五個等待,但只涉及兩種不同類型的等待者:三個是 TaskAwaiter<int>,兩個是 TaskAwaiter<bool>。因此,狀態(tài)機上最終只有兩個等待者字段:
private struct <M>d__0 : IAsyncStateMachine { public int <>1__state; public AsyncTaskMethodBuilder <>t__builder; private TaskAwaiter<int> <>u__1; private TaskAwaiter<bool> <>u__2; ... }
然后,如果我將我的示例改為:
public async Task M() { await Task.FromResult(1); await Task.FromResult(true); await Task.FromResult(2).ConfigureAwait(false); await Task.FromResult(false).ConfigureAwait(false); await Task.FromResult(3); }
仍然只涉及 Task<int>s 和 Task<bool>s,但實際上我使用了四個不同的 struct awaiter 類型,因為從 ConfigureAwait 返回的東西上的 GetAwaiter() 調(diào)用返回的 awaiter 與 Task.GetAwaiter() 返回的是不同的類型…從編譯器創(chuàng)建的 awaiter 字段可以再次很明顯的看出:
private struct <M>d__0 : IAsyncStateMachine { public int <>1__state; public AsyncTaskMethodBuilder <>t__builder; private TaskAwaiter<int> <>u__1; private TaskAwaiter<bool> <>u__2; private ConfiguredTaskAwaitable<int>.ConfiguredTaskAwaiter <>u__3; private ConfiguredTaskAwaitable<bool>.ConfiguredTaskAwaiter <>u__4; ... }
如果您發(fā)現(xiàn)自己想要優(yōu)化與異步狀態(tài)機相關(guān)的大小,您可以查看的一件事是是否可以合并正在等待的事情,從而合并這些 awaiter 字段。
您可能還會看到在狀態(tài)機上定義的其他類型的字段。值得注意的是,您可能會看到一些字段包含單詞“wrap”??紤]下面這個例子:
public async Task<int> M() => await Task.FromResult(42) + DateTime.Now.Second;
這將生成一個包含以下字段的狀態(tài)機:
private struct <M>d__0 : IAsyncStateMachine { public int <>1__state; public AsyncTaskMethodBuilder<int> <>t__builder; private TaskAwaiter<int> <>u__1; ... }
到目前為止沒有什么特別的?,F(xiàn)在顛倒一下添加表達式的順序:
public async Task<int> M() => DateTime.Now.Second + await Task.FromResult(42);
這樣,你就得到了這些字段:
private struct <M>d__0 : IAsyncStateMachine { public int <>1__state; public AsyncTaskMethodBuilder<int> <>t__builder; private int <>7__wrap1; private TaskAwaiter<int> <>u__1; ... }
我們現(xiàn)在有了另一個函數(shù):<>7__wrap1。為什么?因為我們計算了 DateTime.Now 的值。其次,只有在計算完它之后,我們才需要等待一些東西,并且需要保留第一個表達式的值,以便將其與第二個表達式的結(jié)果相加。因此,編譯器需要確保第一個表達式的臨時結(jié)果可以添加到 await 的結(jié)果中,這意味著它需要將表達式的結(jié)果溢出到臨時中,它使用 <>7__wrap1 字段做到了這一點。如果你發(fā)現(xiàn)自己對異步方法的實現(xiàn)進行了超優(yōu)化,以減少分配的內(nèi)存量,你可以尋找這樣的字段,并查看對源代碼的微調(diào)是否可以避免溢出的需要,從而避免這種臨時的需要。
我希望這篇文章有助于解釋當你使用 async/await 時背后到底發(fā)生了什么。這里有很多變化,所有這些結(jié)合在一起,創(chuàng)建了一個高效的解決方案,可以編寫可拓展的異步代碼,而不必處理回調(diào)。然而歸根結(jié)底,這些部分實際上是相對簡單的:任何異步操作的通用表示,一種能夠?qū)⑵胀刂屏髦貙憺閰f(xié)程的狀態(tài)機實現(xiàn)的語言和編譯器,以及將它們綁定在一起的模式。其他一切都是優(yōu)化的額外收獲。
以上就是 C# 語言中async/await工作原理SynchronizationContext 和 ConfigureAwait的詳細內(nèi)容,更多關(guān)于 C# 語言 async/await的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
C#使用StopWatch獲取程序毫秒級執(zhí)行時間的方法
這篇文章主要介紹了C#使用StopWatch獲取程序毫秒級執(zhí)行時間的方法,涉及C#操作時間的相關(guān)技巧,需要的朋友可以參考下2015-04-04Unity InputFiled TMP屬性和各種監(jiān)聽示例詳解
這篇文章主要為大家介紹了Unity InputFiled TMP屬性和各種監(jiān)聽示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-01-01使用C#調(diào)用百度地圖并實現(xiàn)坐標點的設(shè)置以及讀取示例
這篇文章主要介紹了使用C#調(diào)用百度地圖并實現(xiàn)坐標點的設(shè)置以及讀取示例,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2020-07-07