SynchronizationContext 是 C# 内置跨线程同步上下文的基类,可以实现线程间的同步。
例如,一般情况下子线程无法修改主线程的 UI,通常比较麻烦的做法就是自己采用轮询机制实现同步。
而该类则可很方便地处理该类型问题。

# 前言

官方文档地址:地址

说实话,官方文档的描述极少,大概就是说这个类是个基类,提供了一个通用的线程上下文的传递模型,有需要可以直接继承该类,并重载需要的方法,简化了多线程工作单元传递所需的工作。
不过在该文档中,还额外提供了一份 MSDN 的文章
MSDN 文章就比较详细些了。

原理就不多说了,上面 MSDN 文章有详细介绍,这种系统层级的东西,用就好了。
另外虽说称为基类,但是直接使用也是没问题的。

# 介绍

在 SynchronizationContext 中,有静态的 Current 变量,在主 UI 线程调用将返回一个 SynchronizationContext 类型的实例。
同时也提供了 SetSynchronizationContext (SynchronizationContext syncContext) 方法可覆盖该返回值,一般用于继承了 SynchronizationContext 并有自己额外处理的方式的派生类使用。

可以将 Current 当做一个单例,但是注意的是它属于每个线程独有,在 UI 主线程可以直接通过 SynchronizationContext.Current 获取到默认的实例,但新开的子线程默认始终为 Null。
注:即便使用 SetSynchronizationContext 在主线程设置之后,新开的子线程若不进行设置,依然会保持为 Null。


SynchronizationContext 类中,主要提供 Post、Send 两个方法进行线程回调使用:

s
public virtual void Post(SendOrPostCallback d, object state);
public virtual void Send(SendOrPostCallback d, object state);

其中调用 Post 将会异步执行,调用 Send 则同步执行。 object state 为调用回调时,传入的参数。

  • Post:在调用之后,立即执行当前线程后续代码。
  • Send:在调用之后,等待回调执行完毕,才会继续执行当前线程后续代码。

测试如下:

Send

s
Task.Run(() =>
{
    SynchronizationContext.SetSynchronizationContext(context);
    SynchronizationContext.Current.Send((args) =>
    {
        Thread.Sleep(1000);
        Console.WriteLine($"子线程调用UI!args:{args}");
    }, "参数X");
    Console.WriteLine("调用结束!");
});

在该方法中,调用 Send,并在回调内休眠 1000 毫秒。
打印结果:

子线程调用UI!args:参数X
调用结束!

按照顺序执行,当子线程休眠时,后续代码也阻塞未执行,直到 Send 回调执行完毕。

Post

直接将上述代码中的 Send 改为 Post,结果如下:

调用结束!
子线程调用UI!args:参数X

即使去掉回调中的 1000 毫秒休眠,调用 Post,依然会是该结果。

因此可以表明:Post 可用于异步回调,Send 可用于等待回调执行,需要阻塞后续代码的情况。

# 使用

# 简单应用

直接使用的方法很简单,这里先来看 WPF 中的效果。

正常情况下,子线程无法操作 UI,若直接调用,会得到异常报错,例如如下代码:

s
Task.Run(() =>
{
    TestBtn.Content = "子线程调用UI!";
});

会得到如下报错:

错误

在 WPF 中,虽然有其它方式可以避免报错并让其正常工作,例如:

s
Task.Run(() =>
{
    TestBtn.Dispatcher.Invoke(() =>
    {
        TestBtn.Content = "子线程调用UI!";
    });
});

不过这种方式是 WPF 自己的特性,如果想在其它地方使用,比如 Unity,可就没这种东西了。

此时便可使用 SynchronizationContext:

s
SynchronizationContext context = SynchronizationContext.Current;
Task.Run(() =>
{
    SynchronizationContext.SetSynchronizationContext(context);
    SynchronizationContext.Current.Send((args) =>
    {
        Console.WriteLine($"子线程调用UI!");
        TestBtn.Content = "子线程调用UI!";
    }, null);
    Console.WriteLine("调用结束!");
});

该份代码复制至 Unity,且调用 UI 替换为 Unity 的函数,亦可正常工作:

