理解C#中的ConfigureAwait

xiaoxiaotank發表於2020-08-20

原文:https://devblogs.microsoft.com/dotnet/configureawait-faq/
作者:Stephen
翻譯:xiaoxiaotank
靜下心來,你一定會有收穫。

七年前(原文釋出於2019年).NET的程式語言和框架庫新增了async/await語法糖。自那以後,它猶如星火燎原一般,不僅遍及整個.NET生態,還被許許多多的其他語言和框架所借鑑。當然,.NET也有很大改進,就拿對使用非同步的語言結構上的補充來說,它提供了非同步API支援,並對async/await的基礎架構進行了根本改進(特別是 .NET Core中效能和可分析性的提升)。

然而,大家對ConfigureAwait的原理和使用仍然有一些困惑。接下來,我們會從SynchronizationContext開始講起,然後過渡到ConfigureAwait,希望這篇文章能夠為你解惑。廢話少說,進入正文。

什麼是SynchronizationContext?

System.Threading.SynchronizationContext的文件是這樣說的:“提供在各種同步模型中傳播同步上下文的基本功能”,太抽象了。

在99.9%的使用場景中,SynchronizationContext僅僅被當作一個提供虛(virtual)Post方法的類,該方法可以接收一個委託,然後非同步執行它。雖然SynchronizationContext還有許多其他的虛成員,但是很少使用它們,而且和我們今天的內容無關,就不說了。Post方法的基礎實現就僅僅是呼叫一下ThreadPool.QueueUserWorkItem,將接收的委託加入執行緒池佇列去非同步執行。

另外,派生類可以選擇重寫(override)Post方法,讓委託在更加合適的位置和時間去執行。

例如,WinForm有一個派生自SynchronizationContext的類,重寫了Post方法,內部執行Control.BeginInvoke,這樣,呼叫該Post方法就會在該控制元件的UI執行緒上執行接收的委託。WinForm依賴Win32的訊息處理機制,並在UI執行緒上執行“訊息迴圈”,該執行緒就是簡單的等待新訊息到達,然後去處理。這些訊息可能是滑鼠移動和點選、鍵盤輸入、系統事件、可供呼叫的委託等。所以,只需要將委託傳遞給SynchronizationContext例項的Post方法,就可以在控制元件的UI執行緒中執行。

和WinForm一樣,WPF也有一個派生自SynchronizationContext的類,重寫了Post方法,通過Dispatcher.BeginInvoke將接收的委託封送到UI執行緒。與WinForm通過控制元件管理不同的是,WPF是由Dispatcher管理的。

Windows執行時(WinRT)也不例外,它有一個派生自SynchronizationContext的類,重寫了Post方法,通過CoreDispatcher將接收的委託排隊送到UI執行緒。

當然,不僅僅“在UI執行緒中執行該委託”這一種用法,任何人都可以重寫SynchronizationContextPost方法做任何事。例如,我可能不會關心委託在哪個執行緒上執行,但是我想確保任何在我自定義的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的派生類,和我寫的這個很類似,用於限制可以併發的測試相關的程式碼量。

與抽象的優點一樣:它提供了一個API,可用於將委託排隊進行處理,無需瞭解該實現的細節,這是實現者所期望的。所以,如果我正在編寫一個庫,想要停下來做一些工作,然後將委託排隊送回“原始上下文”繼續執行,那麼我只需要獲取他們的SynchronizationContext,存下來。當完成工作後,在該上下文上呼叫Post去傳遞我想要呼叫的委託即可。我不需在WinForm中知道要獲取一個控制元件並呼叫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); 
        }
    });
}

如果框架想要通過Current公開自定義的上下文,可以使用SynchronizationContext.SetSynchronizationContext方法進行設定。

什麼是TaskScheduler?

