從執行上下文角度重新理解.NET(Core)的多執行緒程式設計[2]:同步上下文
一般情況下,我們可以將某項操作分發給任意執行緒來執行,但有的操作確實對於執行的執行緒是有要求的,最為典型的場景就是:GUI針對UI元素的操作必須在UI主執行緒中執行。將指定的操作分發給指定執行緒進行執行的需求可以通過同步上下文(SynchronizationContext)來實現。你可能從來沒有使用過SynchronizationContext,但是在基於Task的非同步程式設計中,它卻總是默默存在。今天我們就來認識一下這個SynchronizationContext物件。
目錄
一、從一個GUI的例子談起
二、自定義一個SynchronizationContext
三、ConfiguredTaskAwaitable方法
四、再次回到開篇的例子
一、從一個GUI的例子談起
GUI後臺執行緒將UI操作分發給UI主執行緒進行執行時SynchronizationContext的一個非常典型的應用場景。以一個Windows Forms應用為例,我們按照如下的程式碼註冊了窗體Form1的Load事件,事件處理器負責修改當前窗體的Text屬性。由於我們使用了執行緒池,所以針對UI元素的操作(設定窗體的Text屬性)將不會再UI主執行緒中執行。
partial class Form1 { private void InitializeComponent() { ... this.Load += Form1_Load; } private void Form1_Load(object sender, EventArgs e)=>ThreadPool.QueueUserWorkItem(_ => Text = "Hello World"); }
當這個Windows Forms應用啟動之後,設定Form1的Text屬性的那行程式碼將會丟擲如下所示的InvalidOperationException異常,並提示“Cross-thread operation not valid: Control '' accessed from a thread other than the thread it was created on.”
我們可以按照如下的方式利用SynchronizationContext來解決這個問題。如程式碼片段所示,在利用執行緒池執行非同步操作之前,我們呼叫Current靜態屬性得到當前的SynchronizationContext。對於GUI應用來說,這個同步上下文將於UI執行緒繫結在一起,我們可以利用它將指定的操作分發給UI執行緒來執行。具體來說,針對UI執行緒的分發是通過呼叫其Post方法來完成的。
partial class Form1 { private void InitializeComponent() { ... this.Load += Form1_Load; } private void Form1_Load(object sender, EventArgs e) { var syncContext = SynchronizationContext.Current; ThreadPool.QueueUserWorkItem(_ => syncContext.Post(_=>Text = "Hello World", null)); } }
二、自定義一個SynchronizationContext
雖然被命名為SynchronizationContext,並且很多場景下我們利用該物件旨在非同步執行緒中同步執行部分操作的問題(比如上面這個例子),但原則上可以利用自定義的SynchronizationContext對分發給的操作進行100%的控制。在如下的程式碼中,我們建立一個FixedThreadSynchronizationContext型別,它會使用一個單一固定的執行緒來執行分發給它的操作。FixedThreadSynchronizationContext繼承自SynchronizationContext,它將分發給它的操作(體現為一個SendOrPostCallback型別的委託)置於一個佇列中,並建立一個獨立的執行緒依次提取它們並執行。
public class FixedThreadSynchronizationContext:SynchronizationContext { private readonly ConcurrentQueue<(SendOrPostCallback Callback, object State)> _workItems; public FixedThreadSynchronizationContext() { _workItems = new ConcurrentQueue<(SendOrPostCallback Callback, object State)>(); var thread = new Thread(StartLoop); Console.WriteLine("FixedThreadSynchronizationContext.ThreadId:{0}", thread.ManagedThreadId); thread.Start(); void StartLoop() { while (true) { if (_workItems.TryDequeue(out var workItem)) { workItem.Callback(workItem.State); } } } } public override void Post(SendOrPostCallback d, object state) => _workItems.Enqueue((d, state)); public override void Send(SendOrPostCallback d, object state)=> throw new NotImplementedException(); }
向SynchronizationContext分發指定的操作可以呼叫Post和Send方法,它們之間差異就是非同步和同步的差異。FixedThreadSynchronizationContext僅僅重寫了Post方法,意味著它支援非同步分發,而不支援同步分發。我們採用如下的方式來使用FixedThreadSynchronizationContext。我們先建立一個FixedThreadSynchronizationContext物件,並採用執行緒池的方式同時執行5個非同步操作。對於我們非同步操作來說,我們先呼叫靜態方法SetSynchronizationContext將建立的這個FixedThreadSynchronizationContext物件設定為當前SynchronizationContext。然後呼叫Post方法將指定的操作分發給當前SynchronizationContext。置於具體的操作,它會打印出當前執行緒池執行緒和當前操作執行執行緒的ID。
class Program { static async Task Main() {
var synchronizationContext = new FixedThreadSynchronizationContext();
for (int i = 0; i < 5; i++) { ThreadPool.QueueUserWorkItem(_ => { SynchronizationContext.SetSynchronizationContext(synchronizationContext); Invoke(); }); } Console.Read(); void Invoke() { var dispatchThreadId = Thread.CurrentThread.ManagedThreadId; SendOrPostCallback callback = _ => Console.WriteLine($"Pooled Thread: {dispatchThreadId}; Execution Thread: {Thread.CurrentThread.ManagedThreadId}"); SynchronizationContext.Current.Post(callback, null); } } }
這段演示程式執行之後會輸出如下所示的結果,可以看出從5個執行緒池執行緒分發的5個操作均是在FixedThreadSynchronizationContext繫結的那個執行緒中執行的。
三、ConfiguredTaskAwaitable方法
我知道很少人會顯式地使用SynchronizationContext上下文,但是正如我前面所說,在基於Task的非同步程式設計中,SynchronizationContext上下文其實一直在發生作用。我們可以通過如下這個簡單的例子來證明SynchronizationContext的存在。如程式碼片段所示,我們建立了一個FixedThreadSynchronizationContext物件並通過呼叫SetSynchronizationContext方法將其設定為當前SynchronizationContext。在呼叫Task.Delay方法(使用await關鍵字)等待100ms之後,我們打印出當前的執行緒ID。
class Program { static async Task Main() { SynchronizationContext.SetSynchronizationContext(new FixedThreadSynchronizationContext()); await Task.Delay(100); Console.WriteLine("Await Thread: {0}", Thread.CurrentThread.ManagedThreadId); } }
如下所示的是程式執行之後的輸出結,可以看出在await Task之後的操作實際是在FixedThreadSynchronizationContext繫結的那個執行緒上執行的。在預設情況下,Task的排程室通過ThreadPoolTaskScheduler來完成的。顧名思義,ThreadPoolTaskScheduler會將Task體現的操作分發給執行緒池中可用執行緒來執行。但是當它在分發之前會先獲取當前SynchronizationContext,並將await之後的操作分發給這個同步上下文來執行。
如果不瞭解這個隱含的機制,我們編寫的非同步程式可能會導致很大的效能問題。如果多一個執行緒均將這個FixedThreadSynchronizationContext作為當前SynchronizationContext,意味著await Task之後的操作都將分發給一個單一執行緒進行同步執行,但是這往往不是我們的真實意圖。其實這個問題很好解決,我們只需要呼叫等待Task的ConfiguredTaskAwaitable方法,並將引數設定為false顯式指示後續的操作無需再當前SynchronizationContext中執行。
class Program { static async Task Main() { SynchronizationContext.SetSynchronizationContext(new FixedThreadSynchronizationContext()); await Task.Delay(100).ConfigureAwait(false); Console.WriteLine("Await Thread: {0}", Thread.CurrentThread.ManagedThreadId); } }
再次執行該程式可以從輸出結果看出await Task之後的操作將不會自動分發給當前的FixedThreadSynchronizationContext了。
四、再次回到開篇的例子
由於SynchronizationContext的存在,所以如果將開篇的例子修改成如下的形式是OK的,因為await之後的操作會通過SynchronizationContext分發到UI主執行緒執行。
partial class Form1 { private void InitializeComponent() { ... this.Load += Form1_Load; } private async void Form1_Load(object sender, EventArgs e)
{
await Task.Delay(1000);
Text = "Hello World";
}
}
但是如果添加了ConfigureAwait(false)方法的呼叫,依然會丟擲上面遇到的InvalidOperationException異常。
partial class Form1 { private void InitializeComponent() { ... this.Load += Form1_Load; }
private async void Form1_Load(object sender, EventArgs e)
{
await Task.Delay(1000).ConfigureAwait(false);
Text = "Hello World";
}
}
從執行上下文角度重新理解.NET(Core)的多執行緒程式設計[1]:基於呼叫鏈的”引數”傳遞
從執行上下文角度重新理解.NET(Core)的多執行緒程式設計[2]:同步上下文
從執行上下文角度重新理解.NET(Core)的多執行緒程式設計[3]:安全上