一站式WPF:线程模型 和 Dispatcher

▪ 前言

开始着手写这个WPF系列,这里的一站式就是力争在每一个点上能把它讲透。当然,做不到那么尽善尽美,如果有不对的地方也欢迎朋友们指正,我会逐步补充,争取把这个系列写好。

通常,WPF 应用程序从两个线程开始:一个用于处理呈现(render),一个用于管理 UI。呈现线程有效地隐藏在后台运行,而 UI 线程则接收输入、处理事件、绘制屏幕以及运行应用程序代码。

UI 线程对一个名为 Dispatcher 的对象内的工作项进行排队。 Dispatcher 基于优先级选择工作项,并运行每一个工作项直到完成。每个 UI 线程都必须至少有一个 Dispatcher,并且每个 Dispatcher 都只能在一个线程中执行工作项。

以上两段是 MSDN 上关于 WPF 线程模型 的描述。主要介绍了两个概念:

  • WPF中线程一分为二:一个用于呈现(Render),一个用于管理 UI。
  • 在 UI 线程中,使用了一个名为 Dispatcher 的类帮助 UI 线程处理任务。

▪ WPF 的 线程模型 和 Dispatcher

线程模型Dispatcher 到底是怎样的呢,它又有什么特点,有什么优缺点呢?在正式分析之前,我先找一个插入点,希望这个插入点能为朋友们所理解。

▪ WPF的窗口建立和消息机制

作为一个 Presentation 的基架,WPF 的使命就是要编写图形化的操作界面。而在 Windows 操作系统上,图形化界面是建立在消息机制这个基础上的,那么创建一个窗口,要经历哪些步骤呢?

  1. 创建窗口类:WNDCLASSEX wcex; RegisterClassEx(&wcex);
  2. 创建窗口:CreateWindow(…); ShowWindow(…); UpdateWindow(…);
  3. 建立消息泵:while( GetMessage(&msg,NULL,0,0) ){ TranslateMessage(&msg); DispatchMessage(&msg); }
▪ 打个比方,我们在一个自动化的厂房里生产设备
  1. 基于正规,我们会首先定义好该设备的模板,这就是创建窗口类(这里 “类” 更多表示类别的意思)。

  2. 模板定义完毕,我们可以正式生产设备了,这就是 创建窗口,调用 CreateWindow(…) 的时候会通过字符串参数来匹配到我们定义的模板(窗口类)。

  3. 创建成功后,我们要让生产出来的设备动起来。就要像人一样,体内一定要有类似于血液的流传机制,把命令传达到设备的各个部分,这就是消息泵,这个泵就像我们的心脏一样,源源不断的通过 GetMessage(…) 并调用 DispatchMessage(…) 来分发血液(消息)。既然我们通过消息来对设备下达指令,那么就要有消息队列来存储消息,在 Windows 中,线程为基本的调度单位,这个消息队列就在线程上,当循环使用 GetMessage(…) 时,就是在当前线程的消息队列中的取出消息,然后分发到对应的窗口中去。

▪ 那么具体到WPF,它又是一个怎么样的情况,如何和老的技术兼容,又有什么新的突破呢?

WPF 引入了 Dispatcher 的概念,这个 Dispatcher 的主要功能类似于 Win32 中的消息队列,在 Dispatcher 的内部函数中仍然调用了传统的创建窗口类,创建窗口,建立消息泵等操作。

Dispatcher 本身是一个单例模式,构造函数私有,暴露了一个静态的 CurrentDispatcher() 方法用于获得当前线程的 Dispatcher。对于线程来说,它对 Dispatcher是一无所知的,Dispatcher 内部维护了一个静态的 List<Dispatcher> _dispatchers, 每当使用 CurrentDispatcher(…) 方法时,它会在这个 _dispatchers 中遍历,如果没有找到,则创建一个新的 Dispatcher 对象并加入到 _dispatchers 中去。Dispatcher 内部维护了一个 Thread 的属性,创建 Dispatcher 对象时会把当前线程赋值给这个 Thread 的属性,下次遍历查找的时候就使用这个字段来匹配是否在 _dispatchers 中已经保存了当前线程的 Dispatcher

▪ 那么 创建窗口 和 建立消息泵 又是什么时候被调用的呢?

Dispatcher 内部,维护了一个 HwndWrapper 的字段,在 Dispatcher 的构造函数中,调用了 HwndWrapper 的构造函数,创建窗口类创建窗口 就是在这个函数中被调用的。这里实际的类是 MessageOnlyHwndWrapper,这个 Message-Only,是 Windows 编程中常用的伎俩,创建一个隐藏窗口,仅仅用来派发消息。

▪ 那么循环读取消息的消息泵又是什么时候建立起来的呢?

