用Reflector分析WPF时,发现几乎所有的类的继承自DispatcherObject类。而该类非常简单,只有CheckAccess和VerifyAccess两个方法,一个Dispatcher只读属性和一个私有成员变量。正是由于继承了这个类,使WPF中其他类具有了多线程处理的能力。
在分析DispatcherObject前,我们先看看一般Win32或WinForm GUI应用程序中存在的问题和解决办法。
"没有响应":Message Pump被阻塞
使用Windows时,最常碰到的问题就是窗口没有响应的。OS发现一个窗口一定时间内没有处理消息队列时,OS就会显示该窗口"没有响应"。
消息队列是OS为窗口创建的一个线程相关的内部结构。所有对窗口的操作,如鼠标移到,键盘输入等,最终都是在该队列中添加了一条消息。一般我们称创建主窗口的线程为UI线程,该线程创建Message Pump,负责不断的从消息队列中读取消息事件,并执行相应的窗口函数。
在Win32中,是通过调用GetMessage或PeekMessage来读取队列中的消息,并执行窗口处理函数WinProc。WinFom是对Win32 API封装,Application.Run()建立了Message Pump,从队列中读取消息,并调用对应的Event Handler。正是由于Windows这样的设计,如果Event Handler耗时很长时,UI线程无法继续处理消息队列中的其他消息(如WM_Paint),从而窗口没有响应或更新。这就是GUI应用程序中常碰到的Message Pump被阻塞的问题。
如下面的例子,在Button.Click Event Handler中,我们从数据库或文件中读出数据,并将每条记录加入List列表中。由于Message Pump被阻塞,UI线程无法处理WM_Paint消息,因而List只有在所以数据都加入后,才会更新显示。
2 {
3 // Clear the list
4 listBox1.Items.Clear();
5
6 // Get data from database or file
7 for (Int32 i = 0; i < 100000; i++)
8 {
9 // Add the data to list
10 listBox1.Items.Add(i);
11 }
12 }
解决办法之一:Application.DoEvents
既然问题是由于没有处理消息队列造成的,那么比较直接的一个办法就是调用GetMessage或PeekMessage。WinForm中对于的函数就是Application.DoEvents()方法。
2 {
3 // Clear the list
4 listBox1.Items.Clear();
5
6 // Get data from database or file
7 for (Int32 i = 0; i < 100000; i++)
8 {
9 // Add the data to list
10 listBox1.Items.Add(i);
11 Application.DoEvents();
12 }
13 }
似乎这样就解决问题了,但是上面的方法实际上引入了一个更严重的问题:Reentrancy。
运行上面的程序,我们先点击一次按钮,button1_Click被调用,listBox1.Items.Clear()清空列表,然后开始添加数据到列表。如果这个时候,我们再次点击按钮,一个Button Click消息被加入到消息队列。在没有调用Application.DoEvents时,这条新的消息只有在button1_Click处理完后,才会被调用。这样尽管界面有段时间没有响应,但list中的数据仍能保持完整。当我们加入Application.DoEvents()后,在第一次处理button1_Click过程中,新的消息再次被处理,button1_Click第二次被调用,同样listBox1被清空。但是由于第一次的button1_Click还执行完,后面的Add动作仍会在第二次button1_Click都执行完成后执行,List就变成了1,2,3,4,5,6,4,5,6。而这种行为造成程序不稳定性,有时候数据是好的,有时候又有重复的数据。
造成Reentrancy问题原因是由于DoEvents会处理消息队列中的所有消息,如果DoEvents能够提供只处理WM_Paint事件的话,就不会有这个问题。但是由于消息队列结构的设计不完全支持优先级,所以Win32或WinForm没法解决这个问题。
解决办法之二:拆分复杂的操作
另一个解决办法就是把复杂的操作拆分成很多小的操作,每次只执行一部分。这种办法主要有两个问题:不易拆分或增加了代码的难度,如何调用这些小的操作。如上面的例子中,我们在button1_Click中,我们创建一个System.Windows.Forms.Timer对象,在Tick事件中来添加list的Item。
这种办法有比较大的局限,而且同样会存在Reentrancy的问题。
解决办法之三:多线程
在看多线程前,让我们看看创建一个线程的消耗:
- 创建Thread核心对象(128K?),保留1M的Thread Stack地址空间
- CPU Task Manager多一个Task
- 线程切换非常耗时
是的,如果是单CPU的机器,多线程就意味着性能降低,因为CPU增加了额外的工作。我们需要多线程的原因就是因为UI线程必须能够释放出来去处理消息队列。
在Win32开发中,我们可以调用CreateThread来创建一个线程(Worker线程),来处理耗时的操作,在.Net中对应的就是创建一个Thread对象。这种办法的问题是,每次创建一个Thread,OS都会创建Thread对象,而使用完后该Thread就会被销毁。创建销毁线程对象都是耗时的工作。.Net提供了线程池,可以重复利用线程来提高性能。我们可以用ThreadPool类,或者Delegate的BeginInvoke来使用.Net线程池。
于是,上面的代码很自然的就改为:
2 {
3 ThreadPool.QueueUserWorkItem(new WaitCallback(DoWork));
4 }
5
6 private void DoWork(Object state)
7 {
8 listBox1.Items.Clear();
9 for (Int32 i = 0; i < 10000; i++)
10 {
11 listBox1.Items.Add(i);
12 }
13 }
于是编译执行上面的代码,噢,没有问题。但是当你发布给用户时,用户就会抱怨,这个程序有时候突然就没了,于是噩梦就开始了。造成这个问题的原因就是由于Worker线程访问了控件,窗口等GDI对象。为了帮助找到这种问题,在用VS2008调试上面程序时,程序会报Cross-Operation的异常。但是,在Release版本中,程序就会崩溃,并且没有好的工具或方法能够找到这个问题。所以,在设计代码时,就一定注意避免Worker线程访问控件。
为什么Windows会有这样的限制呢?我想这个主要是由于性能的考虑。如果Windows GDI对象要支持多线程的访问,则每次访问时都需要lock来保证数据的完整性,不然界面的行为就会很怪异,每次GDI操作都判断lock的话,界面将会非常非常的慢。如果不用lock的方式,而采用判断Thread.ID的方式,那么每个对象都必须在创建时有个成员变量来保存Thread ID。在Windows 32的时代,内存是很宝贵的资源,所以Windows选择了忽略这个问题也是可以理解的。
那么,我们如何可以解决这个问题呢?在WinForm中,定义了一个ISynchronizeInvoke的接口,Control实现了这个接口。该接口中InvokdRequired属性,用来判断调用线程是否是Control的创建线程,而BeginInvoke和Invoke方法,这是会发消息到Control对应的消息队列,来告诉UI线程来更新Control。于是,上面的代码就变成:
2 {
3 InitializeComponent();
4 updateList = new UpdateUICallBack(UpdateList);
5 }
6
7 private void button1_Click(object sender, EventArgs e)
8 {
9 ThreadPool.QueueUserWorkItem(new WaitCallback(DoWork));
10 }
11
12 private delegate void UpdateUICallBack(Int32 value);
13 private UpdateUICallBack updateList;
14 private void DoWork(Object state)
15 {
16 for (Int32 i = 0; i < 10000; i++)
17 {
18 updateList(i);
19 }
20 }
21
22 private void UpdateList(Int32 value)
23 {
24 if (listBox1.InvokeRequired)
25 listBox1.Invoke(updateList, new Object[] { value});
26 else
27 listBox1.Items.Add(value);
28 }
一切似乎很完美了,当在Worker线程上调用updateList时,listBox1.InvokeRequried返回True,调用listBox1.Invoke(),Worker线程会等待Invoke()返回,而Invoke()则会调用Win32 API SendMessage()给UI线程的消息队列,UI线程再次调用UpdateList更新ListBox后返回。
当然,如果我们不想Worker线程被阻塞的话,可以通过调用BeginInvoke()和EndInvoke()的异步方法。不过,不管那种方式,代码都会变得比较难看。而且,尽管InvokeRequired是个属性,实际上,会执行很多操作。InvokeRequired拿到调用线程的ID,然后尝试去得到Control的创建线程ID。而得到Control的创建线程ID会是一个耗时的操作。因为Control创建时并没有保存线程ID到成员变量中。所以InvokeRequired会遍历Control的父Control,一层层,直到找到最外面的父窗口。因而InvokeRequired是一个耗时的操作。
为了解决InvokeRequired的性能问题,我们可以在创建窗口的时候,保存一个Thead的引用,然后再来判断,于是代码变为:
2 {
3 private Thread ownerThread;
4 public Form1()
5 {
6 InitializeComponent();
7 updateList = new UpdateUICallBack(UpdateList);
8 ownerThread = Thread.CurrentThread;
9 }
10
11 private Boolean CheckAccess()
12 {
13 return ownerThread == Thread.CurrentThread;
14 }
15
16 private void VerifyAccess()
17 {
18 if (!CheckAccess())
19 throw new Exception("Invoke Needed");
20 }
21
22 private void UpdateList(Int32 value)
23 {
24 if (!CheckAccess())
25 listBox1.Invoke(updateList, new Object[] { value });
26 else
27 listBox1.Items.Add(value);
28 }
29 }
好了,上面差不多分析了当前WinForm或Win32 GDI编程的主要问题,下一个Post,让我们来看看WPF是如何解决这些问题的。