ConfigureAwait in .NET8

2023-11-24 15:00:39

ConfigureAwait in .NET8

ConfigureAwait(true) 和 ConfigureAwait(false)

首先,讓我們回顧一下原版 ConfigureAwait 的語意和歷史,它採用了一個名為 continueOnCapturedContext 的布林引數。

當對任務(TaskTask<T>ValueTaskValueTask<T>)執行 await 操作時,其預設行為是捕獲「上下文」的;稍後,當任務完成時,該 async 方法將在該上下文中繼續執行。「上下文」是 SynchronizationContext.CurrentTaskScheduler.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),我想指出幾個常見的誤解:

  1. ConfigureAwait(false) 並不是避免死鎖的好方法。這不是它的目的,充其量只是一個值得商榷的解決方案。為了在直接阻塞時避免死鎖,你必須確保所有非同步程式碼都使用 ConfigureAwait(false),包括庫和執行時中的程式碼。這並不是一個非常容易維護的解決方案。還有更好的解決方案
  2. ConfigureAwait 設定的是 await,而不是任務。例如,SomethingAsync().ConfigureAwait(false).GetAwaiter().GetResult() 中的 ConfigureAwait(false) 完全沒有任何作用。同樣,var task = SomethingAsync(); task.ConfigureAwait(false); await task; 中的 await 仍在捕獲的上下文中繼續,完全忽略了 ConfigureAwait(false)。多年來,我見過這兩種錯誤。
  3. 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) 僅適用於 TaskTask<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.NoneConfigureAwait(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.YieldYield 返回一個特殊的 awaitable,它總是聲稱尚未完成,但會立即安排其繼續。這意味著 await 始終以非同步方式執行,讓出給呼叫者,然後非同步方法儘快繼續執行。await 的正常行為是檢查可等待物件是否完成,如果完成,則繼續同步執行;ForceYielding 阻止了這種同步行為,強制 await 以非同步方式執行。

就我個人而言,我發現強制非同步行為在單元測試中最有用。在某些情況下,它還可以用來避免堆疊潛入。在實現非同步協調基元(如我的 AsyncEx 庫中的原語)時,它也可能很有用。基本上,在任何需要強制 await 以非同步方式執行的地方,都可以使用 ForceYielding 來實現。

我覺得有趣的一點是,使用 ForceYieldingawait 會讓 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 不完全相同。實際上,它類似於帶有 ContinueOnCapturedContextForceYielding

// 下面兩行程式碼效果相同
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)