callcc.dev

SynchronizationContext 是一个极其重要的东西,尤其是涉及到多线程和 UI 有牵扯的时候。从它可以展开两个话题:异步和线程通信,我们来简单看看这两个玩意。

Δ异步

我们先来看看异步。在 C#中,我们可以很轻松地写用书写同步代码的习惯书写异步代码,功劳归于 await 关键字。我们先来看看 await 为我们做了什么工作,以及为什么异步和 SynchronizationContext 有关系。 一个 Button 的 Click 事件处理函数里边
private async void Button_Click(object sender, RoutedEventArgs e)
{
    Console.WriteLine("Event handler starts...");
    await FooAsync();
    Console.WriteLine("Event handler ends...");
}
它的等效代码为
private void Button_Click2(object sender, RoutedEventArgs e)
{
    Console.WriteLine("Event handler starts...");
    var t = FooAsync();
    var context = SynchronizationContext.Current;
    t.ContinueWith((_) =>
    {
        if (context == null)
        {
            RestOfMethod();
        }
        else
        {
            context.Post((o) => RestOfMethod(), null); // Important!!!
        }
    }, TaskScheduler.Current);
}

private void RestOfMethod()
{
    Console.WriteLine("Event handler ends...");
}
可以看到,实际上,await 是把之后的代码提取成一个方法(这里我们演示把这个方法命名为 RestOfMethod),然后生成一个委托放入 SynchronizationContext 的 Post 方法里边去去执行。在两三年前 JS 的异步代码还是普遍如此来实现的,现在也都采用了 await 的形式。这里的关键是是 Post 方法的执行,在 WPF 等有 UI 线程的程序中,会有一个很有意思的现象,就是如果在代码用了 Task 的 Wait 方法,就一定会产生死锁,本质上就是因为 Post 方法得不到执行。那么问题来了,为什么不能执行呢?首先,在 WPF 这里 UI 程序中,SynchronizationContext 都是同一个且在 UI 线程上,所有 UI 的更新都要通过 UI 线程来完成,不能跨线程更新 UI。其次,Wait 和 await 是两种不同的实现,Wait 会导致当前线程阻塞等待任务的完成,当任务完成之后就会调用 ContinueWith 里边的委托,也就是要执行 Post 方法,可是要知道 SynchronizationContext.Current 获得的同步上下文是 UI 线程上的,此时 UI 线程在阻塞,所以它会等待 UI 线程变为就绪态,两者相互等待,所以就进入了死锁的状态。所以一般情况下,是不建议用 Wait 方法,正确的姿势应该是async/await all the way up,但是这就会导致一个问题,异步代码会向上如同病毒般地进行传播,称为zombie virus。一般情况下,这种传播会到达一个事件处理函数,也就像上边的 Button 的 Click 事件的处理函数。那么有没有什么手段来阻止这种病毒式的传播,在普通的同步方法内调用异步方法且不会阻塞 UI 线程呢?还是有的,只要我们提供一个非 UI 线程的 SynchronizationContext 来执行 Post 即可。默认的 SynchronizationContext 的 Post 方法会在线程池中申请线程执行,因此多个 ContinueWith 的委托会在不同的线程中被执行。这里我们希望所有的委托都能在一个线程中执行,避免线程切换带来的性能损耗,那么我们要手动编写一个自己的 SynchronizationContext 类,来管理所有的委托的执行。

Δ手撸 SynchronizationContext

首先,我们先来明确一下任务。SynchronizationContext 主要就是实现 Post 和 Send 方法,前者是异步的,后者则是同步的,我们这里就实现 Post 方法就好了,Send 不作为可被调用的。其次,两个方法的参数都是一个 SendOrPostCallback 加一个 object 类型,我们要把这两个参数保存起来,因此要用到集合类。再来,考虑到线程安全性,因此要用到线程安全的集合类,当然,自己手动加锁也是可以的,这里简化就直接用自带的就好了。
private class SingleThreadSynchronizationContext : SynchronizationContext, IDisposable
{
    private bool done;
    public Exception InnerException { get; set; }
    private readonly AutoResetEvent mux = new AutoResetEvent(false);
    private readonly ConcurrentQueue<Tuple<SendOrPostCallback, object>> tasks =
        new ConcurrentQueue<Tuple<SendOrPostCallback, object>>();

