取消

深入了解 WPF Dispatcher 的工作原理(Invoke/InvokeAsync 部分)

深耕 WPF 开发的各位程序员大大们一定避不开使用 Dispatcher。跨线程访问 UI 当然免不了用到它,将某个任务延迟到当前任务之后执行也会用到它。Dispatcher.Invoke、Dispatcher.BeginInvoke 是过去大家经常使用的方法,而 .NET Framework 4.5 中微软为我们带来了 Dispatcher.InvokeAsync 方法,它和前面两个有何不同?

阅读本文将更深入地了解 Dispatcher 的工作机制。


本文是深入了解 WPF Dispatcher 的工作原理系列文章的一部分:

  1. Invoke/InvokeAsync 部分(本文)
  2. PushFrame 部分

回顾老旧的 BeginInvoke,看看新的 InvokeAsync

微软自 .NET Framework 3.0 为我们引入了 Dispatcher 之后,BeginInvoke 方法就已存在。不过,看这名字的 Begin 前缀,有没有一种年代感?没错!这是微软在 .NET Framework 1.1 时代就推出的 Begin/End 异步编程模型(APM,Asynchronous Programming Model)。虽说 Dispatcher.BeginInvoke 并不完全按照 APM 模型来实现(毕竟没有对应的 End,也没有返回 IAsyncResult),但这个类型毕竟也是做线程相关的事情,而且这个方法的签名明显还带着那个年代的影子。不止名字上带着 Begin 表示异步的执行,而且参数列表中还存在着 Delegateobject 这样古老的类型。要知道,现代化的方法可是 Action/Func 加泛型啊!

大家应该还对 .NET Framework 4.5 带给我们的重磅更新——async/await 异步模式感到兴奋,因为它让我们的异步代码变得跟同步代码一样写了。这是微软新推荐的异步编程模式,叫做 TAP(Task-based Asynchronous Pattern)。既然异步编程模式都换了,同为线程服务的 Dispatcher.BeginInvoke 怎能不改呢?于是,微软真的改了,就是从 .NET Framework 4.5 版本开始。

它叫做——Dispatcher.InvokeAsync


BeginInvoke 和 InvokeAsync 有什么不同?

这个还真得扒开微软的源码看一看呢!

1
2
3
4
5
6
7
8
9
10
11
12
[Browsable(false), EditorBrowsable(EditorBrowsableState.Never)]
public DispatcherOperation BeginInvoke(DispatcherPriority priority, Delegate method);

[Browsable(false), EditorBrowsable(EditorBrowsableState.Never)]
public DispatcherOperation BeginInvoke(DispatcherPriority priority, Delegate method, object arg);

[Browsable(false), EditorBrowsable(EditorBrowsableState.Never)]
public DispatcherOperation BeginInvoke(DispatcherPriority priority, Delegate method, object arg, params object[] args);

public DispatcherOperation BeginInvoke(Delegate method, params object[] args);

public DispatcherOperation BeginInvoke(Delegate method, DispatcherPriority priority, params object[] args);

一共五个重载,前面三个都被微软做了标记,让你在智能感知列表中看不见。(这里吐槽一下 ReSharper,明明微软已经不让显示了嘛,干嘛还把人家显示出来……)后面两个暂时还看得见,但那又如何?!根本没啥区别好吗!!!

为什么会像上面那样吐槽,是因为我发现这五个不同的重载里面其实都调用了同一个内部方法:

1
2
3
4
5
6
7
8
9
10
11
12
[SecuritySafeCritical]
private DispatcherOperation LegacyBeginInvokeImpl(DispatcherPriority priority, Delegate method, object args, int numArgs)
{
    ValidatePriority(priority, "priority");
    if(method == null)
    {
        throw new ArgumentNullException("method");
    }
    DispatcherOperation operation = new DispatcherOperation(this, method, priority, args, numArgs);
    InvokeAsyncImpl(operation, CancellationToken.None);
    return operation;
}

这里让我忍不住吐槽的是两点:

  1. Legacy 是个什么鬼!词典上说这是“遗产,老化的”意思啊!很明显这是近乎被微软遗弃的代码啊!
  2. 既然这五个重载都用了被遗弃的方法,为什么只有前面三个看不见,后面两个看得见啊!还有,微软你干嘛不标记为 [Obsolete] 呢!

好,吐槽结束。我们再来看看 InvokeAsync 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
public DispatcherOperation InvokeAsync(Action callback);