SynchronizationContext是對“排程程式(scheduler)”的通用抽象。個別框架會有自己的抽象排程程式,比如System.Threading.Tasks。當Tasks通過委託的形式進行排隊和執行時,會用到System.Threading.Tasks.TaskScheduler。和SynchronizationContext提供了一個virtual Post方法用於將委託排隊呼叫一樣(稍後,我們會通過典型的委託呼叫機制來呼叫委託),TaskScheduler也提供了一個abstract QueueTask方法(稍後,我們會通過ExecuteTask方法來呼叫該Task)。

通過TaskScheduler.Default我們可以獲取到Task預設的排程程式ThreadPoolTaskScheduler——執行緒池(譯註:這下知道為什麼Task預設使用的是執行緒池執行緒了吧)。並且可以通過繼承TaskScheduler來重寫相關方法來實現在任意時間任意地點進行Task呼叫。例如,核心庫中有個類,名為System.Threading.Tasks.ConcurrentExclusiveSchedulerPair,其例項公開了兩個TaskScheduler屬性,一個叫ExclusiveScheduler,另一個叫ConcurrentScheduler。排程給ConcurrentScheduler的任務可以併發,但是要在構造ConcurrentExclusiveSchedulerPair時就要指定最大併發數(類似於前面演示的MaxConcurrencySynchronizationContext);相反,在ExclusiveScheduler執行任務時,那麼將只允許執行一個排他任務,這個行為很像讀寫鎖。

SynchronizationContext一樣,TaskScheduler也有一個Current屬性,會返回當前排程程式。不過,和SynchronizationContext不同的是,它沒有設定當前排程程式的方法,而是在啟動Task時就要提供,因為當前排程程式是與當前執行的Task相關聯的。所以,下方的示例程式會輸出“True”,這是因為和StartNew一起使用的lambda表示式是在ConcurrentExclusiveSchedulerPairExclusiveScheduler上執行的(我們手動指定cesp.ExclusiveScheduler),並且TaskScheduler.Current也會指向該ExclusiveScheduler

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方法,該方法會建立一個SynchronizationContextTaskScheduler例項並返回,以便在原始的SynchronizationContext.Current上的Post方法對任務進行排隊執行。

SynchronizationContext和TaskScheduler是如何與await關聯起來的呢?

假設有一個UI App,它有一個按鈕。當點選按鈕後,會從網上下載一些文字並將其設定為按鈕的內容。我們應當只在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執行緒上設定了按鈕的內容,與上面手動實現的版本一樣,await Task預設會關注SynchronizationContext.CurrentTaskScheduler.Current兩個引數。當你在C#中使用await時,編譯器會進行程式碼轉換來向“可等待者”(這裡為Task)索要(通過呼叫GetAwaiter)“awaiter”(這裡為TaskAwaiter<string>)。該awaiter負責掛接回撥(通常稱為“繼續(continuation)”),當等待的物件完成時,該回撥將被封送到狀態機,並使用在註冊回撥時捕獲的上下文或排程程式來執行此回撥。儘管與實際程式碼不完全相同(實際程式碼還進行了其他優化和調整),但大體上是這樣的:

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

說人話就是,它先檢查有沒有設定當前SynchronizationContext,如果沒有,則再判斷當前排程程式是否為預設的TaskScheduler。如果不是,那麼當準備好呼叫回撥時,會使用該排程程式執行回撥;否則,通常會作為完成已等待任務的操作的一部分來執行回撥(譯註:這個“否則”我也沒看懂,我的理解是如果有當前上下文,則使用當前上下文執行回撥;如果當前上下文為空,且使用的是預設排程程式ThreadPoolTaskScheduler,則會啟用執行緒池執行緒執行回撥)。

ConfigureAwait(false)做了什麼?

