理解Windows窗体和WPF中的跨线程调用

来源:互联网 发布:淘宝在线人数免费插件 编辑:程序博客网 时间:2024/06/06 04:13
 你曾开发过Windows窗体程序,可能会注意到有时事件处理程序将抛出InvalidOperationException异常,信息为“线程调用非法:在非创建控件的线程上访问该控件”。这种Windows窗体应用程序中线程调用时的一个最为奇怪的行为就是,有些时候它没什么问题,可有些时候却会出现问题。在WPF(Windows Presentation Foundation)中,这个行为有所改变。WPF线程调用将永远不会成功。不管怎样,至少这能让你在开发过程中更容易地找到问题的所在。

在Windows窗体中,解决方法是首先检查Control.InvokeRequired属性,若Control. InvokeRequired属性为true,那么调用ControlInvoke()。在WPF中,可以使用System.Windows.Threading.Dispatcher中的Invoke()和BeginInvoke()方法。这两种情况中都发生了很多事情,你也同样有别的选择。这两个API为你做了很多事情,不过在某些情况下仍有可能会失败。因为这些方法将用来处理线程调用,因此若是没有正确使用(甚至是正确使用但没有完全理解其行为)的话,也有可能会导致竞争条件的出现。

无论是Windows窗体还是WPF,问题的成因都很简单:Windows控件使用的是组件对象模型(Component Object Model,COM)单线程单元(Single-threaded Apartment,STA)模型,因为其底层的控件是单元线程(apartment-threaded)的。此外,很多控件都用消息泵(message pump)来完成操作。因此,这种模型就需要所有调用该控件的方法都和创建该控件的方法位于同一个线程上。Invoke、BeginInvoke和EndInvoke调度方法都需要在正确的线程上调用。两种模型的底层代码非常相似,因此这里将以Windows窗体的API为例。不过当调用方法有所区别时,我将同时给出两个版本。其具体的做法非常复杂,但仍需要深入了解。

首先,我们来看一段简单的泛型代码,能够让你在遇到此种情况时得到一定的简化。匿名委托让仅在一处使用的小方法更加易于编写。不过,匿名委托却并不能与接受System.Delegate类型的方法(例如Control.Invoke)配合使用。因此,你需要首先定义一个非抽象的委托类型,随后在使用Control. Invoke时传入。

private void OnTick(object sender, EventArgs e)

{

    Action action = () =>

        toolStripStatusLabel1.Text =

            DateTime.Now.ToLongTimeString();

    if (this.InvokeRequired)

        this.Invoke(action);

    else

        action();

}

C# 3.0大大简化了上述代码。System.Core.Action委托定义了一类专门的委托类型,用来表示不接受任何参数并返回void的方法。lambda表达式也能够更加简单地定义方法体。但若你仍旧需要支持C# 2.0,那么需要编写如下的代码。

delegate void Invoker();

private void OnTick20(object sender, EventArgs e)

{

    Action action = delegate()

    {

        toolStripStatusLabel1.Text =

            DateTime.Now.ToLongTimeString();

    };

    if (this.InvokeRequired)

        this.Invoke(action);

    else

        action();

}

WPF中,则需要使用控件上的System.Threading.Dispatcher对象来执行封送操作。

private void UpdateTime()

{

    Action action = () => textBlock1.Text =

        DateTime.Now.ToString();

    if (System.Threading.Thread.CurrentThread !=

        textBlock1.Dispatcher.Thread)

    {

        textBlock1.Dispatcher.Invoke

            (System.Windows.Threading.DispatcherPriority.Normal,

            action);

    }

    else

    {

        action();

    }

}

这种做法让事件处理程序的实际逻辑变得更加模糊,让代码难以阅读和维护。这种做法还需要引入一个委托定义,仅仅用来满足方法的签名。

使用一小段泛型代码即可改善这种情况。下面的这个ControlExtensions静态类所包含的泛型方法适用于调用不超过两个参数的委托。再添加一些重载即可支持更多的参数。此外,其中的方法还可使用委托定义来调用目标方法,既可以直接调用,也可以通过Control.Invoke的封送。

public static class ControlExtensions

{

    public static void InvokeIfNeeded(this Control ctl,

        Action doit)

    {

        if (ctl.InvokeRequired)

            ctl.Invoke(doit);

        else

            doit();

    }

    public static void InvokeIfNeeded<T>(this Control ctl,

        Action<T> doit, T args)

