原文:https://devblogs.microsoft.com/pfxteam/executioncontext-vs-synchronizationcontext/
做者:Stephen
翻譯:xiaoxiaotank
不來深刻了解一下?html
爲了更好的理解本文內容,強烈建議先看一下理解C#中的ConfigureAwait。編程
雖然原文發佈於2012年,可是內容放到今日仍不過期。好,開始吧!安全
最近,有人問了我幾個關於ExecutionContext
和SynchronizationContext
的問題,例如:它們倆有什麼區別?「流動」它們有什麼意義?它們與C#和VB中新的async/await
語法糖有什麼關係?我想經過本文來解決其中一些問題。架構
注意:本文深刻到了.NET的高級領域,大多數開發人員都無需關注。框架
對於絕大多數開發者來講,不須要關注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.Run
、ThreadPool.QueueUserWorkItem
、Delegate.BeginInvoke
、Stream.BeginRead
、DispatcherSynchronizationContext.Post
,以及你能夠想到的任何其餘異步API,都是這樣的。它們全都會捕獲ExecutionContext
,存儲起來,而後在調用某些代碼時使用它。
當咱們討論「流動ExecutionContext
」時,指的就是這個過程,即獲取一個線程上的環境狀態,而後在執行傳遞的委託時,將狀態還原到執行線程上。
在軟件開發中,咱們喜歡抽象。咱們幾乎不會願意對特定的實現進行硬編碼,相反,在編寫大型系統時,咱們更原意將特定實現的細節抽象化,以便之後能夠插入其餘實現,而沒必要更改咱們的大型系統。這就是咱們有接口、抽象類,虛方法等的緣由。
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框架無關的組件呢?固然是經過使用SynchronizationContext
。SynchronizationContext
提供了一個虛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
在語義上與捕獲SynchronizationContext
並Post徹底不一樣。
當流動ExecutionContext
時,你是從一個線程中捕獲狀態,而後在提供的委託執行期間將該狀態恢復回來。而你捕獲並使用SynchronizationContext
時,不會出現這種狀況。捕獲部分是相同的,由於你要從當前線程中獲取數據,可是後續使用狀態的方式不一樣。SynchronizationContext
是經過SynchronizationContext.Post
來使用捕獲的狀態調用委託,而不是在委託調用期間將狀態恢復爲當前狀態。該委託在什麼時候何地以及如何運行徹底取決於Post
方法的實現。
async
和await
關鍵字背後的框架支持自動與ExecutionContext
和SynchronizationContext
交互。
每當代碼等待一個awaiter,awaiter說它還沒有完成(例如awaiter.IsCompleted
返回false
)時,該方法須要暫停,而後經過awaiter的延續(Continuation)來恢復,這是我以前提到的異步點之一。所以,ExecutionContext
須要從發出等待的代碼一直流動到延續委託的執行,這會由框架自動處理。當異步方法即將掛起時,基礎架構會捕獲ExecutionContext
。傳遞給awaiter的委託會擁有該ExecutionContext
實例的引用,並在恢復該方法時使用它。這就是使ExecutionContext
表示的重要「環境」信息跨等待流動的緣由。
該框架還支持SynchronizationContext
。前面對ExecutionContext
的支持內置於表示異步方法的「構建器」中(例如System.Runtime.CompilerServices.AsyncTaskMethodBuilder
),而且這些構建器可確保ExecutionContext
跨等待點流動,而無論使用哪一種等待方式。相反,對SynchronizationContext
的支持已內置在等待Task
和Task <TResult>
的支持中。自定義awaiter能夠本身添加相似的邏輯,但不會自動獲取。這是設計使然,由於自定義什麼時候以及後續如何調用是自定義awaiter使用的緣由之一。
默認狀況下,當你等待Task時,awaiter將捕獲當前的SynchronizationContext
,當Task完成時,會將提供的延續(Continuation)委託封送到該上下文去執行,而不是在任務完成的線程上,或在ThreadPool
上執行該委託。若是開發人員不但願這種封送行爲,則能夠經過更改使用的awaiter來進行控制。雖然在等待Task
或Task <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)
。)
到目前爲止,我掩蓋了一些細節,可是我仍是無法避免它們。
我掩蓋的主要內容是ExecutionContext
可以流動的全部上下文(例如SecurityContext
,HostExecutionContext
,CallContext
等),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
的狀況,異步方法基礎結構會嘗試忽略因爲流動而設置爲Current
的SynchronizationContexts
。
簡而言之,SynchronizationContext.Current
不會在等待點之間「流動」,你放心好了。