理解C#中的ExecutionContext vs SynchronizationContext

xiaoxiaotank發表於2020-09-15

原文:https://devblogs.microsoft.com/pfxteam/executioncontext-vs-synchronizationcontext/
作者:Stephen
翻譯:xiaoxiaotank
不來深入瞭解一下?

為了更好的理解本文內容,強烈建議先看一下理解C#中的ConfigureAwait

雖然原文釋出於2012年,但是內容放到今日仍不過時。好,開始吧!

最近,有人問了我幾個關於ExecutionContextSynchronizationContext的問題,例如:它們倆有什麼區別?“流動”它們有什麼意義?它們與C#和VB中新的async/await語法糖有什麼關係?我想通過本文來解決其中一些問題。

注意:本文深入到了.NET的高階領域,大多數開發人員都無需關注。

什麼是ExecutionContext,流動它有什麼意義?

對於絕大多數開發者來說,不需要關注ExecutionContext。它的存在就像空氣一樣:雖然它很重要,但我們一般是不會關注它的,除非有必要(例如出現問題時)。ExecutionContext本質上只是一個用於盛放其他上下文的容器。這些被盛放的上下文中有一些僅僅是輔助性的,而另一些則對於.NET的執行模型至關重要,不過它們都和ExecutionContext一樣:除非你不得不知道他們存在,或你正在做某些特別高階的事情,或者出了什麼問題(,否則你沒必要關注它)。

ExecutionContext是與“環境”資訊相關的,也就是說它會儲存與你當前正在執行的環境或“上下文”相關的資料。在許多系統中,這類環境資訊使用執行緒本地儲存(TLS)來維護,例如ThreadStatic標記的欄位或ThreadLocal<T>。在同步的世界裡,這種執行緒本地資訊就足夠了:所有的一切都執行在該執行緒上,因此,無論你在該執行緒上使用什麼棧幀、正在執行什麼功能,等等,在該執行緒上執行的所有程式碼都可以檢視並受該執行緒特定資料的影響。例如,ExecutionContext盛放的一個上下文叫做SecurityContext,它維護了諸如當前“principal”之類的資訊以及有關程式碼訪問安全性(CAS)拒絕和允許的資訊。這類資訊可以與當前執行緒相關聯,這樣的話,如果一個棧幀的訪問被某個許可權拒絕瞭然後呼叫另一個方法,那麼該呼叫的方法仍會被拒絕:當嘗試執行需要該許可權的操作時,CLR會檢查當前執行緒是否允許該操作,並且它也會找到呼叫者放入的資料。

當從同步世界過渡到非同步世界時,事情就變得複雜了起來。突然之間,TLS變得無關緊要。在同步的世界裡,如果我先執行操作A,然後再執行操作B,最後執行操作C,那麼這三個操作都會在同一執行緒上執行,所以這三個操作都會受該執行緒上儲存的環境資料的影響。但是在非同步的世界裡,我可能在一個執行緒上啟動A,然後在另一個執行緒上完成它,這樣操作B就可以在不同於A的執行緒上啟動或執行,並且類似地C也可以在不同於B的執行緒上啟動或執行。 這意味著我們用來控制執行細節的環境不再可行,因為TLS不會在這些非同步點上“流動”。執行緒本地儲存特定於某個執行緒,而這些非同步操作並不與特定執行緒繫結。不過,我們希望有一個邏輯控制流,且環境資料可以與該控制流一起流動,以便環境資料可以從一個執行緒移動到另一個執行緒。這就是ExecutionContext發揮的作用。

ExecutionContext實際上只是一個狀態包,可用於從一個執行緒捕獲所有當前狀態,然後在控制邏輯繼續流動的同時將其還原到另一個執行緒。通過靜態Capture方法來捕獲ExecutionContext

// 把環境狀態捕捉到ec中
ExecutionContext ec = ExecutionContext.Capture();

在呼叫委託時,通過靜態Run方法將環境狀態還原回來:

ExecutionContext.Run(ec, delegate
{
    … // 此處的程式碼會將ec的狀態視為環境
}, null);