public DispatcherOperation InvokeAsync(Action callback, DispatcherPriority priority);

[SecuritySafeCritical]
public DispatcherOperation InvokeAsync(Action callback, DispatcherPriority priority, CancellationToken cancellationToken);

public DispatcherOperation<TResult> InvokeAsync<TResult>(Func<TResult> callback);

public DispatcherOperation<TResult> InvokeAsync<TResult>(Func<TResult> callback, DispatcherPriority priority);

[SecuritySafeCritical]
public DispatcherOperation<TResult> InvokeAsync<TResult>(Func<TResult> callback, DispatcherPriority priority, CancellationToken cancellationToken);

看吧,这才像微软新的 TAP 异步模式的代码啊。

不带 CancellationToken 的四个重载会汇聚到带 CancellationToken 的两个重载中,这两个重载代码除了泛型返回值以外几乎一模一样。所以我们拿第三个当研究对象看看:

1
2
3
4
5
6
7
8
9
10
11
12
[SecuritySafeCritical]
public DispatcherOperation InvokeAsync(Action callback, DispatcherPriority priority, CancellationToken cancellationToken)
{
    if(callback == null)
    {
        throw new ArgumentNullException("callback");
    }
    ValidatePriority(priority, "priority");
    DispatcherOperation operation = new DispatcherOperation(this, priority, callback);
    InvokeAsyncImpl(operation, cancellationToken);
    return operation;
}

你发现了什么?这与那个被遗弃的 LegacyBeginInvokeImpl 长得非常像。不,就是一模一样!你总不能说参数名称不同也要算吧……甚至……返回值类型也是一样的。

既然这样,我们总算是明白微软到底在做些什么了。其实微软在 .NET Framework 4.5 中已经把 BeginInvoke 的实现改造成了 TAP 异步模式,但方法名字和老旧的参数列表却始终是微软的一块心病,于是痛下决心新增了 6 个更加现代的方法免得产生兼容性问题。不过由于里面的实现一模一样,并没有额外带来什么 BUG,所以微软也不好意思标记为 [Obsolete] 已过时了。

既然两个方法一样,后文我也就没必要两个都说了,一切以新款的 InvokeAsync 为主。


InvokeAsync 的实现原理

前面一节几乎告诉我们,InvokeAsync 的关键就在 InvokeAsyncImpl 方法中。

  1. 用一个 DispatcerOperation 把我们传入的 Action/Func 包装起来。这样,我们传入的任务和优先级将在一起处理。
  2. DispatcherOperation 加入到一个 PriorityQueue<DispatcherOperation> 类型的队列中。这个队列内部实现是一个 SortedList,于是每次入队之后,出队的时候一定是按照优先级出队的。
  3. 调用 RequestProcessing,直至最后向某个隐藏窗口发送了一条消息。
  4. 那个隐藏窗口接收到了这条消息,然后从 PriorityQueue<DispatcherOperation> 队列中取出一条任务执行(真实情况复杂一点,后面会谈到)。

InvokeAsync 的实现原理图

上面第 3 点的消息是这样发的:

1
UnsafeNativeMethods.TryPostMessage(new HandleRef(this, _window.Value.Handle), _msgProcessQueue, IntPtr.Zero, IntPtr.Zero);

等等,这句代码里面的 _window 是哪儿来的?为什么凭空出现了一个可以用来发送消息的窗口?于是,在 Dispatcher 构造函数中发现了这个窗口。这并不是我们平时所熟知的那个 Window 类,而是一个用于发送和接收 Dispatcher 调度器调度任务消息的 Win32 隐藏窗口。不信它是一个窗口?请进入 MessageOnlyHwndWrapper 类看,它的基类 HwndWrapper 中直接使用了方法 UnsafeNativeMethods.CreateWindowEx 创建了这个窗口,然后拿到了它的句柄 Handle

既然会向窗口发消息,自然而然可以 Hook 它的消息处理函数,就像下面这样:

1
2
3
4
5
6
7
// Create the message-only window we use to receive messages
// that tell us to process the queue.
MessageOnlyHwndWrapper window = new MessageOnlyHwndWrapper();
_window = new SecurityCriticalData<MessageOnlyHwndWrapper>( window );

_hook = new HwndWrapperHook(WndProcHook);
_window.Value.AddHook(_hook);

而这里处理的消息类型只有三种:

  1. 关掉这个隐藏窗口;
  2. 处理 Dispatcher 调度的任务(这个消息是在 Dispatcher 的静态构造函数中注册的);
  3. 定时器。

