【譯】ConfigureAwait FAQ

MeteorSeed發表於2023-04-18

  .NET 在數年前就在語言和庫中新增了 async/await。在那段時間裡,它像野火一樣蔓延開來,不僅在 .NET 生態系統中,而且在無數其他語言和框架中被複制。在 .NET 中也看到了大量的改進,包括利用非同步的額外語言構造、提供非同步支援的 API 以及在基礎設施中實現 async/await (特別是在 .NET Core 中效能和診斷支援方面的改進)。

  然而,async/await 中的 ConfigureAwait,引發了一些疑問。在這篇文章中,我希望能回答其中的許多問題。我希望這篇文章從頭到尾都是易讀的,同時也是一個常見問題的列表,可以作為將來的參考。

  要真正理解  ConfigureAwait,我們需從更基礎的一些東西說起。

SynchronizationContext 是什麼?

  SynchronizationContext 在 MSDN 中描述為:“提供了在各種同步模型中傳播同步上下文的基本功能。”

  對於99.9%的用例,SynchronizationContext 只是一個型別,它提供了一個 virtual Post 方法,提供了一個非同步執行的委託。(SynchronizationContext 有各種各樣的其他虛擬成員,但他們很少使用本文不做討論)。基型別的 Post 實際上只是呼叫 ThreadPool.QueueUserWorkItem 非同步呼叫提供的委託。但是,派生型別會重寫 Post 以使該委託能夠在最合適的地方和最合適的時間執行。

  例如,Windows Form 有一個 WindowsFormsSynchronizationContext 派生型別,它重寫了 Post 方法,內部其實就是 Control.BeginInvoke。

  這意味著,對其 Post 方法的任何呼叫都將導致該委託在與相關控制元件關聯的執行緒(又名“UI執行緒”)上稍後的某個點被呼叫。Windows Forms 依賴於 Win32 訊息處理,並在 UI 執行緒上執行一個“訊息迴圈”,該執行緒只是坐著等待新訊息到達處理。這些訊息可以用於滑鼠移動和單擊、鍵盤輸入、系統事件、可呼叫的委託等。因此,給定 Windows Form 應用程式 UI 執行緒的 SynchronizationContext 例項,要獲得一個在那個 UI 執行緒上執行的委託,只需要將它傳遞給 Post 即可。

  WPF 也是如此。DispatcherSynchronizationContext 派生型別的 Post 內部其實是 Dispatcher.BeginInvoke,在這種情況下由一個 WPF Dispatcher 管理而不是一個 Windows Forms Control。

  WinRT 也類似。它有自己的 SynchronizationContext 派生型別,帶有一個 Post 過載,該過載透過 CoreDispatcher 將委託排隊到 UI 執行緒。

  這不僅僅是“在 UI 執行緒上執行這個委託”。任何人都可以實現 SynchronizationContext 派生類來做任何事情。例如,我可能不關心委託在哪個執行緒上執行,但我想確保任何 Post 到我的 SynchronizationContext 的委託都是在有限的併發程度下執行的。我可以透過一個自定義的 SynchronizationContext 來實現:

internal sealed class MaxConcurrencySynchronizationContext : SynchronizationContext
{
    private readonly SemaphoreSlim _semaphore;

    public MaxConcurrencySynchronizationContext(int maxConcurrencyLevel) =>
        _semaphore = new SemaphoreSlim(maxConcurrencyLevel);

    public override void Post(SendOrPostCallback d, object state) =>
        _semaphore.WaitAsync().ContinueWith(delegate
        {
            try { d(state); } finally { _semaphore.Release(); }
        }, default, TaskContinuationOptions.None, TaskScheduler.Default);

    public override void Send(SendOrPostCallback d, object state)
    {
        _semaphore.Wait();
        try { d(state); } finally { _semaphore.Release(); }
    }
}

  實際上,xunit 提供了一個與此非常類似的 SynchronizationContext,它使用它來限制併發執行的測試相關聯的程式碼數量。

namespace Xunit.Sdk
{
    /// <summary>
    /// An implementation of <see cref="SynchronizationContext"/> which runs work on custom threads
    /// rather than in the thread pool, and limits the number of in-flight actions.
    /// </summary>
    public class MaxConcurrencySyncContext : SynchronizationContext, IDisposable
    {
        bool disposed = false;
        readonly ManualResetEvent terminate = new ManualResetEvent(false);
        readonly List<XunitWorkerThread> workerThreads;
        readonly ConcurrentQueue<Tuple<SendOrPostCallback, object, object>> workQueue = new ConcurrentQueue<Tuple<SendOrPostCallback, object, object>>();
        readonly AutoResetEvent workReady = new AutoResetEvent(false);

        /// <summary>
        /// Initializes a new instance of the <see cref="MaxConcurrencySyncContext"/> class.
        /// </summary>
        /// <param name="maximumConcurrencyLevel">The maximum number of tasks to run at any one time.</param>
        public MaxConcurrencySyncContext(int maximumConcurrencyLevel)
        {
            workerThreads = Enumerable.Range(0, maximumConcurrencyLevel)
                                      .Select(_ => new XunitWorkerThread(WorkerThreadProc))
                                      .ToList();
        }

        /// <summary>
        /// Gets a flag indicating whether maximum concurrency is supported.
        /// </summary>
        public static bool IsSupported
            => ExecutionContextHelper.IsSupported;

        /// <inheritdoc/>
        public void Dispose()
        {
            if (disposed)
                return;

            disposed = true;
            terminate.Set();

            foreach (var workerThread in workerThreads)
            {
                workerThread.Join();
                workerThread.Dispose();
            }

            terminate.Dispose();
            workReady.Dispose();
        }

        /// <inheritdoc/>
        public override void Post(SendOrPostCallback d, object state)
        {
            // HACK: DNX on Unix seems to be calling this after it's disposed. In that case,
            // we'll just execute the code directly, which is a violation of the contract
            // but should be safe in this situation.
            if (disposed)
                Send(d, state);
            else
            {
                var context = ExecutionContextHelper.Capture();
                workQueue.Enqueue(Tuple.Create(d, state, context));
                workReady.Set();
            }
        }

        /// <inheritdoc/>
        public override void Send(SendOrPostCallback d, object state)
        {
            d(state);
        }

        [SecuritySafeCritical]
        void WorkerThreadProc()
        {
            while (true)
            {
                if (WaitHandle.WaitAny(new WaitHandle[] { workReady, terminate }) == 1)
                    return;

                Tuple<SendOrPostCallback, object, object> work;
                while (workQueue.TryDequeue(out work))
                {
                    // Set workReady() to wake up other threads, since there might still be work on the queue (fixes #877)
                    workReady.Set();
                    if (work.Item3 == null)    // Fix for #461, so we don't try to run on a null execution context
                        RunOnSyncContext(work.Item1, work.Item2);
                    else
                        ExecutionContextHelper.Run(work.Item3, _ => RunOnSyncContext(work.Item1, work.Item2));
                }
            }
        }

        [SecuritySafeCritical]
        void RunOnSyncContext(SendOrPostCallback callback, object state)
        {
            var oldSyncContext = Current;
            SetSynchronizationContext(this);
            callback(state);
            SetSynchronizationContext(oldSyncContext);
        }
    }
}

  這些派生類都比較類似:SynchronizationContext 提供了一個單獨的 API,可用於對委託進行排隊,以便按使用者需求進行處理,而不需要知道實現的細節。所以,如果我正在寫一個庫,我想開始做一些工作,然後將一個委託排隊回到原始位置的“context”,我只需要抓住他們的 SynchronizationContext,持有它,然後當我完成工作後,在那個context上呼叫Post來傳遞我想要呼叫的委託。我不需要知道,對於 Windows 窗體,我應該獲取一個 Control 並使用它的 BeginInvoke,或者對於 WPF,我應該獲取一個 Dispatcher 並使用它的 BeginInvoke,或者對於 xunit,我應該以某種方式獲取它的上下文並排隊到它;我只需要獲取當前的 SynchronizationContext 並在後面使用它。為了實現這一點,SynchronizationContext 提供了一個 Current  屬性,這樣,為了實現前面提到的目標,我可能會寫這樣的程式碼:

public void DoWork(Action worker, Action completion)
{
    SynchronizationContext sc = SynchronizationContext.Current;
    ThreadPool.QueueUserWorkItem(_ =>
    {
        try { worker(); }
        finally { sc.Post(_ => completion(), null); }
    });
}

  框架使用 SynchronizationContext.SetSynchronizationContext 方法,透過 Current 暴露一個自定義上下文。

