首先,讓我們回顧一下原版 ConfigureAwait
的語意和歷史,它採用了一個名為 continueOnCapturedContext
的布林引數。
當對任務(Task
、Task<T>
、ValueTask
或 ValueTask<T>
)執行 await
操作時,其預設行為是捕獲「上下文」的;稍後,當任務完成時,該 async
方法將在該上下文中繼續執行。「上下文」是 SynchronizationContext.Current
或 TaskScheduler.Current
(如果未提供上下文,則回退到執行緒池上下文)。通過使用 ConfigureAwait(continueOnCapturedContext: true)
可以明確這種在捕獲上下文中繼續的預設行為。
如果不想在該上下文上恢復,ConfigureAwait(continueOnCapturedContext: false)
就很有用。使用 ConfigureAwait(false)
時,非同步方法會在任何可用的執行緒池執行緒上恢復。
ConfigureAwait(false)
的歷史很有趣(至少對我來說是這樣)。最初,社群建議在所有可能的地方使用 ConfigureAwait(false)
,除非需要上下文。這也是我在 Async 最佳實踐一文中推薦的立場。在那段時間裡,我們就預設為 true
的原因進行了多次討論,尤其是那些不得不經常使用 ConfigureAwait(false)
的庫開發人員。
不過,多年來,」儘可能使用 ConfigureAwait(false)
「的建議已被修改。第一次(儘管是微小的)變化是,不再是」儘可能使用 ConfigureAwait(false)
「,而是出現了更簡單的指導原則:在庫程式碼中使用 ConfigureAwait(false)
,而不要在應用程式碼中使用。這條準則更容易理解和遵循。儘管如此,關於必須使用 ConfigureAwait(false)
的抱怨仍在繼續,並不時有人要求在整個專案範圍內更改預設值。出於語言一致性的考慮,C# 團隊總是拒絕這些請求。
最近(具體來說,自從 ASP.NET 在 ASP.NET Core 中放棄了 SynchronizationContext
並修復了所有需要 sync-over-async(即同步套非同步程式碼) 的地方之後),C# 團隊開始放棄使用 ConfigureAwait(false)
。作為一名庫作者,我完全理解讓 ConfigureAwait(false)
在程式碼庫中隨處可見是多麼令人討厭!有些庫作者決定不再使用 ConfigureAwait(false)
。就我自己而言,我仍然在我的庫中使用 ConfigureAwait(false)
,但我理解這種挫敗感。
既然談到了 ConfigureAwait(false)
,我想指出幾個常見的誤解:
ConfigureAwait(false)
並不是避免死鎖的好方法。這不是它的目的,充其量只是一個值得商榷的解決方案。為了在直接阻塞時避免死鎖,你必須確保所有非同步程式碼都使用 ConfigureAwait(false)
,包括庫和執行時中的程式碼。這並不是一個非常容易維護的解決方案。還有更好的解決方案。ConfigureAwait
設定的是 await
,而不是任務。例如,SomethingAsync().ConfigureAwait(false).GetAwaiter().GetResult()
中的 ConfigureAwait(false)
完全沒有任何作用。同樣,var task = SomethingAsync(); task.ConfigureAwait(false); await task;
中的 await
仍在捕獲的上下文中繼續,完全忽略了 ConfigureAwait(false)
。多年來,我見過這兩種錯誤。ConfigureAwait(false)
並不意味著」線上程池執行緒上執行此方法的後續部分「或」在不同的執行緒上執行此方法的後續部分「。它只在 await
暫停執行並稍後恢復非同步方法時生效。具體來說,如果 await
的任務已經完成,它將不會暫停執行;在這種情況下,ConfigureAwait
將不會起作用,因為await
會同步繼續執行。好了,既然我們已經重新理解了 ConfigureAwait(false)
,下面就讓我們看看 ConfigureAwait
在 .NET8 中是如何得到增強的。ConfigureAwait(true)
和 ConfigureAwait(false)
仍具有相同的行為。但是,有一種新的 ConfigureAwait
即將出現!
ConfigureAwait
有幾個新選項。ConfigureAwaitOptions 是一種新型別,它提供了設定 awaitables 的所有不同方法:
namespace System.Threading.Tasks;
[Flags]
public enum ConfigureAwaitOptions
{
None = 0x0,
ContinueOnCapturedContext = 0x1,
SuppressThrowing = 0x2,
ForceYielding = 0x4,
}
首先,請注意:這是一個 Flags 列舉;這些選項的任何組合都可以一起使用。
接下來我要指出的是,至少在 .NET8 中,ConfigureAwait(ConfigureAwaitOptions)
僅適用於 Task
和 Task<T>
。它還沒有新增到 ValueTask/ValueTask<T>
。未來的 .NET 版本有可能為 ValueTask
新增 ConfigureAwait(ConfigureAwaitOptions)
,但目前它僅適用於參照任務,因此如果您想在 ValueTask
中使用這些新選項,則需要呼叫 AsTask
。
現在,讓我們依次講解這些選項。
這兩個選項都很熟悉,但有一點不同。
ConfigureAwaitOptions.ContinueOnCapturedContext
--從名字就能猜到與 ConfigureAwait(continueOnCapturedContext: true)
相同。換句話說,await 將捕獲上下文,並在該上下文上繼續執行非同步方法。
Task task = ...;
// 下面做的事情相同
await task;
await task.ConfigureAwait(continueOnCapturedContext: true);
await task.ConfigureAwait(ConfigureAwaitOptions.ContinueOnCapturedContext);
ConfigureAwaitOptions.None
與 ConfigureAwait(continueOnCapturedContext: false)
相同。換句話說,除了不捕獲上下文外,await 的行為完全正常;假設 await 確實產生了結果(即任務尚未完成),那麼非同步方法將在任何可用的執行緒池執行緒上繼續執行。
Task task = ...;
// 下面兩行程式碼效果一樣
await task.ConfigureAwait(continueOnCapturedContext: false);
await task.ConfigureAwait(ConfigureAwaitOptions.None);
這裡有一個轉折點:使用新選項後,預設情況下不會捕獲上下文!除非你在標記中明確包含 ContinueOnCapturedContext
,否則上下文將不會被捕獲。當然,await 本身的預設行為不會改變:在沒有任何 ConfigureAwait
的情況下,await 的行為將與使用了 ConfigureAwait(true)
或 ConfigureAwaitOptions.ContinueOnCapturedContext)
時一樣。
Task task = ...;
// 預設的行為還是會繼續捕捉上下文
await task;
// 預設選項 (ConfigureAwaitOptions.None): 不會捕捉上下文
await task.ConfigureAwait(ConfigureAwaitOptions.None);
因此,在開始使用這個新的 ConfigureAwaitOptions
列舉時,請記住這一點。
SuppressThrowing
標誌可抑制等待任務時可能出現的異常。在正常情況下,await
會通過在 await
時重新引發異常來觀察任務異常。通常情況下,這正是你想要的行為,但在某些情況下,你只想等待任務完成,而不在乎任務是成功完成還是出現異常。那麼 SuppressThrowing
選項允許您等待任務完成,而不觀察其結果。
Task task = ...;
// 下面兩行程式碼等價
await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
try { await task.ConfigureAwait(false); } catch { }
我預計這將與取消任務一起發揮最大作用。在某些情況下,有些程式碼需要先取消任務,然後等待現有任務完成後再啟動替代任務。在這種情況下,SuppressThrowing
將非常有用:程式碼可以使用 SuppressThrowing
等待,當任務完成時,無論任務是成功、取消還是出現異常,方法都將繼續。
// 取消舊任務並等待完成,忽略異常情況
_cts.Cancel();
await _task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
// 開啟新任務
_cts = new CancellationTokenSource();
_task = SomethingAsync(_cts.Token);
如果使用 SuppressThrowing
標誌等待,異常就會被視為」已觀察到「,因此不會引發 TaskScheduler.UnobservedTaskException
異常。我們的假設是,你在等待任務時故意丟棄了異常,所以它不會被認為是未觀察到的。
TaskScheduler.UnobservedTaskException += (_, __) => { Console.WriteLine("never printed"); };
Task task = Task.FromException(new InvalidOperationException());
await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
task = null;
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.ReadKey();
這個標記還有另一個考慮因素。當與 Task
一起使用時,其語意很清楚:如果任務失敗了,異常將被忽略。但是,同樣的語意對 Task<T>
並不完全適用,因為在這種情況下,await
表示式需要返回一個值(T
型別)。目前還不清楚在忽略異常的情況下返回 T
的哪個值合適,因此當前的行為是在執行時丟擲 ArgumentOutOfRangeException
。為了幫助在編譯時捕捉到這種情況,最近新增了一個新的警告:CA2261
ConfigureAwaitOptions.SuppressThrowing 僅支援非泛型任務
。該規則預設為警告,但我建議將其設為錯誤,因為它在執行時總是會失敗。
Task<int> task = Task.FromResult(13);
// 在構建時導致 CA2261 警告,在執行時導致 ArgumentOutOfRangeException。
await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
最後要說明的是,除了 await
之外,該標記還影響同步阻塞。具體來說,您可以呼叫 .GetAwaiter().GetResult()
來阻塞從 ConfigureAwait
返回的 awaiter。無論使用 await
還是 GetAwaiter().GetResult()
,SuppressThrowing
標記都會導致異常被忽略。以前,當 ConfigureAwait
只接受一個布林引數時,你可以說」ConfigureAwait 設定了 await「;但現在你必須說得更具體:」ConfigureAwait 返回了一個已設定的 await「。現在,除了 await 的行為外,設定的 awaitable 還有可能修改阻塞程式碼的行為。除了修改 await 的行為之外。現在的 ConfigureAwait
可能有點誤導性,但它仍然主要用於設定 await
。當然,不推薦在非同步程式碼中進行阻塞操作。
Task task = Task.Run(() => throw new InvalidOperationException());
// 同步阻塞任務(不推薦)。不會丟擲異常。
task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing).GetAwaiter().GetResult();
最後一個標誌是 ForceYielding
標誌。我估計這個標誌很少會用到,但當你需要它時,你就需要它!
ForceYielding
類似於 Task.Yield
。Yield
返回一個特殊的 awaitable,它總是聲稱尚未完成,但會立即安排其繼續。這意味著 await 始終以非同步方式執行,讓出給呼叫者,然後非同步方法儘快繼續執行。await
的正常行為是檢查可等待物件是否完成,如果完成,則繼續同步執行;ForceYielding
阻止了這種同步行為,強制 await
以非同步方式執行。
就我個人而言,我發現強制非同步行為在單元測試中最有用。在某些情況下,它還可以用來避免堆疊潛入。在實現非同步協調基元(如我的 AsyncEx 庫中的原語)時,它也可能很有用。基本上,在任何需要強制 await
以非同步方式執行的地方,都可以使用 ForceYielding
來實現。
我覺得有趣的一點是,使用 ForceYielding
的 await
會讓 await
的行為與 JavaScript 中的一樣。在 JavaScript 中,await 總是會產生結果,即使你傳遞給它一個已解析的 Promise 也是如此。在 C# 中,您現在可以使用 ForceYielding
來等待一個已完成的任務,await
的行為就好像它尚未完成一樣,就像 JavaScript 的 await 一樣。
static async Task Main()
{
Console.WriteLine(Environment.CurrentManagedThreadId); // main thread
await Task.CompletedTask;
Console.WriteLine(Environment.CurrentManagedThreadId); // main thread
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
Console.WriteLine(Environment.CurrentManagedThreadId); // thread pool thread
}
請注意,ForceYielding
本身也意味著不在捕獲的上下文中繼續執行,因此等同於說」將該方法的剩餘部分排程到執行緒池「或者」切換到執行緒池執行緒「。
// ForceYielding 強制 await 以非同步方式執行。
// 缺少 ContinueOnCapturedContext 意味著該方法將線上程池執行緒上繼續執行。
// 因此,該語句之後的程式碼將始終線上程池執行緒上執行。
await task.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
Task.Yield
將在捕獲的上下文中恢復執行,因此它與僅使用 ForceYielding
不完全相同。實際上,它類似於帶有 ContinueOnCapturedContext
的ForceYielding
。
// 下面兩行程式碼效果相同
await Task.Yield();
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding | ConfigureAwaitOptions.ContinueOnCapturedContext);
當然,ForceYielding
的真正價值在於它可以應用於任何任務。以前,在需要進行讓步的情況下,您必須要麼新增單獨的 await Task.Yield()
語句,要麼建立自定義的可等待物件。現在有了可以應用於任何任務的 ForceYielding
,這些操作就不再必要了。
很高興看到 .NET 團隊在多年後仍然在改進 async/await 的功能!
如果您對 ConfigureAwaitOptions
背後的歷史和設計討論更感興趣,可以檢視相關的 Pull Request。在釋出之前,曾經有一個名為ForceAsynchronousContinuation
的選項,但後來被刪除了。它具有更加複雜的用例,基本上可以覆蓋 await
的預設行為,將非同步方法的繼續操作排程為 ExecuteSynchronously
。也許未來的更新會重新新增這個選項,或者也許將來的更新會為 ValueTask
新增 ConfigureAwaitOptions
的支援。我們只能拭目以待!