    {

        if (ctl.InvokeRequired)

            ctl.Invoke(doit, args);

        else

            doit(args);

    }

}

在多线程环境中使用InvokeIfNeeded能够很大程度上简化事件处理程序的代码。

private void OnTick(object sender, EventArgs e)

{

    this.InvokeIfNeeded(() => toolStripStatusLabel1.Text =

        DateTime.Now.ToLongTimeString());

}

对于WPF控件,也可以创建出一系列类似的扩展。

public static class WPFControlExtensions

{

    public static void InvokeIfNeeded(

        this System.Windows.Threading.DispatcherObject ctl,

        Action doit,

        System.Windows.Threading.DispatcherPriority priority)

    {

        if (System.Threading.Thread.CurrentThread !=

            ctl.Dispatcher.Thread)

        {

            ctl.Dispatcher.Invoke(priority,

                doit);

        }

        else

        {

            doit();

        }

    }

    public static void InvokeIfNeeded<T>(

        this System.Windows.Threading.DispatcherObject ctl,

        Action<T> doit,

        T args,

        System.Windows.Threading.DispatcherPriority priority)

    {

        if (System.Threading.Thread.CurrentThread !=

            ctl.Dispatcher.Thread)

        {

            ctl.Dispatcher.Invoke(priority,

                doit, args);

        }

        else

        {

            doit(args);

        }

    }

}

WPF版本没有检查InvokeRequired,而是检查了当前线程的标识,并于将要进行控件交互的线程进行比较。DispatcherObject是很多WPF控件的基类,用来为WPF控件处理线程之间的分发操作。注意,在WPF中还可以指定事件处理程序的优先级。这是因为WPF应用程序使用了两个UI线程。一个线程用来专门处理UI呈现,以便让UI总是能够及时呈现出动画等效果。你可以通过指定优先级来告诉框架哪类操作对于用户更加重要:要么是UI呈现,要么是处理某些特定的后台事件。

这段代码有几个优势。虽然使用了匿名委托定义,不过事件处理程序的核心仍位于事件处理程序中。与直接使用Control.IsInvokeRequired或ControlInvoke相比,这种做法更加易读且易于维护。在ControlExtensions中,使用了泛型方法来检查InvokeRequired或是比较两个线程,这也就让使用者从中解脱了起来。若是代码仅在单线程应用程序中使用,那么我也不会使用这些方法。不过若是程序最终可能在多线程环境中运行,那么不如使用上面这种更加完善的处理方式。

若想支持C# 2.0,那么还要做一些额外的工作。主要在于无法使用扩展方法和lambda表达式语法。这样,代码将变得有些臃肿。

// 定义必要的Action:

public delegate void Action;

public delegate void Action<T>(T arg);

// 3个和4个参数的Action定义省略

public static class ControlExtensions

{

    public static void InvokeIfNeeded(Control ctl, Action doit)

    {

        if (ctl.InvokeRequired)

            ctl.Invoke(doit);

        else

            doit();

    }

    public static void InvokeIfNeeded<T>( Control ctl,

        Action<T> doit, T args)

    {

        if (ctl.InvokeRequired)

            ctl.Invoke(doit, args);

        else

            doit(args);

    }

}

// 其他位置:

private void OnTick20(object sender, EventArgs e)

{

    ControlExtensions.InvokeIfNeeded(this, delegate()

    {

        toolStripStatusLabel1.Text =

          DateTime.Now.ToLongTimeString();

    });

}

在将这个方法应用到事件处理程序之前,我们来仔细看看InvokeRequired和Control.Invoke所做的工作。这两个方法并非没有什么代价,也不建议将这种模式应用到各处。Control.InvokeRequired用来判断当前代码是运行于创建该控件的线程之上,还是运行于另一个线程之上。若是运行于另一个线程之上,那么则需要使用封送。大多数情况下,这个属性的实现还算简单:只要检查当前线程的ID,并与创建该控件的线程ID进行比较即可。若二者匹配,那么则无需Invoke,否则就需要Invoke。这个比较并不需要花费太多时间,WPF版本的这类扩展方法也是执行了同样的检查。