TaskScheduler 是什麼?

  SynchronizationContext 是“scheduler”的一般抽象。個別框架有時對排程程式有自己的抽象,System.Threading.Tasks 也不例外。當 Task 由委託支援,以便它們可以排隊並執行時,它們與 System.Threading.Tasks.TaskScheduler 相關聯。正如 SynchronizationContext 提供了一個 virtual 的 Post 方法來對委託的呼叫進行排隊(實現稍後會透過標準的委託呼叫機制來呼叫委託),TaskScheduler 提供了一個 abstract 的 QueueTask 方法(實現稍後透過 ExecuteTask 方法呼叫該任務)。

  排程器透過 TaskScheduler.Default 返回的預設排程器是執行緒池,但是可以從 TaskScheduler 派生並覆蓋相關的方法,從而實現何時何地呼叫 Task 的行為。例如,核心庫包括 System.Threading.Tasks.ConcurrentExclusiveSchedulerPair 型別。這個類的例項暴露了兩個 TaskScheduler 屬性,一個稱為 ExclusiveScheduler,另一個稱為 ConcurrentScheduler。任務排程到 ConcurrentScheduler 的中執行,則可以同時執行,但有併發限制,受制於 ConcurrentExclusiveSchedulerPair。(類似於前面說的 MaxConcurrencySynchronizationContext)。當 Task 被排程到 ExclusiveScheduler 中執行時,沒有任何 ConcurrentScheduler 的 Task 會執行,一次只允許執行一個排他性 Task……在這種情況下,它的行為非常類似於一個 reader/writer-lock。

  與 SynchronizationContext 一樣,TaskScheduler 也有一個 Current 屬性,它返回當前的 TaskScheduler。然而,與 SynchronizationContext 不同的是,它沒有設定當前排程器的方法。作為替代,當前排程器是與當前執行的 Task 相關聯的排程器,並且作為啟動 Task 的一部分提供給系統的排程器。下面程式將輸出“True”,因為與 StartNew 一起使用的 lambda 在 ConcurrentExclusiveSchedulerPair 的 ExclusiveScheduler 上執行,檢視設定到該排程程式的 TaskScheduler.Current:

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        var cesp = new ConcurrentExclusiveSchedulerPair();
        Task.Factory.StartNew(() =>
        {
            Console.WriteLine(TaskScheduler.Current == cesp.ExclusiveScheduler);
        }, default, TaskCreationOptions.None, cesp.ExclusiveScheduler).Wait();
    }
}

  有趣的是,TaskScheduler 提供了一個靜態的 FromCurrentSynchronizationContext 方法,該方法建立一個新的 TaskScheduler,使用它的 Post 方法對任務進行排隊,以便在 SynchronizationContext.Current 返回的上下文上執行。

SynchronizationContext、TaskScheduler 與 await 的關聯?

  考慮編寫一個帶有按鈕的 UI 應用程式。單擊按鈕時,我們希望從 web 站點下載一些文字,並將其設定為按鈕的內容。該按鈕只能從擁有它的 UI 執行緒訪問,因此當我們成功下載了新的文字並希望將其儲存回按鈕的內容時,我們需要從擁有控制元件的執行緒進行操作。如果我們不這樣做,我們會得到一個異常:

System.InvalidOperationException: 'The calling thread cannot access this object because a different thread owns it.'

  如果我們手動實現,我們可以使用 SynchronizationContext,如前面所示,將內容設定回原始上下文,例如可以透過 TaskScheduler:

private static readonly HttpClient s_httpClient = new HttpClient();

private void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    s_httpClient.GetStringAsync("http://example.com/currenttime").ContinueWith(downloadTask =>
    {
        downloadBtn.Content = downloadTask.Result;
    }, TaskScheduler.FromCurrentSynchronizationContext());
}

  或者直接使用 SynchronizationContext:

private static readonly HttpClient s_httpClient = new HttpClient();

private void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    SynchronizationContext sc = SynchronizationContext.Current;
    s_httpClient.GetStringAsync("http://example.com/currenttime").ContinueWith(downloadTask =>
    {
        sc.Post(delegate
        {
            downloadBtn.Content = downloadTask.Result;
        }, null);
    });
}

  但是,這兩種方法都顯式地使用回撥,下面使用 async/await 實現:

private static readonly HttpClient s_httpClient = new HttpClient();

