深耕 WPF 开发的各位程序员大大们一定避不开使用 Dispatcher。跨线程访问 UI 当然免不了用到它,将某个任务延迟到当前任务之后执行也会用到它。Dispatcher.Invoke、Dispatcher.BeginInvoke 是过去大家经常使用的方法,而 .NET Framework 4.5 中微软为我们带来了 Dispatcher.InvokeAsync 方法,它和前面两个有何不同?
阅读本文将更深入地了解 Dispatcher 的工作机制。
本文是深入了解 WPF Dispatcher 的工作原理系列文章的一部分:
回顾老旧的 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
表示异步的执行,而且参数列表中还存在着 Delegate
和 object
这样古老的类型。要知道,现代化的方法可是 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;
}
这里让我忍不住吐槽的是两点:
Legacy
是个什么鬼!词典上说这是“遗产,老化的”意思啊!很明显这是近乎被微软遗弃的代码啊!- 既然这五个重载都用了被遗弃的方法,为什么只有前面三个看不见,后面两个看得见啊!还有,微软你干嘛不标记为
[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
方法中。
- 用一个
DispatcerOperation
把我们传入的Action
/Func
包装起来。这样,我们传入的任务和优先级将在一起处理。 - 将
DispatcherOperation
加入到一个PriorityQueue<DispatcherOperation>
类型的队列中。这个队列内部实现是一个SortedList
,于是每次入队之后,出队的时候一定是按照优先级出队的。 - 调用
RequestProcessing
,直至最后向某个隐藏窗口发送了一条消息。 - 那个隐藏窗口接收到了这条消息,然后从
PriorityQueue<DispatcherOperation>
队列中取出一条任务执行(真实情况复杂一点,后面会谈到)。
上面第 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);
而这里处理的消息类型只有三种:
- 关掉这个隐藏窗口;
- 处理
Dispatcher
调度的任务(这个消息是在Dispatcher
的静态构造函数中注册的); - 定时器。
前面两个不难理解,但是为什么这里与定时器有关?!
继续调查,我们发现微软在 Dispatcher
中把所有不同种类的优先级分成了三个大类:
- 前台优先级(对应
DispatcherPriority.Loaded
到DispatcherPriority.Send
,也就是数字 6~10) - 后台优先级(对应
DispatcherPriority.Background
到DispatcherPriority.Input
,也就是数字 4~5) - 空闲优先级(对应
DispatcherPriority.SystemIdle
到DispatcherPriority.ApplicationIdle
,也就是数字 1~3)
在这里微软又开始逗我们玩了……因为在他的处理中,后面两个是完全相同的!所以严格意义上只分了两种——前台优先级和非前台优先级。而区分他们的一个分界点就是——用户的输入。
如果有用户的输入发生,那么会开启一个定时器,在定时器时间到达之前,所有的后台优先级任务都不会去执行。但前台优先级任务不受用户输入的影响。在这样的设定下,用户的输入不会随随便便被饿死,WPF 程序也就不会从输入层面开始卡顿了。
研究到这里,似乎 InvokeAsync
的执行原理差不多都清楚了。但是不要忘了这可是 TAP 异步模式的一项实践啊,这方法是要支持 await
并附带返回值的。
但这里就没有更多底层的内容了。我们注意到 InvokeAsync
的返回值是 DispatcherOperation
类型的,而这就是 InvokeAsync
方法中我们前面看到的代码中直接 new
出来的。DispatcherOperation
中有一个 Invoke
方法,这是无返回值的,但是它执行完后会置 Task
的 Result
值。另外 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
,还怎么插入得进去?!
所以,它一定有另外一套实现方式!而微软为这套实现方式做了两条路径:
- 如果是 10 的最高优先级,则直接调用
Invoke
里传入的任务; - 如果是其他,则调用
DispatcherOperation
的Wait
方法进行等待。
等等,这不还是 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 部分)。
总结
- 进入了 .NET Framework 4.5 及以上的开发者们,建议使用
InvokeAsync
代替BeginInvoke
; Dispatcher
通过创建一个隐藏的消息窗口来让一个个Invoke
到此线程的任务按照优先级执行;Invoke
使用PushFrame
做到了不阻塞 UI 线程的等待。
参考资料
- 异步编程模型
- InvokeAsync
- WPF 消息机制
- Awaiter
本文会经常更新,请阅读原文: https://blog.walterlv.com/post/dotnet/2017/09/26/dispatcher-invoke-async.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://blog.walterlv.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 ([email protected]) 。