浅谈Excel开发:十 Excel 开发中与线程相关的若干问题

采用VSTO或者Shared Add-in等技术开发Excel插件,其实是在与Excel提供的API在打交道,Excel本身的组件大多数都是COM组件,也就是说通过Excel PIA来与COM进行交互。这其中会存在一些问题,这些问题如果处理不好,通常会导致在运行的时候会抛出难以调试的COM异常,从而导致我们开发出的Excel插件的不稳定。

和普通的WinForm程序一样,Excel也是一种STA(Single Thread Apartment)线程的应用程序,Excel插件是寄宿在Excel中运行的,这也就意味着插件也是一种STA线程的应用程序。插件在操作Excel的时候,如果是在Excel的主线程中,可以直接获取Excel对象进行操作,比如写入单元格值,对单元格进行格式化等操作。但是通常,我们会在多线程或者后台工作线程中去处理一系列复杂的数据或者逻辑,待处理完成获得结果之后,再像WinForm那样,回到UI线程中,去更新界面信息,对于Excel插件来说,就是回到Excel的主线程上来,然后再更新界面。但是Excel又是一种不同于一般Winform 类型的STA,它是COM并且Excel插件是寄宿在其上的,所以还有一些需要注意的地方。

本文首先介绍什么是STA应用程序及其工作原理,然后介绍一般的Winform程序的界面刷新逻辑,以及在这其中非常重要的一个名为SynchronizationContext对象,最后介绍在Excel插件中如何获取Excel主线程,以及这其中需要注意的地方。

Excel插件的最难处理的地方在于其应用程序的稳定性,了解了Excel中的线程以及其机制对增强系统的稳定性会有很大的帮助。

1. STA(Single Thread Apartment)

COM组件的线程模型被称之为Apartment模型,即COM对象初始化时其执行上下文(Execution Context),他要么和单个线程关联STA(Single Thread Apartment ) 要么和多个线程关联MTA(Multi Thread Apartment)。

通常COM对象为了保护其自身维护的数据不被破坏,需要运行时来保证其不被多个线程同时调用;另外也需要运行时来保证对COM对象的调用不会阻塞UI线程。Apartment 就是COM对象生存的地方,一个Apartment可以包含一个或者多个线程。对一个COM对象的调用可以由该COM生存的Apartment中的任何一个线程接受和处理。如果一个Apartment中只有一个线程,那么就是STA线程,否则就是MTA,这个是在程序初始化COM组件的时候即确定下来的。一个进程可以包含多个STA,但是只有一个MTA。

STA模型是COM对象使用的一种非线程安全的模型,这意味着他不能处理自己的线程同步,通常在UI组件中使用这种模型。因此,如果其他线程需要和UI对象进行交互,需要将消息封送(marshall)到STA线程中。在Windows 窗体应用程序中,这一过程是通过窗口消息队列 (message pumping system)来实现的。当客户线程以STA 模式启动时,系统将为STA创建一个隐藏窗口类,所有的对COM对象的调用都会放到这个隐藏窗口的消息队列中。

浅谈Excel开发:十 Excel 开发中与线程相关的若干问题

如果COM对象能够处理其本身的同步逻辑,那么就是MTA模型了,他似的多个线程能够同时和对象进行交互,而不需要进行消息调用的封送。

COM组件在创建的时候采用哪种模型,可以在注册表项的ThreadingModel值中指定:

COM组件在注册表项中的ThreadingModel属性中会有一下四个属性:

浅谈Excel开发:十 Excel 开发中与线程相关的若干问题

  • Main thread. COM对象创建 在宿主程序的主UI线程上,所有的调用必须封送到 主UI线程上 .
  • Apartment. 表示该COM对象能够运行在任何但单线程模型的线程上,如果该线程是STA线程创建的,则对象运行在该STA线程上,否则该对象运行在主STA线程上,如果主STA线程不存在,系统则会自动创建一个。
  • Free. 表示该COM对象运行在MTA上。
  • Both. 表示该COM对象在那个模型上取决于创建Apartment的类型。

浅谈Excel开发:十 Excel 开发中与线程相关的若干问题

对于.NET Framework来说,通常在任何创建UI的线程上使用[STAThread]自定义属性来标识其为STA线程。工作线程通常使用MTA模型,但是如果该工作线程需要与表示为Apartment的COM组件一起使用,那就需要标识为STAThread。

我们可以给Thread对象的ApartmentState属性指定ApartmentState枚举类型来给定该Thread属于那种类型的线程。

那么如何在其他线程中往STA线程中封送消息呢?这个就要使用SynchronizationContext对象了。

2. SynchronizationContext

