WPF学习与分享之二:DispatcherObject与WPF线程模型(上)

Posted on 2008-08-03 19:06  Nullnoid  阅读(6560)  评论(9编辑  收藏  举报

用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只有在所以数据都加入后,才会更新显示。

 1 private void button1_Click(object sender, EventArgs e)
 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()方法。

 1 private void button1_Click(object sender, EventArgs e)
 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的问题。

解决办法之三:多线程

在看多线程前,让我们看看创建一个线程的消耗:

  1. 创建Thread核心对象(128K?),保留1M的Thread Stack地址空间
  2. CPU Task Manager多一个Task
  3. 线程切换非常耗时

是的,如果是单CPU的机器,多线程就意味着性能降低,因为CPU增加了额外的工作。我们需要多线程的原因就是因为UI线程必须能够释放出来去处理消息队列。

在Win32开发中,我们可以调用CreateThread来创建一个线程(Worker线程),来处理耗时的操作,在.Net中对应的就是创建一个Thread对象。这种办法的问题是,每次创建一个Thread,OS都会创建Thread对象,而使用完后该Thread就会被销毁。创建销毁线程对象都是耗时的工作。.Net提供了线程池,可以重复利用线程来提高性能。我们可以用ThreadPool类,或者Delegate的BeginInvoke来使用.Net线程池。

于是,上面的代码很自然的就改为:

 1 private void button1_Click(object sender, EventArgs e)
 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。于是,上面的代码就变成:

 1 public Form1()
 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的引用,然后再来判断,于是代码变为:

 1 public partial class Form1 : Form
 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是如何解决这些问题的。