[转]理解COM套间(第二部分)

时间:2021-12-31 09:00:25

     本文的前一部分阐述了为什么和怎样使用COM套间。读过之后,你会知道,调用CoInitialize或者CoInitializeEx的时候,线程被放入到套间中。你还会知道,对象创建的时候也被放入到套间中,COM使用注册表中的ThreadingModel值决定将进程内对象放到什么类型的套间中。

你还会知道,有三种类型的套间:单线程套间STA;多线程套间MTA;线程中立套间NTA。Windows 2000支持所有这三种套间类型,而Windows NT 4.0只支持两种(STA和MTA)。各种类型的COM套间具有下列特征:

  1. 每个STA中只能有一个线程,但是COM不限制进程中STA的个数。进入STA的调用被传递到STA中唯一的一个线程。这样,STA中的对象一次只能接收和处理一个方法调用,并且对象收到的每个方法调用都来自同一个线程。COM通过向为STA服务的隐藏窗口投递私有消息来将入调用传递给STA线程。
  2. 每个进程只能有一个MTA,但是可以在其中运行的线程个数是没有限制的。对MTA中对象的方法调用在进入套间的时候被随机地传递给RPC线程。COM不会对目的地是MTA的方法调用进行串行化,所以基于MTA的对象可能收到来自并发线程的并发调用。因为入调用被传递给RPC线程,所以对基于MTA的对象的每个调用都可能来自于不同的线程,哪怕每个调用都来自于同一个调用者。
  3. Windows 2000引入了NTA用于性能优化。进行跨套间方法调用时,进入STA和MTA的方法调用引起的线程切换会占用大量开销。而进入NTA的调用不会引起线程切换。如果STA或者MTA线程调用同一个进程中基于NTA的对象,线程会暂时离开其套间,直接执行NTA中的代码。

上个月的文章《深入理解IUnknown(Into the IUnknown)》展示的某些信息在那时候看起来似乎是令人绝望地抽象难懂,但是如果你想绕开折磨着很多COM程序员的陷阱,本月你将看到为什么理解套间的奥秘很重要。有两类规则需要理解并遵守:一类是关于COM客户端的,另一类是关于COM服务器的。只要遵守这些规则,COM生活将是充满欢乐的,就像《音乐之声(The Sound of Music)》中Julie Andrews唱着电影的主题曲,在高山草原上像陀螺那样旋转时候一样。如果违背这些规则,则可能遇到隐秘而很难重现的错误,而且很难诊断。我常常收到关于这些错误的电子邮件,是该为此做点什么了。

编写可以工作的COM客户端

要编写可以工作的COM客户端,需要遵循三条规则。牢记这些规则,你就可以在编写COM客户端时避免严重的错误。

规则1:客户线程必须调用CoInitialize[Ex]

线程做任何与COM相关的操作之前,必须调用CoInitialize或者CoInitializeEx初始化COM。如果客户程序有20个线程,其中10个使用COM,则这10个线程都应该调用CoInitialize或者CoInitializeEx。调用线程将在这两个API中被分配给一个套间。对于没有分配给套间的线程,COM是无法施行并发规则的。此外还要记住,成功调用了CoInitialize或者CoInitializeEx的线程应该在终止前调用CoUninitialize。否则,由CoInitialize[Ex]分配的资源将直到进程终止才释放。

这条规则看起来很简单,只是一个函数调用而已。但是你会惊奇地发现,这条规则经常被违背。违背这条规则的错误一般在调用CoCreateInstance或者其他COM API时展现。但是有时候问题直到很晚才出现,而且客户端的错误似乎与没有初始化COM没有明显的关系。

具有讽刺意味的是,有时候开发者不调用CoInitialize[Ex]的原因是,微软告诉他们不需要调用。MSDN中有篇文章说COM客户端有时候可以避免调用这个函数。但文章随后说这可能会导致拒绝访问。我近期收到一个开发者的电话,说客户线程调用Release的时候会死锁或者发生拒绝访问异常。原因是?有些线程没有调用CoInitialize[Ex]就发起方法调用了,结果调用Release的时候发生问题了。幸运地是,解决问题只需要简单地加几个CoInitialize[Ex]调用。