前面两个不难理解,但是为什么这里与定时器有关?!

继续调查,我们发现微软在 Dispatcher 中把所有不同种类的优先级分成了三个大类:

  1. 前台优先级(对应 DispatcherPriority.LoadedDispatcherPriority.Send,也就是数字 6~10)
  2. 后台优先级(对应 DispatcherPriority.BackgroundDispatcherPriority.Input,也就是数字 4~5)
  3. 空闲优先级(对应 DispatcherPriority.SystemIdleDispatcherPriority.ApplicationIdle,也就是数字 1~3)

在这里微软又开始逗我们玩了……因为在他的处理中,后面两个是完全相同的!所以严格意义上只分了两种——前台优先级和非前台优先级。而区分他们的一个分界点就是——用户的输入。

如果有用户的输入发生,那么会开启一个定时器,在定时器时间到达之前,所有的后台优先级任务都不会去执行。但前台优先级任务不受用户输入的影响。在这样的设定下,用户的输入不会随随便便被饿死,WPF 程序也就不会从输入层面开始卡顿了。

研究到这里,似乎 InvokeAsync 的执行原理差不多都清楚了。但是不要忘了这可是 TAP 异步模式的一项实践啊,这方法是要支持 await 并附带返回值的。

但这里就没有更多底层的内容了。我们注意到 InvokeAsync 的返回值是 DispatcherOperation 类型的,而这就是 InvokeAsync 方法中我们前面看到的代码中直接 new 出来的。DispatcherOperation 中有一个 Invoke 方法,这是无返回值的,但是它执行完后会置 TaskResult 值。另外 DispatcherOperation 实现了 GetAwaiter 方法,于是就可以使用 await。对于如何自己实现一个可以 await 的类,我可能会专门写一篇文章,但如果你现在就希望了解,可以阅读:How to write a custom awaiter – Lucian’s VBlog

而被我们遗弃的 BeginInvoke,由于内部调用了同一个函数,所以实现原理是完全一样的。而且,这么古老的函数也允许 await

Invoke 的实现原理

也许你会觉得奇怪。我们连“异步”的 InvokeAsync 的实现原理都了解了,同步的 Invoke 还有何难!

如果你这么认为,你一定忽略了一个很重要的问题——死锁!

如果是另一个线程调用到此线程的 Invoke,那么同步等待一下当然不会有问题。但是如果调用线程就是此线程本身呢?如果依然采用“同步等待”的方式,那么 UI 线程就会因为 Invoke 的调用而阻塞,然而 Invoke 中传入的 Action 是插入到 UI 线程执行的,如果 UI 线程正在等待 Invoke,还怎么插入得进去?!

所以,它一定有另外一套实现方式!而微软为这套实现方式做了两条路径:

  1. 如果是 10 的最高优先级,则直接调用 Invoke 里传入的任务;
  2. 如果是其他,则调用 DispatcherOperationWait 方法进行等待。

等等,这不还是 Wait 吗!然而进去 Wait 方法查看,你会发现,根本不是!

1
2
3
4
5
6
7
8
9
10
11
12
13
public DispatcherOperationStatus Wait(TimeSpan timeout)
{
    // 省略一些前面的代码。
            
    // We are the dispatching thread for this operation, so
    // we can't block.  We will push a frame instead.
    DispatcherOperationFrame frame = new DispatcherOperationFrame(this, timeout);
    Dispatcher.PushFrame(frame);
        
    // 省略一些后面的代码。
    
    return _status;
}

它用了 Dispatcher.PushFrame。这样保证了在不阻塞线程的情况下进行“等待”。至于如何做到“不阻塞地等待”,请参阅本系列的第二篇文章 深入了解 WPF Dispatcher 的工作原理(PushFrame 部分)

总结

  1. 进入了 .NET Framework 4.5 及以上的开发者们,建议使用 InvokeAsync 代替 BeginInvoke
  2. Dispatcher 通过创建一个隐藏的消息窗口来让一个个 Invoke 到此线程的任务按照优先级执行;
  3. Invoke 使用 PushFrame 做到了不阻塞 UI 线程的等待。

参考资料

本文会经常更新,请阅读原文: https://blog.walterlv.com/post/dotnet/2017/09/26/dispatcher-invoke-async.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

知识共享许可协议

本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://blog.walterlv.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 ([email protected])

登录 GitHub 账号进行评论