s
void Start()
    {
        SynchronizationContext context = SynchronizationContext.Current;
        Task.Run(() =>
        {
            SynchronizationContext.SetSynchronizationContext(context);
            Console.WriteLine(SynchronizationContext.Current.GetHashCode());
            SynchronizationContext.Current.Send((args) =>
            {
                Console.WriteLine("子线程调用UI!");
                UnityEditor.EditorUtility.DisplayDialog("提示", "子线程的调用!", "OK");
            }, null);
            Console.WriteLine("调用结束!");
        });

# 单例

一般在项目中,代码结构都不会如此简单,如果在开启子线程时,处处考虑 SynchronizationContext 实例线程传递的话,也比较麻烦。因此可以使用单例。

这里主要有两种方式实现:

  • 普通单例缓存 SynchronizationContext 实例
  • 继承自 SynchronizationContext 的单例实现

# 方式一

第一种比较好说,直接新建一个单例类,并将 SynchronizationContext 封装即可。
代码如下:

s
public class MySynchronizationContextHolder
{
    public static MySynchronizationContextHolder Instance { get; } = new MySynchronizationContextHolder();
    private SynchronizationContext _context = SynchronizationContext.Current;
    private static int _mainThreadId = Thread.CurrentThread.ManagedThreadId;
    /// <summary>
    /// 子线程执行一个回调时调用
    /// </summary>
    /// <param name="action"></param>
    /// <param name="state"></param>
    public void Post(Action action, object state)
    {
        Post(new SendOrPostCallback((x) => action.Invoke()), state);
    }
    /// <summary>
    /// 子线程执行一个回调时调用
    /// </summary>
    /// <param name="callback"></param>
    /// <param name="state"></param>
    public void Post(SendOrPostCallback callback, object state)
    {
        if (Thread.CurrentThread.ManagedThreadId == _mainThreadId)
        {
            callback.Invoke(state);
            return;
        }
        _context.Post(callback, state);
    }
}

此种方式唯一需要注意的是:子线程调用实例之前,主线程必须先调用一次,确保单例初始化于主线程,否则初始化于子线程的单例会造成错误的结果。
(例如上述方式,若第一次由子线程初始化调用,会导致_context 实例为 Null,而且由于处于子线程甚至无法捕获错误。)

# 方式二

第二种方式,由于继承了 SynchronizationContext,就不能简单地通过调用 base.Post(callback,state); 这种方法来实现传递了。
因为那样的话,这个单例就会变成跟子线程调用一样的性质了:
直接调用的情况就如同子线程直接创建了一个新实例,然后调用造成的结果,跟子线程直接调用无所区别。

因此这里就需要额外做点工作,比如重写方法,缓存回调,然后在主线程 Update 中统一更新:

s
public class MySynchronizationContext : SynchronizationContext
{
    public static MySynchronizationContext Instance { get; } = new MySynchronizationContext();
    private ConcurrentQueue<Action> _queue = new ConcurrentQueue<Action>();
    private Action _callback;
    public void Update()
    {
        if (_queue.Count == 0) return;
        while (true)
        {
            if (!_queue.TryDequeue(out _callback))
                return;
            _callback.Invoke();
        }
    }
    /// <summary>
    /// 子线程执行一个回调时调用
    /// </summary>
    /// <param name="action"></param>
    /// <param name="state"></param>
    public void Post(Action action, object state)
    {
        Post(new SendOrPostCallback((x) => action.Invoke()), state);
    }
    /// <summary>
    /// 子线程执行一个回调时调用
    /// </summary>
    /// <param name="callback"></param>
    /// <param name="state"></param>
    public override void Post(SendOrPostCallback callback, object state)
    {
        _queue.Enqueue(() => callback.Invoke(state));
    }
}

以上是两个可以将 SynchronizationContext 封装为单例的实现方式。

# 结束

个人感觉,这种一般在多线程使用中还是挺有用的 —— 比自己去循环查询结果来得方便。

此前个人就经常使用那种笨办法,比如制作 Unity 工具,在子线程回调中需要表现的时候:弹窗或者显示进度条,就是在使用回调修改值,然后在界面中再根据回调修改后的值判断是否进行显示,
中间转化及其麻烦,有时候甚至直接就因此放弃做什么表现了,直接一个子线程搞完了再统一给个回复。

后续就会考虑实际应用,或许修改下以前的代码了。
如 Github 上一些小工具,后续应该会考虑更新一波。