.NET Framework中所有非同步工作的方法都是以這種方式捕獲和還原ExecutionContext的(除了那些以“Unsafe”為字首的方法,這些方法都是不安全的,因為它們顯式的不流動ExecutionContext)。例如,當你使用Task.Run時,對Run的呼叫會導致捕獲呼叫執行緒的ExecutionContext,並將該ExecutionContext例項儲存到Task物件中。稍後,當傳遞給Task.Run的委託作為該Task執行的一部分被呼叫時,會通過呼叫ExecutionContext.Run方法,使委託在剛才儲存的上下文中執行。Task.RunThreadPool.QueueUserWorkItemDelegate.BeginInvokeStream.BeginReadDispatcherSynchronizationContext.Post,以及你可以想到的任何其他非同步API,都是這樣的。它們全都會捕獲ExecutionContext,儲存起來,然後在呼叫某些程式碼時使用它。

當我們討論“流動ExecutionContext”時,指的就是這個過程,即獲取一個執行緒上的環境狀態,然後在執行傳遞的委託時,將狀態還原到執行執行緒上。

什麼是SynchronizationContext,捕獲和使用它有什麼意義?

在軟體開發中,我們喜歡抽象。我們幾乎不會願意對特定的實現進行硬編碼,相反,在編寫大型系統時,我們更原意將特定實現的細節抽象化,以便以後可以插入其他實現,而不必更改我們的大型系統。這就是我們有介面、抽象類,虛方法等的原因。

SynchronizationContext只是一種抽象,代表你要執行某些操作的特定環境。舉個例子,WinForm擁有UI執行緒(雖然可能有多個,但出於討論目的,這並不重要),需要使用UI控制元件的任何操作都需要在上面執行。為了處理需要先線上程池執行緒上執行然後再封送回UI執行緒,以便該操作可以與UI控制元件一起處理的情形,WinForm提供了Control.BeginInvoke方法。你可以向控制元件的BeginInvoke方法傳遞一個委託,該委託將在與該控制元件關聯的執行緒上被呼叫。

因此,如果我正在編寫一個需要線上程池執行緒執行一部分工作,然後在UI執行緒上再進行一部分工作的元件,那我可以使用Control.BeginInvoke。但是,如果我現在要在WPF應用程式中使用我的元件該怎麼辦?WPF具有與WinForm相同的UI執行緒約束,但封送回UI執行緒的機制不同:不是通過Control.BeginInvoke,而是在Dispatcher例項上呼叫Dispatcher.BeginInvoke(或InvokeAsync)。

現在,我們有兩個不同的API用於實現相同的基本操作,那麼如何編寫與UI框架無關的元件呢?當然是通過使用SynchronizationContextSynchronizationContext提供了一個虛Post方法,該方法只接收一個委託,並在任何地點,任何時間執行它,當然SynchronizationContext的實現要認為是合適的。WinForm提供了WindowsFormSynchronizationContext類,該類重寫了Post方法來呼叫Control.BeginInvoke。WPF提供了DispatcherSynchronizationContext類,該類重寫Post方法來呼叫Dispatcher.BeginInvoke,等等。這樣,我現在可以在元件中使用SynchronizationContext,而不需要將其繫結到特定框架。

如果我要專門針對WinForm編寫元件,則可以像這樣來實現先進入執行緒池,然後返回到UI執行緒的邏輯:

public static void DoWork(Control c)
{
    ThreadPool.QueueUserWorkItem(delegate
    {
        … // 線上程池中執行
        
        c.BeginInvoke(delegate
        {
            … // 在UI執行緒中執行
        });
    });
}

如果我把元件改成使用SynchronizationContext,就可以這樣寫:

public static void DoWork(SynchronizationContext sc)
{
    ThreadPool.QueueUserWorkItem(delegate
    {
        … // 線上程池中執行
        
        sc.Post(delegate
        {
            … // 在UI執行緒中執行
        }, null);
    });
}

當然,需要傳遞目標上下文(即sc)來返回顯得很煩人(對於某些所需的程式設計模型而言,這是無法容忍的),因此,SynchronizationContext提供了Current屬性,該屬性使你可以從當前執行緒中尋找上下文,如果存在的話,它會把你返回到該環境。你可以這樣“捕獲”它(即從SynchronizationContext.Current中讀取引用,並儲存該引用以供以後使用):