不过其中还有一些边缘情况。若需要判断的控件还没有被创建,在父控件已创建好,正在创建子控件时就可能发生这个情况。那么此时,虽然C#对象已经存在,不过其底层的窗口句柄仍旧为null。此时也就无法进行比较,因此框架本身将花费一定代价来处理这种情况。框架将沿着控件树向上寻找,看看是否有上层控件已被创建。若是框架能够找到一个创建好了的窗体,那么该窗体将作为封送窗体。这是一个非常合理的假设,因为父控件将要负责创建子控件。这种做法可以保证子控件将会与父控件在同一个线程上创建。找到合适的父控件之后,框架即可执行同样的检查,比较当前线程的ID和创建该父控件的线程的ID。

不过,若是框架无法找到任何一个已创建的父窗体,那么则需要找到一些其他类型的窗体。若在层次体系中无法找到可用的窗体,那么框架将开始寻找暂存窗体(parking window),暂存窗体让你不会被某些Win32 API奇怪的行为所干扰。简而言之,有些对窗体的修改(例如修改某些样式)需要销毁并重新创建该窗体。暂存窗体就是用来在父窗体被销毁并重新创建的过程中用来临时保存其中的控件的。在这段时间内,UI线程仅运行于暂存窗体中。

WPF中,得益于Dispatcher类的使用,上述很多过程都得到了简化。每个线程都有一个Dispatcher。在第一次访问某个控件的Dispatcher时,类库将察看该线程是否已经拥有了Dispatcher。若已经存在,那么直接返回。如果没有的话,那么将创建一个新的Dispatcher对象,并关联在控件及其所在的线程之上。

不过这其中仍旧有可能存在着漏洞和发生失败。有可能所有的窗体,包括暂存窗体都没有被创建。在这种情况下,InvokeRequired将返回false,表示无需将调用封送到另一个线程上。这种情况可能会比较危险,因为这个假设可能是错误的,但框架也仅能做到如此了。任何需要访问窗体句柄的方法都无法成功执行,因为现在还没有任何窗体。此外,封送也自然会失败。若是框架无法找到任何可以封送的控件,自然也无法将当前调用封送到UI线程上。于是框架选择了一个可能在稍后出现的失败,而不是当前会立即出现的失败。幸运的是,这种情况在实际中非常少见。不过在WPF中,Dispatcher还是包含了额外的代码来预防这种情况。

总结一下InvokeRequired的相关内容。一旦控件创建完成,那么InvokeRequired的效率将会不错,且也能保证安全。不过若是目标控件尚未被创建,那么InvokeRequired则可能会耗费比较长的时间。而若是没有创建好任何控件,那么InvokeRequired则可能要相当长的时间,同时其结论也无法保证正确。但虽然Control.InvokeRequired有可能耗时较长,也比非必要地调用Control.Invoke要高效得多。且在WPF中,很多边缘情况都得到了优化,性能要比Windows窗体的实现提高不少。

接下来看看Control.Invoke的执行过程。(Control.Invoke的执行非常复杂,因此这里将仅做简要介绍。)首先,有一个特殊情况是虽然调用了Invoke方法,不过当前线程却和控件的创建线程一样。这是个最为简单的特例,框架将直接调用委托。即当InvokeRequired返回false时仍旧调用Control.Invoke()将会有微小的损耗,不过仍旧是安全的。

在真正需要调用Invoke时会发生一些有趣的情况。Control.Invoke能够通过将消息发送至目标控件的消息队列来实现线程调用。Control.Invoke还创建了一个专门的结构,其中包含了调用委托所需要的所有信息,包括所有的参数、调用栈以及委托的目标等。参数均会被预先复制出来,以避免在调用目标委托之前被修改(记住这是在多线程的世界中)。

在创建好这个结构并添加到队列中之后,Control.Invoke将向目标对象发送一条消息。Control.Invoke随后将在等待UI线程处理消息并调用委托时组合使用旋转等待(spin wait)和休眠。这部分的处理包含了一个重要的时间问题。当目标控件开始处理Invoke消息时,它并不会仅仅执行一个委托,而是处理掉队列中所有的委托。若你使用的是Control.Invoke的同步版本,那么不会看到任何效果。不过若是混合使用了Control.Invoke和Control.BeginInvoke,那么行为将有所不同。这部分内容将在稍后继续介绍,目前需要了解的是,控件的WndProc将在开始处理消息时处理掉每一个等待中的Invoke消息。对于WPF,可控制的要多一些,因为可以指定异步操作的优先级。你可以让Dispatcher将消息放在队列中时给出三种优先级:(1)基于系统或应用程序的当前状况;(2)使用普通优先级;(3)高优先级。

