从执行上下文角度重新理解.NET(Core)的多线程编程[2]:同步上下文

2020-11-26 17:25:16 浏览数 (1)

一般情况下,我们可以将某项操作分发给任意线程来执行,但有的操作确实对于执行的线程是有要求的,最为典型的场景就是:GUI针对UI元素的操作必须在UI主线程中执行。将指定的操作分发给指定线程进行执行的需求可以通过同步上下文(SynchronizationContext)来实现。你可能从来没有使用过SynchronizationContext,但是在基于Task的异步编程中,它却总是默默存在。今天我们就来认识一下这个SynchronizationContext对象。

目录 一、从一个GUI的例子谈起 二、自定义一个SynchronizationContext 三、ConfiguredTaskAwaitable方法 四、再次回到开篇的例子

一、从一个GUI的例子谈起

GUI后台线程将UI操作分发给UI主线程进行执行时SynchronizationContext的一个非常典型的应用场景。以一个Windows Forms应用为例,我们按照如下的代码注册了窗体Form1的Load事件,事件处理器负责修改当前窗体的Text属性。由于我们使用了线程池,所以针对UI元素的操作(设置窗体的Text属性)将不会再UI主线程中执行。

代码语言:javascript复制
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方法来完成的。

代码语言:javascript复制
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类型的委托)置于一个队列中,并创建一个独立的线程依次提取它们并执行。

代码语言:javascript复制
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。

代码语言:javascript复制
class Program
{
    static async Task Main()
    {

这段演示程序执行之后会输出如下所示的结果,可以看出从5个线程池线程分发的5个操作均是在FixedThreadSynchronizationContext绑定的那个线程中执行的。

三、ConfiguredTaskAwaitable方法

我知道很少人会显式地使用SynchronizationContext上下文,但是正如我前面所说,在基于Task的异步编程中,SynchronizationContext上下文其实一直在发生作用。我们可以通过如下这个简单的例子来证明SynchronizationContext的存在。如代码片段所示,我们创建了一个FixedThreadSynchronizationContext对象并通过调用SetSynchronizationContext方法将其设置为当前SynchronizationContext。在调用Task.Delay方法(使用await关键字)等待100ms之后,我们打印出当前的线程ID。

代码语言:javascript复制
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中执行。

代码语言:javascript复制
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主线程执行。

代码语言:javascript复制
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异常。

代码语言:javascript复制
partial class Form1
{  
    private void InitializeComponent()
    {
        ...
        this.Load  = Form1_Load;
    }

0 人点赞