public static void DoWork()
{
    var sc = SynchronizationContext.Current;
    ThreadPool.QueueUserWorkItem(delegate
    {
        … // 線上程池中執行
        
        sc.Post(delegate
        {
            … // 在原始上下文中執行
        }, null);
   });
}

流動ExecutionContext vs 使用SynchronizationContext

現在,我們有一個非常重要的發現:流動ExecutionContext在語義上與捕獲SynchronizationContext並Post完全不同。

當流動ExecutionContext時,你是從一個執行緒中捕獲狀態,然後在提供的委託執行期間將該狀態恢復回來。而你捕獲並使用SynchronizationContext時,不會出現這種情況。捕獲部分是相同的,因為你要從當前執行緒中獲取資料,但是後續使用狀態的方式不同。SynchronizationContext是通過SynchronizationContext.Post來使用捕獲的狀態呼叫委託,而不是在委託呼叫期間將狀態恢復為當前狀態。該委託在何時何地以及如何執行完全取決於Post方法的實現。

這是如何運用於async/await的?

asyncawait關鍵字背後的框架支援自動與ExecutionContextSynchronizationContext互動。
每當程式碼等待一個awaiter,awaiter說它尚未完成(例如awaiter.IsCompleted返回false)時,該方法需要暫停,然後通過awaiter的延續(Continuation)來恢復,這是我之前提到的非同步點之一。因此,ExecutionContext需要從發出等待的程式碼一直流動到延續委託的執行,這會由框架自動處理。當非同步方法即將掛起時,基礎架構會捕獲ExecutionContext。傳遞給awaiter的委託會擁有該ExecutionContext例項的引用,並在恢復該方法時使用它。這就是使ExecutionContext表示的重要“環境”資訊跨等待流動的原因。

該框架還支援SynchronizationContext。前面對ExecutionContext的支援內建於表示非同步方法的“構建器”中(例如System.Runtime.CompilerServices.AsyncTaskMethodBuilder),並且這些構建器可確保ExecutionContext跨等待點流動,而不管使用哪種等待方式。相反,對SynchronizationContext的支援已內建在等待TaskTask <TResult>的支援中。自定義awaiter可以自己新增類似的邏輯,但不會自動獲取。這是設計使然,因為自定義何時以及後續如何呼叫是自定義awaiter使用的原因之一。

預設情況下,當你等待Task時,awaiter將捕獲當前的SynchronizationContext,當Task完成時,會將提供的延續(Continuation)委託封送到該上下文去執行,而不是在任務完成的執行緒上,或在ThreadPool上執行該委託。如果開發人員不希望這種封送行為,則可以通過更改使用的awaiter來進行控制。雖然在等待TaskTask <TResult>時始終會採用這種行為,但你可以改為等待task.ConfigureAwait(…)ConfigureAwait方法返回一個awaitable,它可以阻止預設的封送處理行為。是否阻止由傳遞給ConfigureAwait方法的布林值控制。如果continueOnCapturedContext為true,就是預設行為;否則,如果為false,那麼awaiter不會檢查SynchronizationContext,假裝好像沒有一樣。(注意,待完成的Task完成後,無論ConfigureAwait如何,執行時(runtime)可能會檢查正在恢復的執行緒上的當前上下文,以確定是否可以在此處同步執行延續,或必須從那時開始非同步安排延續。)

注意,儘管ConfigureAwait為更改與SynchronizationContext相關的行為提供了顯式的與等待相關的程式設計模型支援,但沒有用於阻止ExecutionContext流動的與等待相關的程式設計模型支援,就是故意這樣設計的。開發人員在編寫非同步程式碼時無需關注ExecutionContext。它在基礎架構級別的支援,可幫助你在非同步環境中模擬同步語義(即TLS)。大多數人可以並且應該完全忽略它的存在(除非他們真的知道自己在做什麼,否則應避免使用ExecutionContext.SuppressFlow方法)。相反,開發人員應該意識到程式碼在哪裡執行,因此SynchronizationContext上升到了值得顯式程式設計模型支援的水平。(實際上,正如我在其他文章中所述,大多數類庫開發者都應考慮在每次Task等待時使用ConfigureAwait(false)。)

