理解C#中的ExecutionContext vs SynchronizationContext

原文:https://devblogs.microsoft.com/pfxteam/executioncontext-vs-synchronizationcontext/
做者:Stephen
翻譯:xiaoxiaotank
不來深刻了解一下?html

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

雖然原文發佈於2012年,可是內容放到今日仍不過期。好,開始吧!安全

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

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

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

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

ExecutionContext是與「環境」信息相關的,也就是說它會存儲與你當前正在運行的環境或「上下文」相關的數據。在許多系統中,這類環境信息使用線程本地存儲(TLS)來維護,例如ThreadStatic標記的字段或ThreadLocal<T>。在同步的世界裏,這種線程本地信息就足夠了:全部的一切都運行在該線程上,所以,不管你在該線程上使用什麼棧幀、正在執行什麼功能,等等,在該線程上運行的全部代碼均可以查看並受該線程特定數據的影響。例如,ExecutionContext盛放的一個上下文叫作SecurityContext,它維護了諸如當前「principal」之類的信息以及有關代碼訪問安全性(CAS)拒絕和容許的信息。這類信息能夠與當前線程相關聯,這樣的話,若是一個棧幀的訪問被某個權限拒絕了而後調用另外一個方法,那麼該調用的方法仍會被拒絕:當嘗試執行須要該權限的操做時,CLR會檢查當前線程是否容許該操做,而且它也會找到調用者放入的數據。async

當從同步世界過渡到異步世界時,事情就變得複雜了起來。忽然之間,TLS變得可有可無。在同步的世界裏,若是我先執行操做A,而後再執行操做B,最後執行操做C,那麼這三個操做都會在同一線程上執行,因此這三個操做都會受該線程上存儲的環境數據的影響。可是在異步的世界裏,我可能在一個線程上啓動A,而後在另外一個線程上完成它,這樣操做B就能夠在不一樣於A的線程上啓動或運行,而且相似地C也能夠在不一樣於B的線程上啓動或運行。 這意味着咱們用來控制執行細節的環境再也不可行,由於TLS不會在這些異步點上「流動」。線程本地存儲特定於某個線程,而這些異步操做並不與特定線程綁定。不過,咱們但願有一個邏輯控制流,且環境數據能夠與該控制流一塊兒流動,以便環境數據能夠從一個線程移動到另外一個線程。這就是ExecutionContext發揮的做用。ui

ExecutionContext實際上只是一個狀態包,可用於從一個線程捕獲全部當前狀態,而後在控制邏輯繼續流動的同時將其還原到另外一個線程。經過靜態Capture方法來捕獲ExecutionContext編碼

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

在調用委託時,經過靜態Run方法將環境狀態還原回來:spa

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不會在等待點之間「流動」,你放心好了。

相關文章
相關標籤/搜索