private async void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    string text = await s_httpClient.GetStringAsync("http://example.com/currenttime");
    downloadBtn.Content = text;
}

  這“剛剛好”,成功地在 UI 執行緒上設定了 Content,因為就像上面手動實現的版本一樣,await 一個 Task 在預設情況下會注意到 SynchronizationContext.Current 和 TaskScheduler.Current。當你在 C# 中 await 任何東西時,編譯器會透過呼叫 GetAwaiter 將程式碼轉換為“awaitable”型別,在本例中是 Task,所以轉換為“awaiter”,在本例中是 TaskAwaiter。awaiter負責連線回撥(通常稱為“continuation”),當等待的物件完成時它將回撥到狀態機中,它使用在註冊回撥時捕獲的 context/scheduler 來執行此操作。

  雖然不是完全使用的程式碼(有額外的最佳化和調整),它是這樣的:

object scheduler = SynchronizationContext.Current;
if (scheduler is null && TaskScheduler.Current != TaskScheduler.Default)
{
    scheduler = TaskScheduler.Current;
}

  換句話說,它首先檢查是否有 SynchronizationContext 集,如果沒有,是否有一個非預設的 TaskScheduler 在執行。如果它找到一個,當回撥準備被呼叫時,它將使用捕獲排程程式;否則,它通常只執行回撥,作為完成等待的任務的操作的一部分。

ConfigureAwait(false) 做了什麼?

  ConfigureAwait 方法並不特殊:編譯器或執行時都不會以任何特殊的方式識別它。它只是一個簡單方法,返回一個 struct(ConfiguredTaskAwaitable),該 struct 包裝了呼叫它的原始任務,就像 Boolean 一樣被呼叫。請記住,await 可以用於任何暴露正確模式的型別。透過返回一個不同的型別,這意味著當編譯器訪問例項 GetAwaiter 方法(模式的一部分),它是在 ConfigureAwait 返回的型別之外執行的,而不是直接在任務之外執行,並提供了一個鉤子,透過這個定製的 awaiter 來改變等待的行為。

  具體來說,等待從 ConfigureAwait(continueOnCapturedContext: false) 返回的型別,而不是直接等待 Task ,最終會影響前面顯示的如何捕獲目標 context/scheduler 的邏輯。它有效地使前面展示的邏輯更像這樣:

object scheduler = null;
if (continueOnCapturedContext)
{
    scheduler = SynchronizationContext.Current;
    if (scheduler is null && TaskScheduler.Current != TaskScheduler.Default)
    {
        scheduler = TaskScheduler.Current;
    }
}

  換句話說,透過指定 false,即使存在要回撥的當前上下文或排程程式,也會假裝沒有上下文或排程程式。

我為什麼要使用 ConfigureAwait(false)?

  ConfigureAwait(continueOnCapturedContext: false) 用於避免在原始上下文或排程器上強制呼叫回撥。這有幾個好處:

效能改進

  如果不是呼叫它而是要排隊進行回撥,就會產生更高的成本,因為有額外的工作(通常是額外的分配),也因為這意味著某些最佳化我們在執行時不能使用(我們可以做更多的最佳化,當我們知道如何呼叫回撥,但是如果是交給任意一個抽象的實現,我們能做的就很有限了)。對於非常熱的路徑,甚至檢查當前 SynchronizationContext 和當前 TaskScheduler(兩者都涉及執行緒的靜態訪問)的額外成本也會增加可測量的開銷。如果 await 後的程式碼實際上並不需要執行在原來的上下文下,使用 ConfigureAwait(false) 可以避免所有這些成本:它不需要不必要的排隊,它可以利用所有的最佳化,而且可以避免不必要的執行緒靜態訪問。

避免死鎖

  考慮一種使用 await 某個網路下載結果的庫方法。您可以呼叫這個方法並同步地阻塞它,等待它完成,比如在返回的 Task 物件上使用 .Wait() 或 .Result或.GetAwaiter().GetResult()。現在考慮如果你呼叫它時當前 SynchronizationContext 是限制數量的操作可以執行數量為1,不管是明確地透過類似 MaxConcurrencySynchronizationContext 設定,還是隱式地使用只有一個執行緒可以被使用上下文,例如一個UI執行緒。因此,呼叫該執行緒上的方法,然後阻塞它,等待操作完成。該操作啟動了網路下載並等待它。因為在預設情況下,等待 Task 將捕獲當前的 SynchronizationContext,所以它就這樣做了,當網路下載完成時,它會返回到 SynchronizationContext,這個回撥將呼叫剩餘的操作。但是唯一能夠處理佇列回撥的執行緒目前被阻塞等待操作完成的程式碼阻塞。直到回撥處理完畢,該操作才會完成。死鎖!即使上下文沒有將併發限制為1,但是當資源受到任何形式的限制時,這也是這樣的。想象一下同樣的情況,除了使用限制為4的 MaxConcurrencySynchronizationContext。與只對操作進行一次呼叫不同,我們將對上下文4次呼叫進行排隊,每個呼叫都進行呼叫並阻塞等待它完成。在等待非同步方法完成時,我們仍然阻塞了所有的資源,而允許這些非同步方法完成的唯一事情是,它們的回撥是否能被這個已經被完全消耗的上下文處理。再次,死鎖!如果庫方法使用 ConfigureAwait(false),它就不會將回撥排隊回原始上下文,從而避免了死鎖的情況。

為什麼我想要使用 ConfigureAwait(true)?

  你不會這樣做,除非你使用它純粹是為了表明你有意不使用 ConfigureAwait(false)(例如,壓制靜態分析警告或類似的警告)。ConfigureAwait(true) 沒有任何意義。比較 await task 和 await task.ConfigureAwait(true),在功能上是相同的。如果在生產程式碼中看到 ConfigureAwait(true),則可以刪除它而不會產生不良影響。

  ConfigureAwait 方法接受一個布林值,因為在某些特定情況下,您需要傳入一個變數來控制配置。但是99%的用例帶有一個硬編碼的 false 引數值,ConfigureAwait(false)。

何時應該使用 ConfigureAwait(false)?

  這取決於:你是在實現應用程式級程式碼 app-level code 還是通用類庫程式碼?

  在編寫應用程式時,通常需要預設行為(這就是為什麼它是預設行為)。如果一個應用程式模型/環境(如Windows Forms, WPF, ASP. net)釋出一個自定義的 SynchronizationContext,幾乎可以肯定有一個很好的理由:它為關心同步上下文的程式碼提供了一種方式來與應用模型/環境進行適當的互動。如果你在 Windows 窗體應用程式中寫一個事件處理程式,在 xunit 中寫一個單元測試,在 ASP.NET MVC 控制器中寫程式碼,無論 app 模型是否實際上釋出了一個 SynchronizationContext,你都想使用那個 SynchronizationContext 如果它存在的話。這意味著預設是  ConfigureAwait(true)。只需簡單地使用 await,就可以正確地將回撥/延續傳送回原始上下文(如果存在的話)。這就導致了以下的一般指導:如果您正在編寫應用程式級別(app-level code)的程式碼,不要使用 ConfigureAwait(false)。如果你回想一下這篇文章前面的 Click 事件處理程式程式碼示例:

private static readonly HttpClient s_httpClient = new HttpClient();

private async void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    string text = await s_httpClient.GetStringAsync("http://example.com/currenttime");
    downloadBtn.Content = text;
}

  downloadBtn.Content = text 需要在原始上下文中完成。如果程式碼違反了這條準則,在不應該使用 ConfigureAwait(false) 的時候使用了它:

private static readonly HttpClient s_httpClient = new HttpClient();

private async void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    string text = await s_httpClient.GetStringAsync("http://example.com/currenttime").ConfigureAwait(false); // bug
    downloadBtn.Content = text;
}

  ASP.NET 應用依賴於 HttpContext.Current;使用 ConfigureAwait(false),然後嘗試使用 HttpContext.Current 很可能會導致問題。

  相反,通用庫是“通用的”,部分原因是它們不關心使用它們的環境。您可以從 web 應用程式、客戶端應用程式或測試中使用它們,這並不重要,因為庫程式碼與它可能使用的應用程式模型無關。不關心也意味著它不會做任何需要以特定方式與應用模型互動的事情,例如它不會訪問UI控制元件,因為一個通用庫對UI控制元件一無所知。因為這樣我們就不需要在任何特定的環境中執行程式碼,所以我們可以避免強制延續/回撥到原始上下文,我們可以透過使用 ConfigureAwait(false) 來做到這一點,並獲得它帶來的效能和可靠性好處。這就導致了以下的一般指導:如果您正在編寫通用庫程式碼 general-purpose library code,請使用 ConfigureAwait(false)。這就是為什麼 .NET Core 執行時類庫中的每個 await(或者幾乎每個)都使用 ConfigureAwait(false);除了少數例外,剩下的很可能是一個需要修復的錯誤。例如,修復了 HttpClient中 缺失的 ConfigureAwait(false) 呼叫。

  當然,與所有指導一樣,也有例外。例如,通用庫中較大的例外之一(或者至少是需要思考的類別)是當這些庫具有接受委託呼叫的 api 時。在這種情況下,庫的呼叫者傳遞潛在的由庫呼叫的應用級程式碼,從而有效地呈現了通用庫應該“通用”的假設。例如,考慮 LINQ 的 Where 方法的非同步版本, public static async IAsyncEnumerable WhereAsync(this IAsyncEnumerable source, Func<t, bool=""> predicate)。這裡的 predicate 需要在呼叫者的原始 SynchronizationContext 上回撥嗎?這要由 WhereAsync 的實現來決定,這也是它選擇不使用 ConfigureAwait(false) 的一個原因。

  即使在這些特殊的情況下,一般的指導仍然是一個很好的起點:如果您正在編寫通用的庫 general-purpose library/應用程式模型無關的程式碼 app-model-agnostic code,那麼使用 ConfigureAwait(false),否則就不需要。