关于SynchronizationContext类,Understanding SynchronizationContext (Part I) 这篇文章讲解的比较好,建议直接阅读原文。这里简要说一下,为后面讲解做铺垫。 SynchronizationContext类主要是用来进行线程间进行通讯的, 比如我有Thread1和Thread2,Thread1在做一些事情,完了之后,Thread1希望将结果传递给Thread2,希望在Thread2上执行操作。一种可行的方式是获取Thread2的SynchronizationContext对象,然后在Thread1中调用SynchronizationContext的Send或者Post方法,这样需要做的操作就会在Thread2上执行的。需要注意的是,并不是所有的线程都有一个SynchronizationContext与之联系,只有UI线程上才有SynchronizationContext,通常是在线程中,第一次创建UI控件的时候,就会将SynchronizationContext对象附加到当前的线程中。

在进行Winform开发的时候,我们知道不应该在UI线程上执行耗时的操作,因为UI线程是一种STA线程,是通过消息队列来实现的,如果某一操作耗时的话会阻塞其他的消息处理,影像用户交互。所以我们一般需要将一些耗时操纵放到后台线程中去处理,完了之后将结果Post回UI线程来进行界面刷新,我们常在非UI线程中使用Control的Invoke和BeginInvoke来实现UI界面的刷新。而Invoke和BeginInvoke在内部其实是通过继承自SynchronizationContext的对象来发送消息实现的。

通常,可以通过SynchronizationContext.Current的静态属性来获取当前线程的SynchronizationContext对象

有了UI线程的SynchronizationContext对象我们就可以在其他线程上通过该对象将我们需要在UI线程上进进行的操作Post到UI所在的线程上的消息队列中了。

下面的代码中我们在button2中新建了一个新的进程,然后在该进行的方法中传入了当前UI线程的SynchronizationCotext对象, 然后在工作线程中通过该SynchronizationContext对象的Post方法更新UI界面上的Combox对象:

private void button2_Click(object sender, EventArgs e)
{
    // let's see the thread id
    int id = Thread.CurrentThread.ManagedThreadId;
    Trace.WriteLine("Button click thread: " + id);

    // grab the sync context associated to this
    // thread (the UI thread), and save it in uiContext
    // note that this context is set by the UI thread
    // during Form creation (outside of your control)
    // also note, that not every thread has a sync context attached to it.
    SynchronizationContext uiContext = SynchronizationContext.Current;

    // create a thread and associate it to the run method
    Thread thread = new Thread(Run);

    // start the thread, and pass it the UI context,
    // so this thread will be able to update the UI
    // from within the thread
    thread.Start(uiContext);
}

private void Run(object state)
{
    // lets see the thread id
    int id = Thread.CurrentThread.ManagedThreadId;
    Trace.WriteLine("Run thread: " + id);

    // grab the context from the state
    SynchronizationContext uiContext = state as SynchronizationContext;

    for (int i = 0; i < 10; i++)
    {
        // normally you would do some code here
        // to grab items from the database. or some long
        // computation
        Thread.Sleep(10);

        // use the ui context to execute the UpdateUI method,
        // this insure that the UpdateUI method will run on the UI thread.

        uiContext.Post(UpdateUI, "line " + i.ToString());
    }
}

/// <summary>
/// This method is executed on the main UI thread.
/// </summary>
private void UpdateUI(object state)
{
    int id = Thread.CurrentThread.ManagedThreadId;
    Trace.WriteLine("UpdateUI thread:" + id);
    string text = state as string;
    comboBox1.Items.Add(text);
}

运行结果如下,我们可以看到Button以及UpdateUI的方法都是在UI线程上运行的,他们具有相同的线程ID 9,而我们新建的工作线程ID为10。

Button click thread: 9
Run thread: 10
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9

SynchronizationContext对象有Send和Post两个方法可以被我们调用。Send方法是同步的,他会等待Send进去的代理方法执行完成之后,再执行后面的代码,而Post方法则是异步的,Post之后会继续执行后续的代码,Post和Send方法会异常的捕获。其内部的实现大致如此:

public virtual void Send(SendOrPostCallback d, Object state)
{
    d(state);
}

public virtual void Post(SendOrPostCallback d, Object state)
{
    ThreadPool.QueueUserWorkItem(new WaitCallback(d), state);
}

实际上在Winform以及WPF中,我们获取到的是继承自SynchronizationContext的对象,在Winform中是System.Windows.Forms.WindowsFormsSynchronizationContext在WPF中则是 System.Windows.Threading.DispatcherSynchronizationContext。比如在Winform中是Control.BeginInvoke,在WPF或者Silverlight中是Dispatcher.BeginInvoke,这些类都重写了Post和Send方法。并提供了各自的“消息队列”(message pump)机制比如Windows API中的SendMessage 和PostMessage方法来实现各自的消息分发和处理。我们在上面代码中通过SynchronizationContext的Current获取到的实际上是一个WindowsFormsSynchronizationContext对象。真实的SynchronizationContext类不做任何实现,他更应该是一个虚类。所以通过手动new一个SynchronizationContext,然后赋予当前的线程是没有任何意义的。

3. Excel中的线程同步

前面讲过STA以及SynchronizationContext,这是因为Excel也是一种STA线程的应用程序,寄宿在Excel之上的Automation程序也是STA的,了解这一点非常重要。