ConfigureAwait方法並沒有什麼特別:編譯器或執行時均不會以任何特殊方式對其進行標識。它僅僅是一個返回結構體(ConfiguredTaskAwaitable)的方法,該結構體包裝了呼叫它的原始任務以及呼叫者指定的布林值。注意,await可以用於任何正確模式的型別(而不僅僅是Task,在C#中只要類包含GetAwaiter() 方法和bool IsCompleted屬性,並且GetAwaiter()的返回值包含 GetResult()方法、bool IsCompleted屬性和實現了 INotifyCompletion介面,那麼這個類的例項就是可以await 的)。當編譯器訪問例項的GetAwaiter方法(模式的一部分)時,它是根據ConfigureAwait返回的型別進行操作的,而不是直接使用Task,此外,還提供了一個鉤子,用於通過該自定義awaiter更改await的行為。

具體來說,如果等待ConfigureAwait(continueOnCapturedContext:false)返回的型別ConfiguredTaskAwaitable,而非直接等待Task,最終會影響上面展示的捕獲目標上下文或排程程式的邏輯。它使得上面展示的邏輯變成了這樣:

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)用於避免強制在原始上下文或排程程式中進行回撥,有以下好處:

提升效能

比起直接呼叫,排隊進行回撥會更加耗費效能,一個是因為會有一些額外的工作(一般是額外的記憶體分配),另一個是因為無法使用我們本來希望在執行時中採用的某些優化(當我們確切知道回撥將如何呼叫時,我們可以進行更多優化,但如果將其移交給抽象的任意實現,則有時會受到限制)。對於大多數情況,即使檢查當前的SynchronizationContextTaskScheduler也可能會增加一定的開銷(兩者都會訪問執行緒靜態變數)。如果await之後的程式碼並不需要在原始上下文中執行,那麼使用ConfigureAwait(false)就可以避免上述花銷:它不用排隊,且可以利用所有可以進行的優化,還可以避免不必要的執行緒靜態訪問。

避免死鎖

假如有一個方法,使用await等待網路下載結果,你需要通過同步阻塞的方式呼叫該方法等待其完成,比如使用.Wait().Result.GetAwaiter().GetResult()

思考一下,如果限制當前SynchronizationContext併發數為1,會發生什麼情況?方式不限,無論是顯式地通過類似於前面所說的MaxConcurrencySynchronizationContext的方式,還是隱式地通過僅具有一個可以使用的執行緒的上下文來實現,例如UI執行緒,你都可以在那個執行緒上呼叫該方法並阻塞它等待操作完成,該操作將開啟網路下載並等待。在預設情況下, 等待Task會捕獲當前SynchronizationContext,所以,當網路下載完成時,它會將回撥排隊返回到SynchronizationContext中執行剩下的操作。但是,當前唯一可以處理排隊回撥的執行緒卻還被你阻塞著等待操作完成,不幸的是,在回撥處理完畢之前,該操作永遠不會完成。完蛋,死鎖了!

即使不將上下文併發數限制為1,而是通過其他任何方式對資源進行了限制,結果也是如此。比如,我們將MaxConcurrencySynchronizationContext限制為4,這時,我們對該上下文進行4次排隊呼叫,每個呼叫都會進行阻塞等待操作完成。現在,我們在等待非同步方法完成時仍阻塞了所有資源,這些非同步方法能否完成取決於是否可以在已經完全消耗掉的上下文中處理它們的回撥。哦吼,又死鎖了!

如果該方法改為使用ConfigureAwait(false),那麼它就不會將回撥排隊送回原始上下文,進而避免了死鎖。

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

絕對沒必要使用,除非你閒的蛋疼使用它來表明你是故意不使用ConfigureAwait(false)的(例如消除VS的靜態分析警告或類似的警告等),使用ConfigureAwait(true)沒有任何意義。await taskawait task.ConfigureAwait(true)在功能上沒有任何區別,如果你在生產環境的程式碼中發現了ConfigureAwait(true),那麼你可以直接刪除它,不會有任何副作用。

ConfigureAwait方法接收一個布林值引數,可能在某些特殊情況下,你需要通過傳入變數來控制配置,不過,99%的情況下都是通過硬編碼的方式傳入的,如ConfigureAwait(false)