记住:调用CoInitialize[Ex]总是没有坏处的。对于调用COM API或者以任何方式使用COM对象的线程,调用CoInitialize[Ex]应该说是必须的。

规则2:STA线程需要消息循环

如果不理解单线程套间机制,这条规则看起来不那么明显。客户调用基于STA的对象时,调用将被传递到STA中运行的线程。COM通过向STA的隐藏窗口投递消息来完成这种传递。那么,如果STA中的线程不接收和分发消息将发生什么?调用将在RPC通道中消失,永远也不返回。它将永远凋谢在STA的消息队列中(It will languish in the STA's message queue forever)。

开发者问我为什么方法调用不返回的时候,我首先问他们“你调用的对象是在STA中吗?如果是,驱动STA的线程是否有消息循环?”。多半的回答是“我不知道”。如果你不知道,你就是在玩火。调用CoInitialize,或者使用参数COINIT_APARTMENTTHREADED调用CoInitializeEx,或者调用MFC的AfxOleInit的时候,线程被分配到一个STA中。如果随后在这个STA中创建对象,而STA线程又没有消息泵,那么对象不能接收来自其他套间的客户的方法调用。消息泵可以这样简单:

  
  
  
MSG msg;
while (GetMessage ( & msg, 0 , 0 , 0 ))
{
DispatchMessage (
& msg)
}

如果缺少这些简单的语句,把线程放入STA时要当心。一个常见的情况是MFC应用程序启动工作线程(MFC工作线程的定义是,缺少消息泵的线程),而线程调用AfxOleInit将自身放入到STA中。如果STA不容纳任何对象,或者虽然容纳对象但是却没有来自其他套间的客户,你不会遇到问题。但是如果STA容纳导出接口指针到其他套间的对象,则对这些接口指针的调用将永远不会返回。

规则3:不要在套间之间传递原始未列集的接口指针

设想编写一个有两个线程的COM客户端。两个线程都调用CoInitialize进入一个STA,然后其中一个线程——线程A,使用CoCreateInstance创建一个COM对象。线程A想要与线程B共享从CoCreateInstance返回的接口指针。所以线程A将接口指针赋值给一个全局变量,然后通知线程B指针已经准备好了。线程B从全局变量读取接口指针并且对对象发起调用。这个过程有什么错误吗?

这个过程会引发事故。问题是线程A向其他套间中的线程传递了原始未列集的接口指针。线程B应该只通过列集到线程B所属套间的接口指针与对象通信。

这里“列集(Marshaling)”的意思是给COM在线程B所属套间中创建新代理的机会,让线程B可以安全地进行调用。在套间之间传递原始接口指针的后果可以从与时间极其相关(也很难重现)的数据损坏到完全死锁。

如果线程A列集接口指针,则可以安全地与线程B共享接口指针。COM客户端有两种基本的方法将接口指针列集到其他套间:

  1. 使用COM API函数CoMarshalInterThreadInterfaceInStream和CoGetInterfaceAndReleaseStream。

线程A调用CoMarshalInterThreadInterfaceInStream列集接口指针,线程B调用CoGetInterfaceAndReleaseStream进行散集。通过函数CoGetInterfaceAndReleaseStream,COM在调用者套间中创建新的代理。如果接口指针不需要进行列集(比如说,两个线程共享同一个套间时),CoGetInterfaceAndReleaseStream会智能地不创建代理。

  1. 使用在Windows NT 4.0 Service Pack 3中首次引入的全局接口表(Global Interface Table,GIT)。

GIT是每个进程一个的表格,让各个线程可以安全地共享接口指针。如果线程A想要与同一个进程中的其他线程共享接口指针,可以使用IGlobalInterfaceTable::RegisterInterfaceInGlobal来将接口指针放到GIT中。然后想要使用接口的线程可以调用IGlobalInterfaceTable::GetInterfaceFromGlobal来获取接口指针。神奇之处在于线程从GIT获取接口指针的时候,COM会将接口指针列集到获取线程所属的套间中。

有没有不列集需要与其他线程共享的接口指针也OK的情况?有。如果两个线程属于同一个套间,则可以共享原始未列集的接口指针,而这只可能在两个线程都属于MTA时发生。如果不确定是否需要,请进行列集。调用CoMarshalInterThreadInterfaceInStream和CoGetInterfaceAndReleaseStream或者使用GIT总是无害的,因为COM只在必要的时候才进行列集。

编写可以工作的COM服务器

编写COM服务器时也应该遵守一些规则。

规则1:保护ThreadingModel=Apartment的对象的共享数据

标记对象的ThreadingModel=Apartment就可以不考虑线程安全问题?这是关于COM编程的一个最常见的错误想法。注册进程内对象的ThreadingModel=Apartment暗示COM,对象(以及从DLL创建的其他对象)会以线程安全的方式访问共享数据。这意味着已经使用临界区或者其他线程同步原语来保证在任何时刻只有一个线程可以接触到共享数据。对象之间数据共享通常有三种方式:

  • DLL中声明全局变量
  • C++类中的静态成员变量
  • 静态局部变量

为什么线程同步对于ThreadingModel=Apartment的对象是很重要的?考虑从同一个DLL创建两个对象A和B的情况。假定两个对象都读写在DLL中声明的一个全局变量。因为标记为ThreadingModel=Apartment,对象可能分别在不同的STA中创建和运行,因此,也是在不同的线程中运行。但是两个对象访问的全局变量是共享的,只在进程内实例化一次。如果来自A和B的调用几乎同时发生,而且A写入那个变量,B读取那个变量(或者相反),那么变量可能被破坏,除非串行化线程的操作。如果不提供同步机制,那么多数时候会遇到问题。最终两个线程可能在共享数据上发生冲突,后果无法预知。

存在不需要同步机制就可以安全地访问共享数据的情况吗?存在。下列条件下可以不需要同步机制:

  • 没有为对象注册ThreadingModel值(也称作ThreadingModel=None或者ThreadingModel=Single)时,所有对象在相同STA(主STA)和相同线程中运行,因此不会在共享数据上发生冲突。
  • 虽然标记为ThreadingModel=Apartment,但是确信对象将在相同的STA中运行(比如说,所有对象都由同一个STA线程创建)。
  • 确信对象不会被并发地调用时。

对于除此之外的情况,要确保ThreadingModel=Apartment的对象以线程安全的方式访问共享数据,只有这样才是正确完成了任务。

规则2:标记为ThreadingModel=Free或者ThreadingModel=Both的对象应该是线程安全的。

标记对象是ThreadingModel=Free或者ThreadingModel=Both时,对象将被或者可能被放入到MTA中。记住:COM不会串行化对基于MTA的对象的调用。因此,毫无疑问地(beyond the shadow of a doubt),除非确信对象的客户不会进行并发调用,对象应该是完全线程安全的。这意味着除了要同步由多个实例共享的数据之外,还必须同步对非静态成员变量的访问。编写线程安全的代码不容易,但是如果准备使用MTA,就必须这么做。

规则3:避免在标记为ThreadingModel=Free或者ThreadingModel=Both的对象里使用线程局部存储(TLS)

一些Windows程序员使用线程局部存储临时保存数据。设想在实现一个COM方法时,需要缓存一些关于当前调用的信息,以备下次调用时使用。这时你可能很想使用TLS。在STA中,这样做没问题。但是如果对象在MTA中,就应该像躲避瘟疫那样避免使用TLS。

为什么?因为进入MTA的调用被传递给RPC线程。每次调用可能被传递给不同的RPC线程,即使调用都是来自于同一个线程中的同一个调用者。一个线程不能访问另一个线程的线程局部存储。所以如果调用1到达线程A,对象将数据保存在TLS中;然后调用2到达线程B,对象试图取出在调用1中存入TLS的数据时,会找不到数据。这个道理很简单。

对于基于MTA的对象,在方法调用之间使用TLS缓存数据时要注意,这种方法只在所有的方法调用来自于对象所在的MTA中的同一个线程时才可以正确工作。