    public override void Send(SendOrPostCallback d, object state)
    {
        throw new NotSupportedException("Use Post instead.");
    }

    public override void Post(SendOrPostCallback d, object state)
    {
        tasks.Enqueue(Tuple.Create(d, state));
        mux.Set();
    }

    public void Complete()
    {
        Post(_ => done = true, null);
    }

    public void Start()
    {
        while (!done)
        {
            if (tasks.TryDequeue(out var task))
            {
                task.Item1(task.Item2);
                if (InnerException != null)
                {
                    throw new AggregateException(InnerException);
                }
            }
            else
            {
                mux.WaitOne();
            }
        }
    }

    public override SynchronizationContext CreateCopy()
    {
        return this;
    }

    public void Dispose()
    {
        if (mux != null)
        {
            mux.Dispose();
        }
    }
}

Δ化异步为同步

准备把异步代码在单线程上同步执行的方法,两个差别只有返回值类型。关键部分是保存原来的同步上下文,设置新的手动实现的为当前的同步上下文,这样 await 就会调用我们实现的同步上下文的 Post 方法了,最后在结束返回的时候设回原来的。
public static void RunSync(Func<Task> task, object p)
{
    var oldContext = SynchronizationContext.Current;
    var synch = new SingleThreadSynchronizationContext();
    SynchronizationContext.SetSynchronizationContext(synch); // 关键
    synch.Post(async _ =>
    {
        try
        {
            await task();
        }
        catch (Exception e)
        {
            synch.InnerException = e;
            throw;
        }
        finally
        {
            synch.Complete();
        }
    }, null);
    synch.Start();

    SynchronizationContext.SetSynchronizationContext(oldContext); // 设回上下文
}

public static T RunSync<T>(Func<Task<T>> task)
{
    var oldContext = SynchronizationContext.Current;
    var synch = new SingleThreadSynchronizationContext();
    SynchronizationContext.SetSynchronizationContext(synch);
    T ret = default(T);
    synch.Post(async _ =>
    {
        try
        {
            ret = await task();
        }
        catch (Exception e)
        {
            synch.InnerException = e;
            throw;
        }
        finally
        {
            synch.Complete();
        }
    }, null);
    synch.Start();
    SynchronizationContext.SetSynchronizationContext(oldContext);
    return ret;
}
调用的时候,我们可以这样子来用
public Stream ReadStream => AsyncHelper.RunSync(async () =>
    await file.OpenStreamForReadAsync());

Δ线程通信

讲完异步部分,我们再来谈谈线程通信问题。这里举其他线程要更新 UI 元素为例。上边我们明确过了,其他线程是不能直接更新 UI 元素的,需要通过 UI 线程来完成。可是,现实确实需要在其他线程进行一些计算,然后把结果在 UI 元素上呈现,那么还有什么办法呢?在 WPF 中,有两种方法,第一是用 Control 类的 Dispatcher 的 Invoke 方法;另外一种就是用到同步上下文了。后者会比前者好点,因为在用前者的时候我们必须要有一个控件的引用,这意味和 UI 牵扯性太高。后者大可以把更新 UI 的方法作为委托传进去,那么就不用 care 到底如何更新 UI,和 UI 进行脱离了。
举个栗子
private void TrackPlayingProgress(SynchronizationContext context, CancellationToken token)
{
    while (MainPage.MediaPlayer.PlaybackSession.PlaybackState != MediaPlaybackState.Playing)
    {
        Thread.Sleep(100);
    }
    while (!token.IsCancellationRequested && MainPage.MediaPlayer.PlaybackSession.PlaybackState == MediaPlaybackState.Playing)
    {
        var p = MainPage.MediaPlayer.PlaybackSession.Position;
        var whole = MainPage.MediaPlayer.PlaybackSession.NaturalDuration;
        if (p == whole)
        {
            break;
        }
        context.Post((o) => UpdateProgressbarValue(p, whole), null); // UpdateProgressbarValue为更新UI的方法
        Thread.Sleep(1000);
    }
}
这里的 context 给的是 UI 线程的,而且 UpdateProgressbarValue 也在这个上下文内,因此这个更新完全是没问题的。实际上运行的时候,我们会把这个方法放在其他线程上运行,因此不用担心 Thread.Sleep 阻塞 UI。

Δ参考