通常在Excel的插件开发中,我们的业务逻辑可能比较复杂,这些复杂的计算一般不应该放到Excel的主UI线程中,我们需要新建工作线程,然后在里面进行计算。获得了结果之后,我们应该在回到Excel的UI线程中去更新界面。但是我们采用.NET技术开发Excel的Automation有一个特殊性在于,我们可以直接在非UI线程中去调用Excel的COM对象,在正常情况下,如果Excel比较空闲,没有任何问题,但是如果Excel此时比较忙,就会抛出COM异常,这种异常难以捕捉。这也是导致插件不稳定的一个非常重要的因素。这种情况通常出现在以下情形中:

  • 当我们的插件在后台线程中向服务端请求了大量数据,进行了一些处理(这种情况很常见)后,在Excel的Sheet页中将数据填充到单元格中,然后对单元格进行样式,字体等格式化,这个过程需要与COM进行交互,而且在某些情况下比较耗时,如果在此过程中,用户操作了Excel的单元格,比如鼠标点击填充过程中的单元格,这样由于后台线程通过COM对象对Excel的操作会遇到忙碌状态,就会抛出COM异常。
  • 在RTD 函数中,我们在某些情况下需要定时刷新单元格,比如在Excel中直播NBA比赛得分,使用实时的股票市场行情信息进行建模。在RTD 中,我们可以直接调用UpdateNotify方法,通常该方法应该在UI主线程上调用,这样Excel就会将其放到消息队列中,在某一时候触发。但是在很多时候,我们获取数据比如NBA实时比分,实时行情数据,通常是在另外一根工作线程中进行的,我们可以在工作线程中直接调用RTD的UpdateNofity方法,但是在Excel忙碌的时候就会抛出COM异常。

Excel中运行我们再工作线程中通过Excel 的Application对象来直接更新UI界面元素给了我们一个假象。原因在于这样是很不稳定的,非Excel主线程的每一次COM调用中都需要检查是否抛出异常,在调用过程中Excel很可能处于忙碌状态,Excel也可能在任何情况下拒绝线程对COM调用的请求,尤其是在用户正与Excel进行交互的时候。通常我们至少要捕获和处理一下三种COM异常:

  1. u const uint RPC_E_SERVERCALL_RETRYLATER = 0x8001010A;
  2. u const uint VBA_E_IGNORE = 0x800AC472;
  3. u const uint RPC_E_CALLREJECTED

在其他线程中直接调用Excel对象不仅会导致性能损失,而且会增加插件的复杂性和不稳定性。

正确的做法是,在工作线程中获取Excel主线程对象的SynchronizationContext,然后将待操作的步骤Post到Excel主线程的消息队列中等待处理。但是作为一个Addin,在一般情况下如果直接获取SynchronizationContext对象,该对象是为空的,只有在插件加载后,手动创建一个Winform窗体或者控件才能够获取到主线程的SynchronizaitonContext对象。这个From窗体通常就是我们插件的登录窗体。

比如如果要在非Excel 主线程中调用RTD函数的UpdateNotify方法,我们可以首先定义一个SynchronizationContext用来保存Excel主线程的同步上下文。

private SynchronizationContext ExcelContext; 

然后在RTD启动时获取当前Excel主线程的上下文。

public int ServerStart(IRTDUpdateEvent CallbackObject)
{
    this.ExcelContext = new SynchronizationContext();
    xlRTDUpdater = CallbackObject;
} 

最后工作线程中,通过传进来的ExcelContext,然后将需要做的操作Send或者Post回Excel主线程中执行。

ExcelContext.Post(delegate(object obj)
            {
                xlRTDUpdater.UpdateNotify();
            }, null);

所以其他非UI线程中需要操作Excel COM对象的方法经过如此封装将需要做的操作以消息的形式封送到UI线程,这样就可以解决之前调用COM组件可能出现的COM异常,能够极大提高Excel插件的稳定性。

本文很多内容涉及到COM组件的相关知识,这里只是简单的讲解了一些与Excel插件开发中可能与之相关的一些问题,介绍了如何正确的在工作线程中更新Excel UI操作的一些正确做法,希望这些知识对您有所帮助。

参考资料

本文参考了很多资料,如果您想深入了解,以下文章对您或许有帮助。

  1. Understanding The COM Single-Threaded Apartment
  2. Understanding SynchronizationContext
  3. Apartments and Pumping in the CLR
  4. What is a message pump?
  5. Excel interop COM exception while running in background
  6. Accessing Excel Application Object From An STA Thread (Refer to the post by Geoff Darst)
  7. ExecutionContext vs SynchronizationContext
  8. A CONCRETE EXAMPLE OF HOW CONTROL.INVOKE CAN CAUSE DEADLOCK
上一篇:流式大数据计算实践(4)----HBase安装


下一篇:浅谈压缩感知(十六):感知矩阵之RIP