Dispatcher 对外提供了一个静态的 Run(…) 函数,顾名思义,就是启动 Dispatcher,在其函数内部,调用了 PushFrame(…) 函数,在这个函数中,可以找到熟悉的 GetMessage(…), TranslateAndDispatchMessage(…)

▪ 那么这个 PushFrame 是怎么回事,Frame这个概念又是如何而来的呢?

这个就是 WPF 引入的一个新的概念:嵌套消息泵,就是在一个 while(GetMessage(…)) 内部又启动了一个 while(GetMessage(…))

每调用一次 PushFrame(…),就会启动一个新的嵌套消息泵。每调用一次 GetMessage(…),就在线程的消息队列中取出一个消息,直至取出 WM_QUIT 的时候 GetMessage(…) 才返回 False。这个 GetMessage(…) 函数 Windows 内部进行了处理,当消息队列为空时,挂起执行线程,避免死循环的发生。

关于嵌套消息泵的优缺点,我们稍后再讲,先来看看 Dispatcher 是如何处理任务的:
Windows 中定义了很多 Message,以 WM_ 开头,在注册窗口类的时候需要设置窗口过程函数,GetMessage(…) 取得的消息再分发到窗口过程函数中,整个过程为:

image

这个图来自于侯捷的经典书籍《深入浅出MFC》:

  1. 首先创建 Window 并指定窗口的过程函数 WndProc
  2. 当窗口创建时一个 WM_CREATE 被放入到消息队列中。
  3. 消息泵通过 GetMessage(…) 取得该消息后分发到窗口,窗口过程函数处理这个 WM_CREATE 消息…
▪ 那么 WPF 的 Dispatcher 在这个过程中扮演了什么角色呢?

上面的 1,2,3 三点仍然如此,当窗口过程函数接收到消息时,它需要根据消息的类别把 Windows 消息转译成内部的 RoutedEvent 或者调用布局函数等来处理。

前面提到了 Dispatcher 主要功能类似于 Win32 中的消息队列,这个队列中存放的对象是 DispatcherOperation,这个 DispatcherOperation 就是把每一个执行项封装成一个对象,类似:
image

这个队列的类型为 PriorityQueue,是一个含有优先级的队列。WPF 定义了这个优先级 DispatcherPriority 有如下枚举:
image

当对这个 PriorityQueue 调用 DeQueue 时,就会取出优先级最高的任务。

▪ 那么这个队列中的任务是什么时候被添加的,又是什么时候被取出执行的呢?

Dispatcher 暴露了两个方法,InvokeBeginInvoke,这两个方法还有多个不同参数的重载。其中 Invoke 内部还是调用了 BeginInvoke,一个典型的 BeginInvoke 参数如下:

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

在这个 BeginInvoke 内部,会把执行函数 method 与参数 args 封装成 DispatcherOperation,并按 priority 加入到 PriorityQueue 中,这个返回值就是内部创建的 DispatcherOperation

结论:也就是说每调用一次 InvokeBeginInvoke,就向 Dispatcher 中加入了一个任务

▪ 那么这个任务什么时候被执行呢?

DispatcherPriority 定义了很多优先级,WPF 将这些优先级主要分成两类:前台优先级后台优先级,其中前台包括 Loaded ~ Send,后台包括 Background ~ Input(参照上图中的 DispatcherPriority 枚举)。剩下的几个优先级除了 InvalidInactive 都属于空闲优先级,处理顺序同后台优先级。ProrityQueue 的来源有:

image

当然,这里 Hwnd 级别 Hook 到的消息最终也是调用 DispatcherInvoke/BeginInvoke 方法加入到 Dispatcher 的队列中去的。当处理这个 PriorityQueue 时,会首先取得队列中的最大优先级,如果它属于前台优先级,执行。如果属于后台优先级,那么它要去扫描线程的消息队列,看看其中是由有类似 WM_MOUSEMOVE 之类的 Input 消息。如果没有,执行。如果存在,则放弃执行,并启动一个 Timer,当 Timer 唤起时继续判断是否可以执行。

▪ 那么处理 PriorityQueue 的时机呢?

当你调用 BeginInvoke,向队列中加入执行项的同时,也会调用处理 Queue 的判断。判断逻辑和上面类似,队列中最大优先级是 前台优先级,向隐藏窗口 PostMessage(…),这个消息是 Disptcher 使用 RegisterWinodwMessage(…) 注册的自定义消息。然后在调用 GetMessage(…) 的时候如果取出这个自定义消息,则处理 PriorityQueue。如果是后台优先级,扫描线程消息队列的 Input 消息,决定是否启动 Timer 还是 PostMessage(…)