什麼時候應該使用ConfigureAwait(false)?

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

當你編寫應用程式時,你通常需要使用預設行為(這就是ConfigureAwait(true)是預設行為的原因(譯註:原作者應該是想要表達編寫應用程式比通用庫更加頻繁,所以該行為會更頻繁的使用))。如果應用模型或環境(例如WinForm,WPF,ASP.NET Core等)釋出了自定義SynchronizationContext,那麼基本上可以肯定有一個很好的理由:它為關注同步上下文的程式碼提供了一種與應用模型或環境適當互動的方式。所以如果你使用WinForm寫事件處理器、在xunit中寫單元測試或在ASP .NET MVC控制器中編碼,無論應用程式模型是否確實釋出了SynchronizationContext,您都想使用該SynchronizationContext(如果存在),那麼您可以簡單地await預設的ConfigureAwait(true),如果存在回撥,就可以將其正確地封送到原始上下文中執行。這就形成了以下一般指導:如果您正在編寫應用程式級程式碼,請不要使用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;
}

這將導致出現錯誤的結果。依賴於HttpContext.Current的經典ASP.NET應用程式中的程式碼也是如此,使用ConfigureAwait(false)然後嘗試使用HttpContext.Current也可能會導致問題。

相反,通用庫之所以成為“通用庫”,原因之一是因為它們不關心使用它們的環境。您可以在Web應用程式、客戶端應用程式或測試程式中使用它們,這無關緊要,因為庫程式碼與可能使用的應用程式模型無關。那麼,無關就意味著它不會做任何需要以特定方式與應用程式模型進行互動的事情,例如:它不會訪問UI控制元件,因為通用庫對UI控制元件一無所知。由於我們不需要在任何特定環境中執行程式碼,那麼我們可以避免將回撥強制送回到原始上下文,這可以通過使用ConfigureAwait(false)來實現,並享受到其帶來的效能和可靠性優勢。這形成了以下一般指導:如果要編寫通用庫程式碼,請使用ConfigureAwait(false)。這就是為什麼您會在.NET Core執行時庫中看到每個(或幾乎每個)await時都要使用ConfigureAwait(false)的原因;如果不是這樣的話(除了少數例外),那很可能是一個要修復的BUG。例如,此Pull request修復了HttpClient中缺少的ConfigureAwait(false)呼叫。

當然,與其他指導一樣,在某些特殊的情況下可能不適用。例如,在通用庫中,具有可呼叫委託的API是一個較大的例外(或至少需要考慮的例外)。在這種情況下,庫的呼叫者可能會傳遞由庫呼叫的應用程式級程式碼,然後有效地呈現了庫那些“通用”假設。例如,以LINQ中Where的非同步版本(執行時庫不存在該方法,僅僅是假設)為例:public static async IAsyncEnumerable<T> WhereAsync(this IAsyncEnumerable<T> source, Func<T, bool> predicate)。這裡的predicate是否需要在呼叫者的原始SynchronizationContext上重新呼叫?這要取決於WhereAsync的實現,因此,它可能選擇不使用ConfigureAwait(false)

即使有這些特殊情況,一般指導仍然是一個很好的起點:如果要編寫通用庫或與應用程式模型無關的程式碼,請使用ConfigureAwait(false),否則請不要這樣做。

以下是一些常見問題

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

並不能保證!它雖能保證它不會被排隊回到原始上下文中……但這並不意味著await task.ConfigureAwait(false)後的程式碼仍不會在原始上下文中執行。因為當等待已經完成的可等待物件時(即Task例項返回時該Task已經完成了),後續程式碼將會保持同步執行,而無需強制排隊等待。所以,如果您等待的任務在等待時就已經完成了,那麼無論您是否使用了ConfigureAwait(false),緊隨其後的程式碼也會在擁有當前上下文的當前執行緒上繼續執行。

