ConfigureAwait in .NET8 ConfigureAwait(true) 和 ConfigureAwait(false) 首先,讓我們回顧一下原版 ConfigureAwait 的語義和歷史,它採用了一個名為 continueOnCapturedContext 的布爾參數。 當對任務 ...
ConfigureAwait in .NET8
ConfigureAwait(true) 和 ConfigureAwait(false)
首先,讓我們回顧一下原版 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)
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.None 和 ConfigureAwaitOptions.ContinueOnCapturedContext
這兩個選項都很熟悉,但有一點不同。
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
枚舉時,請記住這一點。
ConfigureAwaitOptions.SuppressThrowing
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();
ConfigureAwaitOptions.ForceYielding
最後一個標誌是 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
的支持。我們只能拭目以待!
原文鏈接
ConfigureAwait in .NET 8 (stephencleary.com)
本作品採用知識共用署名-非商業性使用-相同方式共用 4.0 國際許可協議進行許可。