当然,这些委托中可能会抛出异常,且异常无法线程传递。因此框架将把对委托的调用用try/catch包围起来并捕获所有的异常。随后在UI线程完成处理之后,其中发生的异常将被复制到专门的数据结构中,供原线程分析。

在UI线程处理结束之后,Control.Invoke将察看UI线程中抛出的所有异常。如果确有异常发生,那么将在后台线程中重新抛出。若没有异常,那么将继续进行普通的处理。可以看到,调用一个方法的过程并不简单。

Control.Invoke将在执行封送调用时阻塞后台线程,虽然实际上在多线程环境中运行,不过仍旧让人觉得是同步的行为。

不过这可能不是你所期待的。很多时候,你希望让工作线程触发一个事件之后继续进行下面的操作,而不是同步地等待UI。这时则应该使用BeginInvoke。该方法的功能和Control.Invoke基本相同,不过在向目标控件发送消息之后,BeginInvoke将立即返回,而不是等待目标委托完成。BeginInvoke支持发送消息(可能在稍后才会处理)后立即返回到调用线程上。你可以根据需要为ControlExtensions类添加相应的异步方法,以便简化异步线程UI调用的操作。虽然与前面的那些方法相比,这些方法带来的优势不那么明显,不过为了保持一致,我们还是在ControlExtensions中给出。

public static void QueueInvoke(this Control ctl, Action doit)

{

    ctl.BeginInvoke(doit);

}

public static void QueueInvoke<T>(this Control ctl,

    Action<T> doit, T args)

{

    ctl.BeginInvoke(doit, args);

}

QueueInvoke并没有在一开始检查InvokeRequired。这是因为即使当前已经运行于UI线程之上,你仍可能想要异步地调用方法。BeginInvoke()就实现了这个功能。Control.BeginInvoke将消息发送至目标控件,然后返回。随后目标控件将在其下一次检查消息队列时处理该消息。若是在UI线程中调用的BeginInvoke,那么实际上这并不是异步的:当前操作后就会立即执行该调用。

这里我忽略了BeginInvoke所返回的Asynch结果对象。实际上,UI更新很少带有返回值。这会大大简化异步处理消息的过程。只需简单地调用BeginInvoke,然后等待委托在稍后的某个时候执行即可。但编写委托方法时需要格外小心,因为所有的异常都会在线程封送中被默认捕获。

在结束这个条目之前,我再来简单介绍一下控件的WndProc。当WndProc接收到了Invoke消息之后,将执行InvokeQueue中的每一个委托。若是希望按照特定的顺序处理事件,且你还混合使用了Invoke和BeginInvoke,那么可能会在时间上出现问题。可以保证的是,使用Control. BeginInvoke或Control.Invoke调用的委托将按照其发出的顺序执行。BeginInvoke仅仅会在队列中添加一个委托。不过稍后的任意一个Control.Invoke调用均会让控件开始处理队列中所有的消息,包括先前由BeginInvoke添加的委托。“稍后的某一时间”处理委托意味着你无法控制“稍后的某一事件”到底是何时。“现在”处理委托则意味着应用程序先执行所有等待的异步委托,然后处理当前的这一个。很有可能的是,某个由BeginInvoke发出的异步委托将在Invoke委托调用之前改变了程序的状态。因此需要小心地编写代码,确保在委托中重新检查程序的状态,而不是依赖于调用Control.Invoke时传入的状态。

简单举例,如下版本的事件处理程序很难显示出那段额外的文字。

private void OnTick(object sender, EventArgs e)

{

    this.InvokeAsynch(() => toolStripStatusLabel1.Text =

        DateTime.Now.ToLongTimeString());

    toolStripStatusLabel1.Text += "  And set more stuff";

}

这是因为第一个修改会被暂存于队列中,随后在开始处理接下来的消息时才会修改文字。而此时,第二条语句已经给标签添加了额外的文字。

Invoke和InvokeRequired为你默默地做了很多的工作。这些工作都是必需的,因为Windows窗体控件构建于STA模型之上。这个行为在最新的WPF中依旧存在。在所有最新的.NET Framework代码之下,原有的Win32 API并没有什么变化。因此这类消息传递以及线程封送仍旧可能导致意料之外的行为。你必须对这些方法的工作原理及其行为有着充分的理解。

原创粉丝点击