SynchronizationContext不是ExecutionContext的一部分嗎?

到目前為止,我掩蓋了一些細節,但是我還是沒法避免它們。

我掩蓋的主要內容是ExecutionContext能夠流動的所有上下文(例如SecurityContextHostExecutionContextCallContext等),SynchronizationContext實際上就是其中之一。我個人認為,這是API設計中的一個錯誤,這是自許多版本的.NET首次提出以來引起的一些問題。不過,這是我們已經使用了很長時間的設計,如果現在進行更改那將是一項中斷性更改。

當你呼叫公共的ExecutionContext.Capture()方法時,該方法將檢查當前的SynchronizationContext,如果有,則將其儲存到返回的ExecutionContext例項中。然後,當你使用公共的ExecutionContext.Run方法時,在執行提供的委託期間,捕獲的SynchronizationContext會被恢復為Current

這有什麼問題?作為ExecutionContext的一部分流動的SynchronizationContext更改了SynchronizationContext.Current的含義。SynchronizationContext.Current應該可以使你返回到訪問Current時所處的環境,因此,如果SynchronizationContext流到了另一個執行緒上成為Current,那麼你就無法信任SynchronizationContext.Current的含義。在這種情況下,它可能用於返回到當前環境,也可能用於回到流中先前某個時刻所處的環境。(譯註:一定要看到文章末尾,否則你可能會產生誤解)

舉一個可能出現這種問題的例子,請參考以下程式碼:

private void button1_Click(object sender, EventArgs e)
{
    button1.Text = await Task.Run(async delegate
    {
        string data = await DownloadAsync();
        return Compute(data);
    });
}

我的思維模式告訴我,這段程式碼會發生這種情況:使用者單擊button1,導致UI框架在UI執行緒上呼叫button1_Click。然後,程式碼啟動一個在ThreadPool上執行的操作(通過Task.Run)。該操作將開始一些下載工作,並非同步等待其完成。然後,ThreadPool上的延續操作會對下載的結果進行一些計算密集型操作,並返回結果,最終使正在UI執行緒上等待的Task完成。接著,UI執行緒會處理該button1_Click方法的其餘部分,並將計算結果儲存到button1的Text屬性中。

如果SynchronizationContext不會作為ExecutionContext的一部分流動,那麼這是我所期望的。但是,如果流動了,我會感到非常失望。Task.Run會在呼叫時捕獲ExecutionContext,並使用它來執行傳遞給它的委託。這意味著呼叫Task.Run時所處的UI執行緒的SynchronizationContext將流入Task,並且在await DownloadAsync時再次作為Current流入。這意味著await將會找到UI的SynchronizationContext.Current,並Post該方法的剩餘部分作為在UI執行緒上執行的延續。也就表示我的Compute方法很可能會在UI執行緒上執行,而不是在ThreadPool上執行,從而導致我的應用程式出現響應問題。

現在,這個故事有點混亂了:ExecutionContext實際上有兩個Capture方法,但是隻公開了一個。mscorlib公開的大多數非同步功能所使用的是內部的(mscorlib內部的)Capture方法,並且它可選地允許呼叫方阻止捕獲SynchronizationContext作為ExecutionContext的一部分;對應於Run方法的內部過載也支援忽略儲存在ExecutionContext中的SynchronizationContext,實際上是假裝沒有被捕獲(同樣,這是mscorlib中大多數功能使用的過載)。這意味著幾乎所有在mscorlib中的非同步操作的核心實現都不會將SynchronizationContext作為ExecutionContext的一部分進行流動,但是在其他任何地方的任何非同步操作的核心實現都將捕獲SynchronizationContext作為ExecutionContext的一部分。我上面提到了,非同步方法的“構建器”是負責在非同步方法中流動ExecutionContext的型別,這些構建器是存在於mscorlib中的,並且使用的確實是內部過載……(當然,這與task awaiter捕獲SynchronizationContext並將其Post回去是互不影響的)。為了處理ExecutionContext確實流動了SynchronizationContext的情況,非同步方法基礎結構會嘗試忽略由於流動而設定為CurrentSynchronizationContexts

簡而言之,SynchronizationContext.Current不會在等待點之間“流動”,你放心好了。

相關文章