ConfigureAwait(false) 是否保證回撥不會在原始上下文中執行?

  不。ConfigureAwait (false) 可以保證它不會被排隊回原來的上下文中,但這並不意味著 await task.ConfigureAwait(false) 之後的程式碼不會在原來的上下文中執行。這是因為對已經完成的可等待物件的等待只是在 await 過程中同步執行,而不是強迫任何物件排隊返回。因此,如果 await 一個已經完成的任務,無論是否使用 ConfigureAwait(false),緊隨其後的程式碼都將繼續在當前執行緒上執行,無論上下文是否仍然是當前的。

  是否可以只在方法中第一個await 處使用 ConfigureAwait(false),而不是在其餘的中使用?

  一般來說,不行。參見前面的內容,如果等待 await task.ConfigureAwait(false) 包含一個在等待的時候已經完成的任務(這實際上是非常常見的),然後使用 ConfigureAwait (false) 將毫無意義,隨著執行緒繼續執行後面的程式碼上下文還是之前的上下文。

  一個值得注意的例外是,如果您知道第一個 await 總是非同步完成的,並且被等待的物件將在一個沒有自定義 SynchronizationContext 或 TaskScheduler 的環境中呼叫它的回撥。例如,.NET 執行時庫中的 CryptoStream 希望確保其潛在的計算密集型程式碼不會作為呼叫者的同步呼叫的一部分執行,因此它使用自定義的 awaiter 來確保第一次await 之後的所有程式碼都線上程池執行緒上執行。然而,即使在這種情況下,您也會注意到下一個 await 仍然使用 ConfigureAwait(false);從技術上講,這不是必需的,但是它使程式碼檢查變得容易得多,另外,每次檢視這段程式碼時,都不需要進行分析來理解為什麼沒有使用 ConfigureAwait(false)。

我可以使用 Task.Run 來避免使用 ConfigureAwait(false) 嗎?

  是的,如果你這樣寫:

Task.Run(async delegate
{
    await SomethingAsync(); // won't see the original context
});

  SomethingAsync() 的 ConfigureAwait(false) 呼叫將是 Nop,因為委託傳遞給 Task.Run 將線上程池執行緒上執行,而沒有使用者程式碼在堆疊的更高位置,例如 SynchronizationContext.Current 將返回 null。進一步, Task.Run 隱式使用 TaskScheduler.Default,這意味著查詢 TaskScheduler.Current 的內部委託也將返回 Default。這意味著無論是否使用 ConfigureAwait(false),await 都會顯示相同的行為。它也不能保證這個 lambda 中的程式碼可以做什麼。如果你的程式碼:

Task.Run(async delegate
{
    SynchronizationContext.SetSynchronizationContext(new SomeCoolSyncCtx());
    await SomethingAsync(); // will target SomeCoolSyncCtx
});

  然後 SomethingAsync 中的程式碼會看到 SynchronizationContext.Current 作為 SomeCoolSyncCtx 的例項,這兩個 await 和在 SomethingAsync 任何未配置的 await 將回到它。因此,要使用這種方法,您需要了解您正在排隊的所有程式碼可能做什麼,也可能不做什麼,以及它的操作是否會妨礙您的操作。

  這種方法的代價是需要建立/排隊額外的 task 物件。這對你的應用程式或庫來說可能重要,也可能無關緊要,這取決於你的效能敏感性。

  還要記住,這些技巧可能會導致比它們本身價值更多的問題,併產生其他意想不到的後果。例如,靜態分析工具(如 Roslyn 分析程式)已經被用來標記不使用 ConfigureAwait(false) 的等待,比如 CA2007。如果您啟用了這樣的分析器,但是使用了這樣的技巧來避免使用 ConfigureAwait,那麼分析器很有可能會標記它,從而給您帶來更多的工作。因此,您可能會因為分析器的噪音而禁用它,而現在您在程式碼庫中丟失了實際上應該使用 ConfigureAwait(false) 的其他位置。