▪ 举个例子,在后台线程中向 UI 线程中使用 Invoke 来发送请求,经历的过程为:
  1. 调用 this.Dispatcher.Invoke(…),对传入的参数 DispatcherPriority 进行判断,如果是 Send,这是个特殊的优先级,直接执行任务并返回(不放入消息队列)。如果是其他的优先级,调用 BeginInvoke(…)

  2. BeginInvoke(…) 中,把传入的 Delegate和参数封装成 DispatcherOperation,加入到 PriorityQueue 中。

  3. 调用队列处理的请求函数,希望处理 PriorityQueue

  4. 如果队列中最大优先级属于前台优先级,调用 PostMessage(…) 向隐藏窗口发送自定义消息。后台处理这里省略不表。

  5. 在调用 GetMessage(…) 中取得消息并分发到隐藏窗口,这里使用的是常见的 SubWindow(注释一),消息通过 Hook 发送到 DispatcherWndProcHook(…) 函数进行处理。

  6. WndProcHook(…) 中,如果接收到的Window消息是 Dispatcher 自定义的消息,则真正处理 PriorityQueue

  7. 处理 PriorityQueue,从中取出一个任务,进行前后台优先级判断,决定是否处理还是启动 Timer 稍后处理。

▪ WPF 的嵌套消息循环

▪ 回过头来,说一说嵌套的消息循环,这个要从模态对话框说起,一个通常的模态对话框场景如下:
SomeCodeA();
bool ? result = dlg.ShowDialog();
SomeCodeB();

代码运行在UI线程中,当执行到 dlg.ShowDialog() 时,启动模态对话框,等待用户点击 Yes/No 或者 关闭对话框

对话框关闭后程序继续执行 SomeCodeB() 代码。那么程序要在 SomeCodeB() 处等待 ShowDialog() 返回后才继续执行。

当然你可以使用 WaitHandle 来同步,不过这个需要挂起当前(UI)线程,如果主窗口中有动画等UI动作,那么会停止得不到响应。这里 WPF 使用的是 PushFrame,就是在 ShowDialog() 内部又建立起了一个消息泵 while(GetMessage(…))。一方面,可以确保 UI 线程中的消息可以被处理;另一方面,因为是 while 循环,在对话框关闭时返回,可以确保 SomeCodeB() 的执行顺序。

▪ 那么是不是这个嵌套的消息循环真的如此完美呢?

当然不是,它打开了一扇门的同时,也打开了另一扇门。一个情景,当收到 WM_SIZE 消息的时候,Layout系统 开始处理,如果在这个处理过程中,又启动了 PushFrame,那么嵌套的消息泵就会继续从消息队列中取出消息,如果下一个消息也是 WM_SIZE ,那么进行处理。

假设这个消息处理结束后这个嵌套的消息泵返回了,那么第一个 WM_SIZE 得以继续处理。这样就发生了错误,本来12的处理顺序变成了121。当然这种情况不仅仅发生在 Layout 中,所以 WPF 在 Dispatcher 中加入了一个 DisableProcessing(…) 函数,在 Layout 等关键过程中调用了这个函数,在这个过程中停止 pump消息禁止PushFrame

在WPF中,所有的 WPF 对象都派生自 DispatcherObjectDispatcherObject暴露了 Dispatcher 属性用来取得创建对象线程对应的 Dispatcher。鉴于线程亲缘性,DispatcherObject 对象只能被创建它的线程所访问,其他线程修改 DispatcherObject 需要取得对应的 Dispatcher,调用 Invoke 或者 BeginInvoke 来投入任务。一个 UI 线程至少有一个 Dispatcher 来建立消息泵处理任务,一个 Dispatcher 只能对应一个UI线程。

▪ 那么UI线程和Render线程又如何呢?

开篇提到,WPF线程一分为二,一个是UI线程,一个是Render线程。这两个被设计成分离的关系,通过Channel(event)来进行通信。两者之间的数量关系是一个WPF进程只能有一个Render线程,旦可以有大于等于一个的UI线程。通常情况下是一个UI线程,也就是一个Dispatcher,那么什么情况下需要建立多个呢?

大多情况下是不需要的,少数情况下,比如MediaElement,或者Host其他ActiveX控件,我们期望在其他线程中创建,以提高性能。可以新建线程,在新线程中创建控件,并调用Dispatcher.Run启动Dispatcher。这样主Window和新控件就处在不同线程中,两者间的通信可以使用VisualTarget连接视觉树或者使用D3DImage拷贝新控件到主Window中显示。

开篇有益,WPF没有什么全新的技术,但提出了很多新的概念。就像施了妆包装后的美人,远看很美,可是风一来手一伸,丫的,不过如此。_

▪ 注释一

SubWindow,子窗口子类化。通常情况,所有同类别Window会共用同一个消息处理函数WndProc,子Window可以调用SetWindowLong用SubWndProc替换WndProc,这个通常称为Sub-Window。

  • 0
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值