我的方法中僅在第一次await時使用ConfigureAwait(false)而剩下的程式碼不使用可以嗎?

一般來說,不行,參考前面的FAQ。如果await task.ConfigureAwait(false)在等待時就已完成了(實際上很常見),那麼ConfigureAwait(false)將毫無意義,因為執行緒在此之後繼續在該方法中執行程式碼,並且仍在與之前相同的上下文中執行。

有一個例外是:如果您知道第一次等待始終會非同步完成,並且正在等待的事物會在沒有自定義SynchronizationContextTaskScheduler的環境中呼叫其回撥。例如,.NET執行時庫中的CryptoStream希望確保其潛在的計算密集型程式碼不會被呼叫者以同步方式進行呼叫,因此它使用自定義的awaiter來確保第一次等待後的所有內容都線上程池執行緒上執行。但是,即使在這種情況下,您也會注意到下一次等待仍將使用ConfigureAwait(false);從技術上講,使用ConfigureAwait(false)不是必需的,但是它使程式碼審查變得很容易,這樣每次檢視該塊程式碼時,就無需分析一番來了解為什麼取消ConfigureAwait(false)

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

是的,你可以這樣寫:

Task.Run(async delegate
{
    await SomethingAsync(); // 不會找到原始上下文
});

沒有必要對SomethingAsync呼叫ConfigureAwait(false),因為傳遞給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例項,並且該awaitSomethingAsync內部的所有未配置的await都將返回到該上下文。因此,要使用這種方式,您需要了解排隊的所有程式碼可能會做什麼或不做什麼,以及它的行為是否會阻礙您的行為。

這種方法還需要以建立或排隊其他任務物件為代價。這取決於您的效能敏感性,對您的應用程式或庫而言可能無關緊要。

另外要注意,這些技巧可能會引起更多的問題,並帶來其他意想不到的後果。例如,靜態分析工具(例如Roslyn分析儀)提供了標記不使用ConfigureAwait(false)的標誌等待,正如CA2007。如果啟用了這樣的分析器,並採用該技巧來避免使用ConfigureAwait,那麼分析器很有可能會標記它,這其實會給您帶來更多工作。那麼,也許您可能會因為其煩擾而禁用了分析器,這將會導致您忽略程式碼庫中實際上應該一直使用ConfigureAwait(false)的其他程式碼。

我能用SynchronizationContext.SetSynchronizationContext來避免使用ConfigureAwait(false)嗎?

不行! 額。。好吧,也許可以。這取決於你寫的程式碼。可能一些開發者這樣寫:

Task t;
var old = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(null);
try
{
    t = CallCodeThatUsesAwaitAsync(); // 在方法內部進行 await 不會感知到原始上下文
}
finally 
{
    SynchronizationContext.SetSynchronizationContext(old); 
}
await t; // 這時則會回到原始上下文

我們希望CallCodeThatUsesAwaitAsync中的程式碼看到的當前上下文是null,而且確實如此。但是,以上內容不會影響TaskScheduler的等待狀態,因此,如果此程式碼在某些自定義TaskScheduler上執行,那麼在CallCodeThatUsesAwaitAsync(不使用ConfigureAwait(false))內部等待後仍將排隊返回該自定義TaskScheduler

所有這些注意事項也適用於前面Task.Run相關的FAQ:這種解決方法可能會帶來一些效能方面的問題,並且try中的程式碼也可以通過設定其他上下文(或使用非預設TaskScheduler來呼叫程式碼)來阻止這種嘗試。

使用這種模式,您還需要注意一些細微的變化:

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

