理解C#中的ExecutionContext vs SynchronizationContext


原文: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不會在等待點之間“流動”,你放心好了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM