天天看點

了解C#中的ConfigureAwait

原文: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線程中執行該委托”這一種用法,任何人都可以重寫

SynchronizationContext

Post

方法做任何事。例如,我可能不會關心委托在哪個線程上執行,但是我想確定任何在我自定義的

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表達式是在

ConcurrentExclusiveSchedulerPair

ExclusiveScheduler

上執行的(我們手動指定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.Current

TaskScheduler.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)

用于避免強制在原始上下文或排程程式中進行回調,有以下好處:

提升性能

比起直接調用,排隊進行回調會更加耗費性能,一個是因為會有一些額外的工作(一般是額外的記憶體配置設定),另一個是因為無法使用我們本來希望在運作時中采用的某些優化(當我們确切知道回調将如何調用時,我們可以進行更多優化,但如果将其移交給抽象的任意實作,則有時會受到限制)。對于大多數情況,即使檢查目前的

SynchronizationContext

TaskScheduler

也可能會增加一定的開銷(兩者都會通路線程靜态變量)。如果

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 task

await 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)

将毫無意義,因為線程在此之後繼續在該方法中執行代碼,并且仍在與之前相同的上下文中執行。

有一個例外是:如果您知道第一次等待始終會異步完成,并且正在等待的事物會在沒有自定義

SynchronizationContext

TaskScheduler

的環境中調用其回調。例如,.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

執行個體,并且該

await

SomethingAsync

内部的所有未配置的

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)

的需要。

但這并不意味着永遠不會存在自定義的

SynchronizationContext

TaskScheduler

。如果某些使用者代碼(或您的應用程式正在使用的其他庫代碼)設定了自定義上下文并調用了您的代碼,或在自定義

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

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