找到問題沒?可能很難發現但是影響很大。這樣寫沒法保證await最終會回到原始執行緒上執行回撥並繼續執行生下的程式碼,也就是說將SynchronizationContext重置回原始上下文這個操作可能實際上並未在原始執行緒上進行,這可能導致該執行緒上的後續工作項看到錯誤的上下文(為解決這一問題,具有良好編碼規範的應用模型在設定了自定義上下文時,通常會在呼叫任何其他使用者程式碼之前新增程式碼以手動將其重置)。而且即使它確實在同一執行緒上執行,也可能要等一會兒,這樣一來,上下文仍無法適當恢復。而且,如果它在其他執行緒上執行,可能最終會在該執行緒上設定錯誤的上下文。等等。很不理想。

如果我用了GetAwaiter().GetResult(),我還需要使用ConfigureAwait(false)嗎?

不需要ConfigureAwait隻影響回撥。具體來說,awaiter模式要求awaiters 公開IsCompleted屬性、GetResult方法和OnCompleted方法(可選使用UnsafeOnCompleted方法)。ConfigureAwait只會影響OnCompleted/UnsafeOnCompleted的行為,因此,如果您只是直接呼叫等待者的GetResult()方法,那麼你無論是在TaskAwaiter上還是在ConfiguredTaskAwaitable.ConfiguredTaskAwaiter上進行操作,都是沒有任何區別的。因此,如果在程式碼中看到task.ConfigureAwait(false).GetAwaiter().GetResult(),則可以將其替換為task.GetAwaiter().GetResult()(並考慮是否真的需要這樣的阻塞)。

我知道我的執行環境永遠不會具有自定義SynchronizationContext或自定義TaskScheduler

我可以跳過使用ConfigureAwait(false)嗎?
也許可以,這取決於你是如何確定“永遠不會”的。 如之前的FAQ,僅僅因為您正在使用的應用程式模型未設定自定義SynchronizationContext且未在自定義TaskScheduler上呼叫您的程式碼並不意味著其他使用者或庫程式碼未設定。因此,您需要確保不存在這種情況,或至少要意識到這種風險。

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

假的! 在.NET Core上執行時仍需要使用它,和在.NET Framework上執行時需要使用的原因完全相同,在這方面沒有任何改變。

不過,有一些變化的是某些環境是否釋出了自己的SynchronizationContext。特別是雖然在.NET Framework上的經典ASP.NET具有自己的SynchronizationContext,但是ASP.NET Core卻沒有。這意味著預設情況下,在ASP.NET Core應用程式中執行的程式碼是看不到自定義SynchronizationContext的,從而減少了在這種環境中執行ConfigureAwait(false)的需要。

但這並不意味著永遠不會存在自定義的SynchronizationContextTaskScheduler。如果某些使用者程式碼(或您的應用程式正在使用的其他庫程式碼)設定了自定義上下文並呼叫了您的程式碼,或在自定義TaskScheduler的預定Task中呼叫您的程式碼,那麼即使在ASP.NET Core中,您的等待物件也可能會看到非預設上下文或排程程式,從而促使您想要使用ConfigureAwait(false)。當然,在這種情況下,如果您想要避免同步阻塞(任何情況下,都應避免在Web應用程式中進行同步阻塞),並且不介意在這種有限的情況下有細微的效能開銷,那您可能無需使用ConfigureAwait(false)就可以實現。

我在await using一個IAsyncDisposable的物件時我可以使用ConfigureAwait嗎?

可以,不過有些小問題。 與前面的FAQ中所述的IAsyncEnumerable<T>一樣,.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在等待之後仍然流向程式碼,那是個BUG嗎?

不,這是預期的。 AsyncLocal<T>資料流是ExecutionContext的一部分,它與SynchronizationContext是相互獨立的。除非您使用ExecutionContext.SuppressFlow()明確禁用了ExecutionContext流,否則ExecutionContext(以及AsyncLocal<T>資料)將始終在等待狀態中流動,無論是否使用ConfigureAwait來避免捕獲原始的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

如果這對您很重要,或者您有新的有趣的想法,我鼓勵您為這些或新的討論貢獻自己的想法。

相關文章