我可以使用 SynchronizationContext.SetSynchronizationContext 來避免使用 ConfigureAwait(false)?

  不。嗯,也許吧。這取決於所涉及的程式碼。

  一些開發人員這樣寫程式碼:

Task t;
SynchronizationContext old = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(null);
try
{
    t = CallCodeThatUsesAwaitAsync(); // awaits in here won't see the original context
}
finally { SynchronizationContext.SetSynchronizationContext(old); }
await t; // will still target the original context

   期望是它將使 CallCodeThatUsesAwaitAsync 內的程式碼看到當前上下文為空。它會的。但是,上面的操作不會影響 await 在 TaskScheduler.Current 中看到的內容。因此,如果這段程式碼執行在一些自定義 TaskScheduler上, CallCodeThatUsesAwaitAsync 內的 await(並沒有使用 ConfigureAwait(false))仍將看到當前上下文並且排隊返回到自定義 TaskScheduler。

  所有相同的警告也適用於之前的 Task.Run 相關的 FAQ:這樣的解決方案有其潛在的影響,並且 try 中的程式碼還可以透過設定不同的上下文(或使用非預設的TaskScheduler呼叫程式碼)來阻止這些意圖。

  對於這種模式,你還需要注意細微的變化:

SynchronizationContext old = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(null);
try
{
    await t;
}
finally { SynchronizationContext.SetSynchronizationContext(old); }

  看出這個問題了嗎?它很難發現,但影響很大。這裡無法保證 await 最終會呼叫回撥/延續原來的執行緒,這意味著 SynchronizationContext 回到最初的重置實際上可能不會發生在原始的執行緒,這可能會導致後續工作項線上程看到錯誤的上下文(要解決這個問題,現有精心編寫的應用程式模型,並編寫一個自定義上下文,新增一個方法,用於在進一步呼叫使用者程式碼之前手動復位上下文)(to counteract this, well-written app models that set a custom context generally add code to manually reset it before invoking any further user code)。即使它碰巧在同一個執行緒上執行,也可能需要一段時間才能完成,因此上下文在一段時間內無法適當地恢復。如果它在不同的執行緒上執行,它可能會在該執行緒上設定錯誤的上下文,等等,這與想象的相去甚遠。

我在用 GetAwaiter().GetResult(),我需要使用 ConfigureAwait(false) 嗎?

  不。ConfigureAwait 隻影響回撥。具體來說,awaiter 模式要求 awaiter 公開一個 IsCompleted 屬性、一個 GetResult 方法和一個 OnCompleted 方法(可選的 UnsafeOnCompleted 方法)。ConfigureAwait 隻影響 {Unsafe}OnCompleted 的行為,所以如果您只是直接呼叫 awaiter 的  GetResult() 方法,無論您是在 TaskAwaiter 上還是在 ConfiguredTaskAwaitable.ConfiguredTaskAwaiter 上執行它,對行為沒有任何影響。因此,如果在程式碼中看到  task.ConfigureAwait(false).GetAwaiter().GetResult(),可以用 task.GetAwaiter().GetResult() 替換它(還要考慮是否真的希望這樣阻塞)。

我知道我執行在一個永遠不會有自定義 SynchronizationContext 或自定義 TaskScheduler 的環境中。我可以跳過使用 ConfigureAwait(false) 嗎?

  也許吧。這取決於你對“從不”的那部分有多肯定。正如在前面的常見問題中提到的,僅僅因為你工作的應用模型沒有設定自定義 SynchronizationContext,也沒有在自定義 TaskScheduler 上呼叫你的程式碼,並不意味著其他使用者或庫程式碼沒有這樣做。所以你需要確定事實並非如此,或者至少認識到可能存在的風險。

