" 返回《C 併發編程》" "1. 簡介" "2. 同步非同步對比" "3. 上下文的捕獲和恢復" "4. Flowing ExecutionContext vs Using SynchronizationContext" "5. 如何適用於 async/await" "5.1. 實現方式" "5.1 ...
- 1. 簡介
- 2. 同步非同步對比
- 3. 上下文的捕獲和恢復
- 4. Flowing ExecutionContext vs Using SynchronizationContext
- 5. 如何適用於 async/await
- 6. 兩者的關係
- 7. 說明
1. 簡介
註意: 本篇文章講述的是在 .Net Framework 環境下的分析, 但是我相信這與 .Net Core 設計思想是一致,但在實現上一定優化了很多。
下麵開始本次講述:
ExecutionContext 實際上只是線程相關其他上下文的容器。
- 有些上下文起輔助作用
- 有些上下文對 .Net 執行模型至關重要
ExecutionContext 與周圍環境的信息有關,這意味著,代碼正在運行時,它存儲了與 當前環境 或 “context” 有關的數據。
周圍環境: 代碼執行處,可以訪問到的變數、方法、屬性等等。
2. 同步非同步對比
在同步世界:
- 在許多系統中,此類“周圍”的信息在線程本地存儲(TLS)中維護,例如在
[ThreadStatic]
欄位或ThreadLocal<T>
中。- 在同步世界中,這樣的 thread-local 信息就足夠了。
- 任何事情發生在該線程上,也就是不管在該線程上所處的堆棧結構是什麼,正在執行什麼方法,等等。
- 所有在該線程上運行的代碼都可以查看和影響該線程特有的數據。
- 在同步世界中,這樣的 thread-local 信息就足夠了。
在非同步世界,TLS變得無關緊要,同步非同步對比:
- 同步
- 例如:
- 如果我先執行操作 A
- 然後執行操作 B
- 然後執行操作 C
- 則所有這三個操作都在同一線程上發生
- 因此所有這三個操作都受該線程上存儲的周圍環境數據的影響。
- 例如:
- 非同步
- 例如:
- 我可能在一個線程上啟動 A
- 然後在另一個線程上完成它
- 這樣操作 B 可以在不同於 A 的線程上啟動或運行
- 並且類似地使 C 可以在不同於 B 的線程上啟動或運行。
- 這意味著我們用來控制執行細節的周圍環境context不再可行,因為TLS不會“流”過這些非同步點。
- Thread-local 存儲特定於線程,這些非同步操作並不與特定線程相關聯。
- 但是,通常存在邏輯控制流,我們希望這些周圍環境的數據與該控制流一起流動,以使周圍環境的數據從一個線程移動到另一個線程
- 這就 需要 ExecutionContext 來完成這些操作。
- 例如:
3. 上下文的捕獲和恢復
ExecutionContext 實際上是一個 state 包
- 用於從一個線程上捕獲所有 state
- 然後在控制邏輯流的同時將其還原到另一個線程
ExecutionContext 是使用靜態方法 Capture 捕獲的:
// 周圍環境的 state 捕獲到 ec 中
ExecutionContext ec = ExecutionContext.Capture();
通過靜態方法 Run ,在委托(Run方法的參數)調用時恢復 ExecutionContext
ExecutionContext.Run(ec, delegate
{
… // 這裡的代碼將上述 ec 的狀態視為周圍環境
}, null);
所有派生非同步工作的方法都以這種方式捕獲和還原 ExecutionContext 的。
- 帶有“Unsafe”字樣的方法除外,它們是不安全的,因為它們不傳播 ExecutionContext
例如:
- 當您使用
Task.Run
時,對Run
的調用將從調用線程中捕獲 ExecutionContext ,並將該 ExecutionContext 實例存儲到Task
對象中 - 當提供給
Task.Run
的委托作為該Task
執行的一部分被調用時,它是使用存儲的 ExecutionContext 通過ExecutionContext.Run
來完成的
以下所有非同步API的執行都是捕獲 ExecutionContext 並將其存儲,然後在調用某些代碼時再使用存儲的 ExecutionContext。
Task.Run
ThreadPool.QueueUserWorkItem
Delegate.BeginInvoke
Stream.BeginRead
DispatcherSynchronizationContext.Post
- 任何其他非同步API
當我們談論“flowing ExecutionContext”時,我們實際上是在討論:
- 在一個線程上獲取周圍環境狀態
- 在稍後的某個時刻將該狀態恢復到另一個線程上(需要執行提供的委托的線程)。
4. Flowing ExecutionContext vs Using SynchronizationContext
前面我們介紹了 SynchronizationContext
是如何調度線程的,現在,我們要進行進行一次對比:
- flowing ExecutionContext 在語義上與 capturing and posting to a SynchronizationContext 完全不同。
- 當 ExecutionContext 流動時,您是從一個線程捕獲 state ,然後還原該 state
- 使提供的委托執行時處於周圍環境 state
- 當您捕獲並使用 SynchronizationContext 時,不會發生這種情況。
- 捕獲部分是相同的,因為您要從當前線程中獲取數據,但是隨後用不同方式使用 state
SynchronizationContext.Post
只是使用捕獲的狀態來調用委托,而不是在調用委托時設置該狀態為當前狀態- 該委托在何時何地以及如何運行完全取決於Post方法的實現
5. 如何適用於 async/await
async
和 await
關鍵字背後的框架支持會自動與 ExecutionContext 和 SynchronizationContext 交互。
每當代碼等待一個可等待項(awaitable),該可等待項(awaitable) 的 等待者(awaiter) 說尚未完成時
- 即等待者(awaiter) 的
IsCompleted
返回false
則該方法需要暫停,並通過等待者(awaiter) 的 continuation
來恢復。
等待者(awaiter) : 可以理解為
await
產生的 Task對象。
5.1. 實現方式
5.1.1. ExecutionContext
- 前面已經提到過了, ExecutionContext 需要從發出
await
的代碼一直流到continuation
委托的執行。- 這是由框架自動處理的
- 當
async
方法即將掛起時,基礎設施將捕獲 ExecutionContext - 得到的委托交給等待者(awaiter) ,而且此等待者(awaiter) 具有對此 ExecutionContext 實例的引用,並將在恢復該方法時使用它。
- 由
ExecutionContext
帶領,啟用重要的周圍環境信息,去流過 awaits 。
5.1.2. SynchronizationContext
該框架還支持 SynchronizationContext 。前述對 ExecutionContext 的支持內置於表示 async
方法的“構建器”中
- 例如
System.Runtime.CompilerServices.AsyncTaskMethodBuilder
- 即
await
/async
會被編譯成執行碼
並且這些構建器可確保 ExecutionContext 跨 await
點流動,無論使用哪種可等待項(awaitable)。
相反,對 SynchronizationContext 的支持內置在 awaiting
的且已經構建好的Task
和 Task<TResult>
中
自定義的等待者(awaiter) (比如 new Task(...)
)可以自己添加類似的邏輯,但是不會自動獲得實例化時的SynchronizationContext
- 這是設計使然,因為能夠自定義何時以及如何調用 continuation 是自定義Task有用的一部分原因。
5.2. 執行過程
5.2.1. SynchronizationContext 使用和控制
- 當您
await
一個 task 時,預設情況下,等待者(awaiter) 將捕獲當前的 SynchronizationContext(如果有的話) - 在 task 完成時將
Post
這個前面提供的 continuation 委托並回到該 context 進行執行- 運行委托的:不是在完成了 task 的線程上,也不是在
ThreadPool
的線程上
- 運行委托的:不是在完成了 task 的線程上,也不是在
如果開發人員不希望這種封送處理行為,則可以通過更改在那裡使用的 可等待項(awaitable) / 等待者(awaiter) 來控制它。
- 大多數情況,等待
Task
或Task<TResult>
就時採用上述方式 - 可以通過
await
方法task.ConfigureAwait(…)
的返回值來修改這種封送處理行為ConfigureAwait()
返回一個 可等待項(awaitable),它可以抑制此預設的封送處理行為。ConfigureAwait()
的唯一bool
類型參數continueOnCapturedContext
- 為 true ,那麼將獲得預設行為;
- 為 false ,則等待者(awaiter) 不檢查 SynchronizationContext ,就像沒有一樣
- 註意: 當等待的任務完成時,無論
ConfigureAwait
如何,在恢復執行的線程上,運行時都會檢查當前的 context ,以確定:- continuation 是否可以在此處同步運行
- continuation 是否必須從此處開始非同步調度(scheduled asynchronously)
5.2.2. ExecutionContext 的流動無法控制
儘管 ConfigureAwait
提供了,用於改變 SynchronizationContext 行為的、顯示的、與 await
相關的編程模型,但是沒有用於抑制 ExecutionContext
流動的、與 await
相關的編程模型支持。
- 這是故意的;
- 開發人員在編寫非同步代碼時不必擔心 ExecutionContext ;
- 它在基礎架構級別上的支持,有助於在非同步環境中模擬同步方式的語義(即TLS);
6. 兩者的關係
7. 說明
SynchronizationContext 不是 ExecutionContext 的一部分嗎?
- ExecutionContext 能夠帶著所有的上下文(例如 SecurityContext , HostExecutionContext , CallContext 等)流動
- 確實也包括 SynchronizationContext
- 我個人認為,這是API設計的一個錯誤,自從它在許多版本的.NET中提出以來,就引起了一些問題
- 註意這個問題在 .Net Core 已經解決
- .Net Core 中的 ExecutionContext 已不包含任何其他 context
當您調用公共 ExecutionContext.Capture()
方法時,它將檢查當前的 SynchronizationContext ,如果有,則將其存儲到返回的 ExecutionContext 實例中。然後,當使用公共 ExecutionContext.Run(...)
方法時,在提供的委托執行期間,該捕獲的 SynchronizationContext 被恢復為 Current 。
為什麼這有問題?作為 ExecutionContext 的一部分而流動的 SynchronizationContext 更改了 SynchronizationContext.Current
的含義。
應該可以通過 SynchronizationContext.Current
返回到你最近調用 Current
時的環境
- 因此,如果
SynchronizationContext
流出,成為另一個線程的當前SynchronizationContext
,則SynchronizationContext.Current
就沒有意義了,所以不是這樣設計的。
7.1. 示例
解釋此問題的一個示例,代碼如下:
private async void button1_Click(object sender, EventArgs e)
{
button1.Text = await Task.Run(async delegate
{
string data = await DownloadAsync();
return Compute(data);
});
}
7.1.1. 運行過程解析
- 用戶單擊 button1 ,導致UI框架在UI線程上調用 button1_Click 事件;
- 然後,代碼啟動一個 WorkItem 在 ThreadPool 上運行(通過
Task.Run
);- WorkItem 在 ThreadPool介紹-非同步調用方法 中提到;
- 這個 WorkItem 開始一些下載工作,並非同步等待其完成;
- 在下載完成之後,ThreadPool 上的 WorkItem 進行一些密集型操作(
Compute(data)
); - 返回結果
- WorkItem 執行完成後,導致正在 UI線程 上等待的
Task
完成 - (下載得到結果,返回結果),成為 UI線程 等待完成的 ;
- 然後,UI線程 處理 button1_Click 方法的剩餘部分: 保存計算結果到
button1.Text
屬性。
7.1.2. 帶來的思考
如果 SynchronizationContext 不作為 ExecutionContext 的一部分流動,我的預期就是有根據的。
如果 SynchronizationContext 流動了,無論如何,我將感到非常失望。
假設:SynchronizationContext 作為 ExecutionContext 的一部分流動:
Task.Run
在調用時捕獲 ExecutionContext ,並使用它運行傳遞給它委托。- 這就意味著
Task.Run
調用時的當前 SynchronizationContext 將流動到Task
中,而且將在DownloadAsync
執行和等待結果期間成為當前 SynchronizationContext ,- 這意味著這個
await
將看到當前SynchronizationContext
,並Post
非同步方法的其餘部分作為一個 continuation 返回到 UI線程 上運行。
- 這意味著這個
- 這意味著我的
Compute
方法將在 UI線程 上運行,而不是在 ThreadPool 上運行,從而導致我的應用程式出現響應性問題。 從實際結果來看這是不對的,假設執行的代碼更像下麵的
private async void button1_Click(object sender, EventArgs e) { string data = await DownloadAsync(); button1.Text = Compute(data); }
實際: 現在,我們看看實際是如何處理的:
Task.Run(...)
這種非同步Api的實現:
- 解讀捕獲(Capture)和運行(Run);
- ExecutionContext 實際上有兩個
Capture
方法:- 但是只有一個是
public
,供外部使用 - 那個
internal
的方法,是 mscorlib 大多數公開的非同步功能(如:Task.Run(...)
)所使用的一個- 這個方法有選擇地允許調用方抑制捕獲 SynchronizationContext 作為 ExecutionContext 的一部分;
- 但是只有一個是
- 與此相對應的是,
Run
方法的internal
重載也支持忽略存儲在 ExecutionContext 中的 SynchronizationContext- 實際上是假裝沒有被捕獲(此外,這也是 mscorlib 中大多數方法使用的重載)。
- ExecutionContext 實際上有兩個
這意味著:
- 在 mscorlib 中幾乎包含所有非同步操作的核心實現,這裡不會將 SynchronizationContext 作為 ExecutionContext 的一部分流動
- 位於其他地方的,任何非同步操作的核心實現,都將使 SynchronizationContext 作為 ExecutionContext 的一部分流動。
標識 async
關鍵字方法的實現:
- 之前我曾提到,非同步方法的 “builders” 是負責在
async
方法中流動 ExecutionContext 所使用的方式- 這些 builders 確實存在於 mscorlib 中,並且確實使用
internal
的重載做一些事情。
- 這些 builders 確實存在於 mscorlib 中,並且確實使用
- 同樣的, SynchronizationContext 不會作為 ExecutionContext 的一部分流動穿過 awaits
- 此外,這與 task awaiters 如何支持 捕獲 SynchronizationContext 和將其
Post
回來是分開的 - 實現方式: 為了幫助處理 ExecutionContext 帶著 SynchronizationContext 流動的情況,
async
方法的基礎設施嘗試忽略由於流動而將 SynchronizationContexts 設置為 Current 。
- 此外,這與 task awaiters 如何支持 捕獲 SynchronizationContext 和將其
- 簡而言之,
SynchronizationContext.Current
不會“流動”穿過await
點。
參考資料:
《ExecutionContext vs SynchronizationContext》 --- Stephen Toub