编写拙作《关于COM组件线程模型的实验》的过程中,发现自己无法合理解释特定情况下程序的运行情况。为更深入理解COM的线程模型,合理解释程序运行情况,找了一些资料看。发现一篇英文文章不错,特地翻译出来。关于对STA中对象的回调处理、其他套间中的线程对MTA中的对象的调用是通过RPC线程池里的线程进行的,以及不应该在自由线程和双线程模型的组件中使用线程局部存储这三点,是我在这篇文章中首次看到的,也是这篇文章比其他资料深入的地方,很值得学习和思考。

原文出处:http://www.codeguru.com/cpp/com-tech/activex/apts/article.php/c5529/Understanding-COM-Apartments-Part-I.htm

COM引入了一种并发机制,可以截获并串行化对于设计为只能一次处理一个方法调用的对象的并发调用。这种机制以称为“套间(apartments)”的抽象边界概念为中心。在解决不能正确工作的COM系统的问题时,我发现大约40%问题的原因是缺乏对于套间的理解。这种知识的缺乏并不意外,因为套间是COM中最复杂的领域,而且也没有很好地文档化。微软的目的是好的,但是在Windows NT 3.51中为COM引入套间的时候,他们为粗心的开发者建立了一个雷区。遵守规则就可以避免踩到地雷,但是如果不知道规则是什么,就很难遵守规则。

本文是一个两部分系列文章的第一部分。系列文章将解释什么是套间、套间存在于什么地方,以及如何避免套间引入的问题。文章的第一部分将介绍COM基于套间的并发机制;第二部分将介绍一些规则,以避免隐藏而又令人讨厌的Bug

1 套间基础

套间是一个并发边界,一个在对象和客户线程之间的假想的盒子,用以隔离具有不兼容线程特性的COM客户和COM对象。套间存在的主要目的是让COM可以串行化对于非线程安全对象的方法调用。如果没有告诉COM对象是线程安全的,则COM不会允许多个调用同时到达对象。相反地,如果告诉COM对象是线程安全的,则COM会让对象处理多个线程中的并发调用。

每个使用COM的线程,以及这些线程创建的每个对象,都被分配到某个套间中。套间不能跨越进程边界,所以如果对象和其客户位于不同的进程中,则它们也在不同的套间中。客户创建进程内对象的时候,COM必须决定将其放在创建者套间中,还是放在客户进程中的另一个套间里。如果COM将对象和创建对象的线程放在同一个套间中,则客户将直接访问对象。如果COM将对象放在另一个不同的套间中,则创建对象的线程对对象的调用将被列集(marshaled)

1展示了线程和对象共享套间,以及线程和对象位于不同套间时二者的关系。线程1中的调用将直接访问线程创建的对象;线程2中的调用将通过代理(proxy)和桩基(stub)进行。COM在将接口指针列集到线程2的套间时创建代理/桩基对。跨越套间边界传递接口指针时必须对指针进行列集,这是一条规则。这意味着,如果涉及到定制接口,即使是进程内对象,如果需要与位于其他套间中的客户通信,也还是需要提供与跨进程和跨机器方法调用时一样的,用于列集支持的代理/桩基DLL(或者选择类型库列集时的类型库)。

理解COM套间(第一部分)_COM

1:对其他套间中对象的调用被列集,即使对象和调用者属于同一个进程

Windows NT 4.0支持两种类型的套间,Windows 2000则支持三种类型。这三种类型的套间是:

l 单线程套间(Single-threaded apartmentsSTA)(Windows NT 4.0Windows 2000

l 多线程套间(Multithreaded apartmentsMTA)(Windows NT 4.0Windows 2000

l 线程中立套间(Neutral-threaded apartmentsNTA)(仅Windows 2000

每个线程只能有一个单线程套间,但是可以容纳的对象个数是无限的。而且,COM不限制进程中STA的个数。进程中第一个创建的STA称为进程的主STA。对STA中对象的调用在投递前都先传递到STA线程中。因为所有对对象的调用都在同一个线程中执行,所以基于STA的对象不能同时执行多个调用。COM使用STA来串行化对于非线程安全对象的调用。如果不明确告诉COM对象是线程安全的,则COM将把对象放到STA中,从而让对象不会被并发访问。

关于STA操作的一个有趣方面是:COM如何将对基于STA的对象的调用传递到STA线程中。COM创建STA的时候,会同时创建一个隐藏窗口,这个窗口的窗口过程知道如何处理代表方法调用的私有消息。目标是STA的方法调用离开COMRPC通道时,COM将向STA窗口投递一个代表这个调用的消息。STA中的线程收到消息后,将消息分发到隐藏窗口,隐藏窗口的窗口过程将调用投递给桩基,桩基将最终执行对对象的调用。因为线程一次只能接收、分发和处理一个消息,STA自然而有效地成为调用串行机制。如图2所示,如果同时发起n个对基于STA的对象的调用,则调用将被排队,然后依次投递给对象。

理解COM套间(第一部分)_COM_02

2:进入STA的调用被转化为消息,投递到消息队列。消息队列中的消息被STA中运行的线程依次转化回方法调用。

调用离开STA时的情形也同样重要。COM不能让线程阻塞在RPC通道中,因为回调可能导致死锁:想象一下当STA线程调用其他套间中的对象,而这个对象反过来调用STA中的另一个对象时会发生什么。如果STA线程阻塞在RPC通道中,则调用永远不会返回,因为唯一一个可以处理回调的线程正在RPC通道中等待最初的调用返回。因此,调用离开STA时,COM会阻塞STA线程,但是让STA线程仍然可以处理回调。为了让回调可以发生,COM会跟踪每个方法调用的因果关系,以便能够识别何时应该释放正在RPC通道中等待某方法调用返回的STA线程,让其处理另一个进入的调用。默认情况下,STA入口有调用到达时,如果STA线程正在等待出调用返回,而且到达的入调用与正在等待返回的出调用不属于同一个因果链,则到达的入调用将阻塞。编写消息过滤器可以改变这个默认行为,下一部分对此进行讨论。

多线程套间完全不同。COM限制每个进程只能有一个MTA,但是没有限制MTA中线程和对象的个数。MTA没有隐藏窗口和消息队列。对MTA中对象的调用被随机地传递给RPC线程池里的线程,不会串行化(见图3)。这意味着MTA中的对象最好是线程安全的,因为没有外部机制保证基于MTA的对象一次只接收一个调用,对象可能被不同的RPC线程并发地调用。

理解COM套间(第一部分)_DCOM_03

3:进入MTA的调用被传递到RPC线程而不会被串行化

对于离开MTA的调用,COM不会进行特别处理。调用线程可以阻塞在RPC通道中,如果发生回调,不会产生死锁,因为回调会被传递给另一个RPC线程。

Windows 2000引入了第三种套间类型:线程中立套间,即NTACOM限制每个进程只能有一个NTA。不会给NTA分配线程;NTA仅用于容纳对象。对基于NTA的对象的调用不会引起线程切换,调用线程会进入NTA。也就是说,从STA或者MTA发起对同一个进程中NTA的调用时,调用线程会暂时离开其套间,直接执行NTA中的代码。这和基于STAMTA的对象是不同的:其他套间对基于STAMTA的对象的调用总是会导致线程切换的。在不同套间之间列集调用的时候,线程切换会占用大量开销。排除线程切换会改进性能,所以NTA是让跨套间方法调用执行更高效的优化。Windows 2000支持基于活动的外部同步机制,可以指定是否串行化对基于NTA的对象的调用。基于活动的串行化比基于消息的串行化更有效,它可以在对象到对象级别进行。

2如何为线程分配套间

以任何方式使用COM的线程必须首先调用CoInitialize或者CoInitializeEx初始化COM。调用这两个函数时,线程将被放入到套间中。放入到什么类型的套间决定于线程调用哪个函数以及如何调用。

l 如果线程调用CoInitialize,则COM创建新的STA并且将线程放入其中:

理解COM套间(第一部分)_COM_04

l 如果线程调用CoInitializeEx并且传递参数COINIT_APARTMENTTHREADED,则线程也被放入到一个STA中:

理解COM套间(第一部分)_COM_05

l 如果调用CoInitializeEx并且传递参数COINIT_MULTITHREADED,则线程被放入到进程里唯一的MTA中:

理解COM套间(第一部分)_COM_06

从大的范围来看,进程的套间配置取决于进程中的线程如何调用CoInitialize[Ex]。存在不调用CoInitialize[Ex]函数而COM创建套间的情况,但是为了让问题简单,暂时不讨论这种情况(but for now we won't muddy the water by considering such circumstances)。

作为一个例子,假设启动了一个新进程,进程中的线程1调用CoInitialize

理解COM套间(第一部分)_DCOM_07

随后,线程1启动线程2,3,45,这些线程使用下列语句初始化COM

理解COM套间(第一部分)_COM_08

4展示了最终的套间配置。线程1,25被分配到单独的STA中,因为每个STA中只能有一个线程。另一方面,线程34被分配到进程的MTA中。记住:COM不会为进程创建多个MTA,而是在唯一的一个MTA中放置任何数量的线程。

理解COM套间(第一部分)_COM_09

4:进程有五个线程,分布于三个STA和一个MTA中。

如果你喜欢刨根问底(if you're a nuts and bolts person),你可能好奇地想知道套间的物理性质,也就是COM如何在内部表示套间。创建新套间时,COM会在堆上分配套间对象,并且用套间ID、套间类型等重要信息进行初始化。为线程分配套间的时候,COM会在线程本地存储(TLS)中记录相应的套间对象的地址。因此,COM在线程中执行时,如果想知道线程是否属于某个套间,只需要在线程本地存储中查找套间对象的地址就可以了。

3 如何为进程内对象分配套间

现在该介绍如何为对象分配套间了。COM用于决定在哪个套间中创建对象的算法对于进程内对象和进程外对象是不同的。进程内对象更有趣,因为只有进程内对象是可以创建于创建者套间中的。我们首先讨论进程内对象,然后讨论进程外对象。

COM通过从注册表中读取对象的ThreadingModel值来决定在哪个套间中创建进程内对象。ThreadingModel是分配给用以标识对象DLLInprocServer32子键的命名值。下面以REGEDIT格式显示的注册表条目标识了CLSID99999999-0000-0000-0000-111111111111DLLMyServer.dllThreadingModelApartment的对象:

理解COM套间(第一部分)_COM_10

ApartmentWindows NT 4.0支持的四种线程模型之一,也是Windows 2000支持的五种线程模型之一。五种线程模型以及支持线程模型的操作系统如下:

理解COM套间(第一部分)_COM_11

Apartment Type列指示COM如何处理具有指定ThreadingModel值的对象。比如说,COM限制没有ThreadingModel值(ThreadingModel=None)的对象位于进程的主STA中;ThreadingModel=Apartment允许在任何STA(不仅仅是主STA)中创建对象;ThreadingModel=Free限制对象在MTA中;ThreadingModel=Neutral限制对象在NTA中;ThreadingModel=BothCOM可以在STA或者MTA中创建对象。

COM尽量放置进程内对象到创建者线程所属的套间中。比如说,如果STA线程创建标识为ThreadingModel=Apartment的对象,COM将在创建者线程的STA中创建对象。如果MTA线程创建ThreadingModel=Free的对象,COM将会把对象放置在MTA中。然而,有时候COM不能将对象放置在创建者的套间中。比如说,如果STA线程创建标识为ThreadingModel=Free的对象,则对象将在进程的MTA中创建,创建者线程将通过代理和桩基访问对象。类似地,如果MTA线程创建ThreadingModel=None或者ThreadingModel=Apartment的对象,则来自创建者线程的调用将从MTA列集到对象的STA中。下表显示了STAMTA线程创建具有任何有效的ThreadingModel值(或者没有ThreadingModel值)的对象时的情况:

理解COM套间(第一部分)_COM_12

为什么ThreadingModel=None限制对象在进程的主STA中?因为只有这样COM才能在不知道对象是否是线程安全时让多个对象安全地执行。假设从同一个DLL创建两个ThreadingModel=None的对象。如果这两个对象访问DLL中的任何全局变量,则COM必须在相同线程中执行所有对这两个对象的调用,否则两个对象可能试图同时读或者写同一个全局变量。限制对象在主STA中就是COM让对象在相同线程中执行的方式。

线程模型对于编码有重要的指导意义。比如说,标记为ThreadingModel=Free或者ThreadingModel=Both的对象必须是线程安全的,因为对基于MTA的对象的调用时不会被串行化。即使是ThreadingModel=Apartment的对象,也应该是部分线程安全的,因为ThreadingModel=Apartment不能阻止从同一个DLL创建多个对象,从而在共享数据上发生冲突。本文的下一部分将讨论这个问题。

4 如何为进程外对象分配套间

进程外对象没有ThreadingModel值,因为COM使用完全不同的算法为进程外对象分配套间。简而言之,COM将进程外对象放到与创建对象的服务器进程相同的套间中。大多数进程外(EXECOM服务器以调用CoInitialize或者CoInitializeEx将主线程放入到STA中开始。然后服务器为其可以创建的对象类型创建类对象并使用CoRegisterClassObject进行注册。激活请求到达以这种方式初始化的服务器时,请求在进程的STA中被处理。结果,服务器进程创建的对象也在进程的STA中。

可以将进程外对象移动到MTA中,只要将注册类对象的线程放置在MTA中就可以了。这样进入的激活请求会到达在服务器进程的MTA中执行的RPC线程。为响应请求创建的对象也将位于MTA中。

要点是,在EXE类型的COM服务器里,调用CoRegisterClassObject的线程所在的套间,也是服务器创建的对象所在的套间。当然也存在例外:使用ATLCComAutoThreadModuleCComClassFactoryAutoThread编写的EXE COM服务器将在服务器进程中创建多个STA,在其中均匀地分布对象。不过这只占现存的EXE COM服务器的很小一部分,可以认为是例外情况,而不是通常的规则。

继续下一部分

这就完了?本文展示的很多细节看起来很神秘,没什么实际价值。然而,要避免大多数常见的、折磨着COM程序员的危险陷阱,理解COM套间绝对是必要的。在下一部分你会明白我的意思的。