我聽說 ConfigureAwait(false) 在.NET Core中不再需要了,真的嗎?

  假的。在 .NET Core 上執行時需要它,原因和在 .NET Framework 上執行時是一樣的。在這方面沒有什麼改變。

  但是,發生變化的是某些環境是否釋出了它們自己的 SynchronizationContext。統的 ASP.NET 在 .NET Framework  上有自己的 SynchronizationContext,而 ASP.NET Core 則不然。這意味著在  ASP.NET Core 中執行的程式碼預設不會看到自定義的 SynchronizationContext,這減少了在這樣的環境中執行 ConfigureAwait(false) 的需要。

  但是,這並不意味著永遠不會出現自定義的 SynchronizationContext 或 TaskScheduler。如果一些使用者程式碼(或你的應用程式正在使用的其他庫程式碼)設定了一個自定義上下文並呼叫你的程式碼,或者在排程到自定義 TaskScheduler 的 Task 中呼叫你的程式碼,那麼即使在 ASP.NET Core 中你的  await 可能會看到一個非預設的上下文或排程程式,這會導致你想要使用 ConfigureAwait(false)。當然,在這種情況下,如果您避免同步阻塞(無論如何都應該避免在 web 應用程式中這樣做),如果您不介意在這種有限的情況下的小的效能開銷,您可能不需要使用ConfigureAwait(false)。

當我在 await foreach IAsyncEnumerable 時,我可以使用 ConfigureAwait 嗎?

  是的。參見 MSDN 雜誌的這篇文章中的示例(https://docs.microsoft.com/en-us/archive/msdn-magazine/2019/november/csharp-iterating-with-async-enumerables-in-csharp-8)。

  await foreach 繫結到一個模式,因此,雖然它可以用於列舉 IAsyncEnumerable,但它也可以用於列舉公開正確 API 。. NET 執行時庫包括 IAsyncEnumerable 上的一個 ConfigureAwait 擴充套件方法,該方法返回一個自定義型別,該型別封裝了 IAsyncEnumerable和一個 Boolean 值,並公開了正確的模式。當編譯器生成對列舉器的 MoveNextAsync 和 DisposeAsync 方法的呼叫時,這些呼叫是對返回的已配置列舉器結構型別的呼叫,然後編譯器以所需的配置方式執行等待。

當我在 await using IAsyncDisposable 時,我可以使用 ConfigureAwait 嗎?

  是的,不過有點小麻煩。

  和前面 FAQ 中描述的 IAsyncEnumerable 一樣,.NET 執行時庫在 IAsyncDisposable 上公開了一個 ConfigureAwait 擴充套件方法,並且 await using 將愉快地與之工作,因為它實現了適當的模式(即公開一個適當的 DisposeAsync 方法):

await using (var c = new MyAsyncDisposableClass().ConfigureAwait(false))
{
    ...
}

  這裡的問題是,c 的型別現在不是 MyAsyncDisposableClass,而是 System.Runtime.CompilerServices.ConfiguredAsyncDisposable,它是 IAsyncDisposable 上的 ConfigureAwait 擴充套件方法返回的型別。

  為了解決這個問題,你需要多寫一行:

var c = new MyAsyncDisposableClass();
await using (c.ConfigureAwait(false))
{
    ...
}

  現在,c 的型別再次是所需的 MyAsyncDisposableClass。這也增加了 c 的作用域;如果這是有效的,你可以把整個程式碼用大括號括起來。

我使用了 ConfigureAwait(false),但是我的 AsyncLocal 仍然在 await 之後流到程式碼中,這是 bug 嗎?

  不,這是意料之中的。AsyncLocal資料流作為 ExecutionContext 的一部分,它與 SynchronizationContext 是分開的。除非您使用 ExecutionContext.SuppressFlow() 顯式禁用了 ExecutionContext 流,否則無論是否使用 ConfigureAwait 來避免捕獲原始的 SynchronizationContext,  ExecutionContext(以及 AsyncLocal資料)都將始終在 await 中流動。更多資訊,請看這篇部落格文章 https://devblogs.microsoft.com/pfxteam/executioncontext-vs-synchronizationcontext。

語言能幫助我避免需要在我的庫中顯式地使用 ConfigureAwait(false) 嗎?

  庫開發人員有時會對需要使用 ConfigureAwait(false) 表示失望,並要求使用侵入性更小的替代方案。

  目前還沒有,至少沒有內建到語言/編譯器/執行時中。然而,對於這樣的解決方案可能會是什麼樣子,有許多建議,例如:

https://github.com/dotnet/csharplang/issues/645, https://github.com/dotnet/csharplang/issues/2542, https://github.com/dotnet/csharplang/issues/2649,

https://github.com/dotnet/csharplang/issues/2746.

  如果這對你很重要,或者你覺得你在這裡有新的有趣的想法,我鼓勵你在這些或新的討論中貢獻你的想法。

原文連結

  https://devblogs.microsoft.com/dotnet/configureawait-faq/?utm_source=vs_developer_news&utm_medium=referral

 

相關文章