关于COM组件线程模型的实验

转自:http://blog.sina.com.cn/s/blog_56dee71a0100ngrv.html

线程模型是COM组件很重要而又不易理解的一个属性。本文尝试用简洁明了的描述和简单的实际例子来介绍COM组件的线程模型。

 

套间

套间,英文为apartment,有的地方译作“套间”;有的译作“公寓”;还有的译作“单元”。本文采用“套间”这种译法。

套间是与COM组件密切相关的一个概念。Windows程序中,每个使用COM的线程都属于某个套间。套间是COM组件运行的逻辑线程上下文,决定了COM基础设施对位于其中的COM组件实施怎样的保护,提供怎样的同步服务。有三种套间类型:

单线程套间(STA,Single Thread Apartment)

多线程套间(MTA,Multiple Threads Apartment)

线程中立套间(TNA,Thread Neutral Apartment)

每个进程可以有多个STA,但是只能有一个MTA,也只能有一个TNA。线程调用CoInitializeEx()初始化COM基础设施时,第二个参数决定线程所属的套间类型:

如果第二个参数中带有COINIT_APARTMENTTHREADED标志,则COM基础设施为线程创建一个新的STA,线程将属于这个新创建的STA。如果这是进程中的第一个STA,则这个STA是进程的主STA。每个STA中有且只有一个线程。

如果第二个参数中带有COINIT_MULTITHREADED标志,则线程属于MTA。因为每个进程只能有一个MTA,所以所有MTA线程都在同一个MTA中运行。

线程中立套间中没有线程,也不是由线程创建的。COM基础设施为所有采用线程中立模型的组件创建线程中立套间。

 

也可以用CoInitialize()初始化COM基础设施,这个函数将为线程创建新的STA。

使用OLE功能的线程需要调用OleInitialize()初始化COM基础设施,这个函数将为线程创建新的STA。

一般而言,对于单线程套间,由于套间中只有一个线程在运行,所以不需要对其中的组件实施保护;而对于多线程套间,由于套间中有多个线程在运行,所以需要控制多个线程对组件的并发访问,也就是组件开发者必须编写代码处理多线程并发访问带来的同步问题。线程中立套间中虽然没有线程,但是任何线程可以自由地、直接地访问套间中的组件,所以也需要编写代码处理多线程并发访问带来的同步问题。

跨越套间的方法调用不能直接进行,而必须通过代理/桩基(proxy/stub)间接进行,这是COM编程的一个基本原则。所以,不能在不同套间直接传递接口指针,而必须通过列集/散集(marshal/unmarshal)间接传递。列集/散集过程就是让COM基础设施创建用于间接访问的代理/桩基的过程。

 

线程模型

线程模型是COM组件的一种属性,它决定COM组件可以存在于哪种或者哪些类型的套间中。线程模型不是在开发组件时通过程序代码指定的,而是通过注册表中的HKEY_CLASS_ROOT\CLSID\{clsid}\InprocServer32\ThreadingModel键值指定的,这是一个字符串类型的注册表键值,其中{clsid}是组件的GUID。当然,配置组件的线程模型时,要考虑组件的代码编写方式,而不应该随意指定。比如说,对于编码时没有考虑被多线程并发访问时数据保护的组件,就不应该配置为使用多线程模型。

COM组件可以被配置为使用五种线程模型之一:

 

2.1 单线程模型(Single)

在注册表中删除上述ThreadingModel键值,则COM组件被配置为使用单线程模型。使用单线程模型的组件只能存在于主STA,也就是进程中的第一个STA中。对于具有图形界面的Windows程序,第一个STA通常由主线程,也就是界面线程创建。具有图形界面的COM组件,比如说,ActiveX控件,常常使用单线程模型。

 

2.2 套间线程模型(Apartment)

设置上述ThreadingModel键值为Apartment,则COM组件被配置为使用套间线程模型。使用套间线程模型的组件只能存在于STA中。套间线程模型是在Visual Studio中使用ATL开发COM组件时默认的线程模型。

 

单线程模型和套间线程模型的共同点是在任何时刻只有一个线程可以直接访问组件,这个线程就是创建组件所在的STA的线程(不一定是调用CoCreateInstance创建组件的线程)。其他线程对组件的调用都是通过这个线程间接进行的:COM基础设施为STA创建一个隐藏的窗口,将其他线程对STA中组件的调用请求转化为发送给这个窗口的消息,然后由套间中唯一的线程处理消息,返回调用结果。所以,使用单线程模型和套间线程模型的组件要求消息队列,其他线程对组件的调用都是间接地通过消息队列进行的。这一点很重要。本文后面将通过代码验证这一点。

 

2.3 自由线程模型(Free)

设置上述ThreadingModel键值为Free,则COM组件被配置为使用自由线程模型。使用自由线程模型的组件只能存在于MTA中,可以被处于MTA中的多个线程“自由”地调用。不在MTA中的线程调用MTA组件时,COM基础设施随机选择RPC线程池中的某个RPC线程代为间接处理(RPC线程池是COM基础设施的组成部分)。由于COM基础设施没有提供任何同步方面的帮助,多个线程可以并发地调用组件的方法,所以需要编写代码对组件实施必要的保护,就像多线程编程中需要对共享资源实施保护一样。

 

2.4 双线程模型(Both)

设置上述ThreadingModel键值为Both,则COM组件被配置为使用双线程模型。此时组件与创建组件的线程存在于相同的套间中:既可能是STA,也可能是MTA。因为组件可能存在于MTA中,被多个线程并发访问,所以需要编写代码对组件实施必要的保护。

 

自由线程模型和双线程模型有一个重要的差别:采用自由线程模型的组件可以创建能够直接调用组件的工作线程;而采用双线程模型的组件不能。因为采用双线程模型的组件可能位于STA中,如果组件创建的工作线程可以直接访问组件,则工作线程也必须位于STA中(套间之外的线程对组件的调用不能直接进行),这就违反了STA中只能有一个线程的规则,破坏了COM线程模型的同步机制。

 

2.5 线程中立模型(Neutral)

设置上述ThreadingModel键值为Neutral,则COM组件被配置为使用线程中立模型。使用线程中立模型的组件位于TNA中,可以被任何线程自由地、直接地访问。调用线程访问这种类型的组件时将暂时离开所属的STA或者MTA,进入TNA,直接对组件进行方法调用,调用完成后返回STA或者MTA。与采用自由线程模型和双线程模型的组件一样,必须编写代码对组件实施必要的保护,以防止多线程并发访问可能出现的问题。线程中立模型是运行在组件服务中的,不需要用户界面的组件的最优选择。

 

示例程序

笔者编写了一个简单的程序,使用生产者-消费者问题来演示COM组件线程模型与线程套间、消息循环的关系。程序中有一个生产者线程、多个消费者线程:生产者线程创建生产者COM组件,并用其创建产品——随机整数;每个消费者线程创建一个消费者COM组件,并通过该组件消费产品。生产者生产的产品和消费者消费的产品都会显示到程序界面上。而且,在启动生产者和消费者线程之前,可以通过程序界面指定各个线程采用的套间类型以及是否使用消息循环,还可以指定各个组件的线程模型。这样,通过观察程序输出,就可以了解到生产者和消费者是否正确地进行了同步,从而认识组件的线程模型与线程的套间类型、消息循环之间的关系。

3.1 生产者组件

生产者组件有两个方法:ProduceProduct()和GetNextProduct()。

生产者线程调用组件的ProduceProduct()方法生产一个产品,也就是生成一个随机整数,代码如下:

关于COM组件线程模型的实验

消费者线程中的消费者调用生产者的GetNextProduct()方法获取一个产品,代码如下:

关于COM组件线程模型的实验



   注意这两个方法的代码都没有对共享数据进行同步访问控制。这样,在多个线程并发地调用这两个方法时,可能会发生同步方面的错误。

注意这里对Sleep()的调用:多线程程序设计中,即使程序没有正确进行同步,有时候似乎也不会发生问题。为了让没有正确进行同步时候的程序错误更快地暴露出来,这里增加了Sleep()调用。这样,一个消费者线程调用GetNextProduct(),执行到Sleep()语句时,进入休眠状态,休眠期间其他消费者线程可能再次调用GetNextProduct(),从而发生错误:再次消费同一个产品。

 

3.2 消费者组件

消费者组件只有一个方法ConsumeProduct(),代码如下:

关于COM组件线程模型的实验

方法调用生产者组件的GetNextProduct()方法获取下一个供消费的产品。多个消费者组件调用的是同一个生产者组件,而GetNextProduct()方法没有进行同步控制,所以多个消费者线程并发地调用时可能会取得错误的数据,也就是取得已经被其他消费者消费过的产品。

 

3.3 生产者线程

生产者线程创建生产者组件,每隔一定时间调用其ProduceProduct()方法生产一个产品,也就是产生一个随机整数,并且把这个整数输出到程序界面上的一个列表控件中。

生产者线程首先调用CoInitializeEx()初始化COM基础设施,其中第二个参数是从程序界面获取的,它指定了生产者线程采用的套间类型。

然后代码创建生产者并且调用CoMarshalInterThreadInterfaceInStream()将生产者接口指针列集到流接口指针pData->pStream中,以便可以传递到随后创建的消费者线程中。COM编程中一个重要原则就是:不能直接在不同套间之间传递原始接口指针,而应该通过列集、散集来间接传递。使用CoMarshalInterThreadInterfaceInStream()和CoUnmarshalInterface()是列集、散集方法之一,此外还可以通过全局接口表(GIT,Global Interface Table)进行列集、散集。

关于COM组件线程模型的实验

随后代码调用生产者每隔一定时间生产一个产品,并将其输出到界面上:

关于COM组件线程模型的实验

这段代码的关键在于MsgWaitForMultipleObjects()函数的参数及其返回值:

参数pData->hReqExitEvent是一个事件句柄,界面通过设置它为授信状态来指示请求生产者线程退出;

参数pData->dwProduceInterval是从程序界面获取的生产时间间隔。如果这个时间内没有其他条件满足使得MsgWaitForMultipleObjects()返回,则函数返回WAIT_TIMEOUT表示等待超时,随后代码会调用ProduceProduct()生产一个产品,并且输出到界面上;

参数pData->dwProduceWakeMask用以指示是否使用消息队列:如果在界面上指定了使用消息队列,则其值为QS_ALLEVENTS,表示如果消息队列中有新消息等待处理,则MsgWaitForMultipleObjects()会返回WAIT_OBJECT_0 1,随后代码会调用PeekMessage()获取消息,调用TranslateMessage()和DispatchMessage()处理消息。如果界面上没有指定使用消息队列,则参数pData->dwProduceWakeMask的值为0,表示MsgWaitForMultipleObjects()不会因为有新消息等待处理而返回,也就是不使用消息循环。

3.4 消费者线程

消费者线程会执行下列处理:

创建下一个消费者线程;

创建消费者组件,通过其获取下一个待消费的产品,并且将其输出到界面上。

代码首先初始化COM基础设施,然后对流对象指针进行散集,取得生产者接口指针;随后再次对生产者接口指针进行列集,传递给下一个消费者线程;最后代码创建消费者对象,每隔一定时间获取消费一个产品,将其输出到界面上。

消费者线程主要代码如下:关于COM组件线程模型的实验

关于COM组件线程模型的实验

 

3.5 实验及结果分析

程序界面如下:

关于COM组件线程模型的实验

可以通过各个控件进行各项设置,设置好之后点击【开始】则程序创建生产者线程和消费者线程,各个线程将在下方的列表中进行输出。列表的每一行代表一个产品,其中生产者列代表生产者生产了一个产品;各个消费者列代表一个消费者消费了一个产品。一段时间后点击【停止】,则退出各个线程。

关于COM组件线程模型的实验

通过观察列表控件的内容可以判断生产者和消费者是否正确地进行了同步,程序是否正确工作。如果如上图所示的那样,对于每一行,有且仅有一个消费者列的值与生产者列的值相等,则说明生产者生产的每个产品都仅仅被某个消费者消费了一次,程序是正确工作的。

 

3.5.1 单线程模型和套间线程模型

程序启动后不修改任何设置,直接点击【开始】,一段时间后点击【停止】,观察列表中的输出。可以发现:对于每一行,有且仅有一个消费者列的值与生产者列的值相等。这就说明了生产者和消费者之间正确地进行了同步。

如果不勾选生产者那一行后面的【使用消息循环】,然后点击【开始】,则程序输出20行后停止输出,而且消费者没有输出,这是为什么?

上文已经论述过,使用单线程模型的组件要求使用消息循环,因为组件所在套间之外的线程对组件的调用是通过消息间接进行的。如果选择不使用消息循环,则COM基础设施无法正确处理跨线程的调用,所以消费者线程无法正确工作;而生产者在生产的产品填满缓冲区之后也无法继续生产了,从而停止输出。

如果选择生产者线程使用多线程套间,不使用消息循环,点击【开始】后可以发现程序会正常工作:输出持续进行,并且每个产品只被某个消费者消费一次。程序正确工作的原因在于:生产者线程创建生产者对象的时候,COM基础设施发现请求创建组件的线程的套间类型与组件的线程模型不兼容。此时COM基础设施会创建一个新的STA,并且将新创建的生产者对象放到这个STA中,从而让生产者对象可以正确处理来自其他线程的调用请求。

如果选择生产者组件的线程模型是“套间线程模型(STA)”,程序的行为也是一样的。

 

单线程模型和套间线程模型非常相似:组件只能存在于STA中,只能有一个线程可以直接访问组件;从其他线程发起的对组件的调用,都是通过消息间接进行的,只有组件所在的STA中的线程正确处理了消息,调用才能正常进行。

单线程模型和套间线程模型的差别在于:采用套间线程模型的组件可以存在于任何STA中:可以是创建组件的线程所属的STA,也可以是COM基础设施帮助创建的STA;而采用单线程模型的组件只能存在于主STA中,也就是所有这种类型的组件都存在于进程中的第一个STA中,只能被创建第一个STA的线程直接访问。这种差别也可以用程序来验证:选择生产者组件使用单线程模型,生产者线程使用STA,不使用消息循环,但是选择界面线程使用单线程套间,观察发现程序可以正确工作。原因在于界面线程首先创建的进程中的第一个STA成为主STA,生产者组件将位于这个STA中,可以被其中的工作线程,也就是界面线程直接访问,而界面线程是有消息循环的,所以其他线程对生产者组件的访问可以正确地通过界面线程的消息循环间接进行。如果选择界面线程不使用COM,或者使用多线程套间,则会发现程序不能正确工作。

 

3.5.2 多线程模型

自由线程模型、双线程模型和线程中立模型都属于多线程模型,即可以有多个线程并发地、直接地访问组件,对组件进行方法调用。此时如果组件中没有处理同步的代码,则在访问共享数据的时候可能发生错误。以示例程序为例,选择生产者组件使用自由线程模型,则程序输出类似于下图:

关于COM组件线程模型的实验
 

有些行的各个消费者列没有内容,说明这一行对应的产品没有被消费;而有些行中有多个消费者列的值与生产者列的值相同,说明一个产品被消费了多次,也就是多个消费者线程之间没有进行正确的同步。这是因为示例程序中的生产者组件没有对并发访问进行同步。

 

如果选择生产者组件使用双线程模型,则可以发现:

1如果选择生产者线程使用单线程套间,在选择不使用消息循环时,程序输出20行之后就停止输出,而且各个消费者列没有内容;如果选择使用消息循环,则程序会产生正确的输出。

如果选择生产者线程使用多线程套间,则程序会产生错误的输出。

原因在于,使用双线程模型时,组件总是与创建组件的线程在相同套间中。这样,选择生产者线程使用单线程套间时,生产者组件在单线程套间中创建,只能被生产者线程直接访问;其他线程对生产者组件的调用需要通过生产者线程间接进行,而且要求生产者线程具有消息循环。如果没有消息循环,则生产者组件不能正确处理来自其他套间的调用,也就是消费者无法正确调用生产者组件来获取产品,所以消费者列没有输出;而生产者组件在产品缓冲区填满之后就停止生产了,所以也不再输出。如果选择生产者线程使用多线程套间,则生产者组件在多线程套间中创建,来自其他套间的调用由RPC线程池中的随机线程代为处理,可以被并发地调用。

 

上述分析的焦点在于生产者组件:由于生产者组件的代码没有对并发访问进行同步处理,所以在被并发访问时程序会产生错误的输出。但是,如果关注下消费者组件,则会发现用本文前面所论述的理论无法正确解释下图所示的情况:

关于COM组件线程模型的实验

这里,消费者线程使用单线程套间,消费者组件使用单线程模型。那么,所有消费者组件都在进程的主STA,也就是第一个消费者线程所在的STA中创建;只能被主STA中的线程,也就是第一个消费者线程直接访问。其他消费者线程由于在各自的STA中,无法直接访问自己创建的、位于主STA中的组件,而只能通过主STA线程间接访问。上图所示的情况没有选择消费者线程使用消息循环,那么,第一个消费者线程,也就是主STA线程没有消息循环,应该无法处理来自其他套间中的消费者线程对于消费者组件的调用。然而观察上图发现,各个消费者列都有输出,也就是其他消费者线程成功地对位于第一个消费者所在的STA(主STA)中的消费者组件进行了调用。这是问题一。抛开这个问题,就算其他消费者线程成功地对位于第一个消费者所在的STA(主STA)中的消费者组件进行了调用,这种调用也应该都是通过第一个消费者线程进行的,不存在并发访问的问题。然而上图所示的情况是:很多行的消费者列没有内容,而有的行里面各个消费者列都有内容,显然存在并发访问的情况。这是问题二。

笔者被这两个问题困扰了好几天,百思不得其解。后来,在完成博文《翻译:理解COM+套间(第一部分)》之后,结合文章关于STA出调用的论述,经过思考,才为上图所示的情况找到了合理的解释。消费者组件的CConsumer::ConsumeProduct()方法会调用生产者组件的CProducer::GetNextProduct()方法,而消费者组件和生产者组件位于不同的套间,所以这是一个跨套间方法调用,相对于消费者组件所在的STA来说,是一个“出调用”。博文《翻译:理解COM+套间(第一部分)》对于STA的出调用有以下论述:“调用离开STA时,COM会阻塞STA线程,但是让STA线程仍然可以处理回调。为了让回调可以发生,COM会跟踪每个方法调用的因果关系,以便能够识别何时应该释放正在RPC通道中等待某方法调用返回的STA线程,让其处理另一个进入的调用。默认情况下,STA入口有调用到达时,如果STA线程正在等待出调用返回,而且到达的入调用与正在等待返回的出调用不属于同一个因果链,则到达的入调用将阻塞。”COM怎样实现STA在等待出调用返回的同时,仍然可以处理回调的呢?由于STA线程是通过消息队列处理其他套间对于STA的入调用的,所以笔者猜测是通过消息等待函数MsgWaitForMultipleObjects或者MsgWaitForMultipleObjectsEx实现的。这样,STA线程在等待出调用返回的时候,仍然可以处理消息队列中新到达的消息。结合本文讨论的示例程序来看,第一个消费者线程在等待对于生产者组件的调用(出调用)返回的同时,可以处理消息队列中新到达的消息,也就是可以处理其他消费者线程对于主STA中消费者组件的调用。这样,多个消费者组件(各个消费者线程只调用自己创建的消费者组件)被并发地调用,然后多个消费者组件又并发地调用同一个生产者组件,所以就产生了上面的程序输出。当然,这里“并发”不太明显:所有对于多个消费者组件的调用最终都是由第一个消费者线程直接进行的,不存在并发。然而,生产者组件在MTA中,而消费者组件在STA中,因此消费者组件对于生产者组件的调用是通过RPC线程池里的随机RPC线程处理的,对生产者组件的多次调用很可能是由多个不同的RPC线程代为执行的。从这个角度看,就是“并发”调用了。

上述对于程序运行情况的解释,只是笔者的猜测,不一定正确。然而,使用上述思路可以解释生产者组件使用线程中立套间时程序的运行情况:

关于COM组件线程模型的实验

这一幅图展示的运行配置,与前一幅图只有一处不同:生产者组件的线程模型从“多线程模型”改成了“线程中立模型”。这一处不同使得只有第一个消费者线程有输出。原因在于,第一个消费者线程在调用使用线程中立模型的生产者组件时,临时离开线程所在的STA,进入到生产者组件所在的TNA中,直接对生产者组件进行调用。这样就不存在等待出调用返回的问题,也就不存在上述使用消息等待函数MsgWaitForMultipleObjects或者MsgWaitForMultipleObjectsEx的过程了。于是,其他消费者线程调用位于第一个消费者线程所在的STA(主STA)中的消费者组件时所投递的消息就一直在第一个消费者线程的消息队列中等待处理,使得其他消费者线程对于消费者组件的调用无法完成,不会返回,所以第一个消费者之外的其他消费者没有输出。

 

参考资料:

《COM+编程指南》,机械工业出版社,2002年1月第1版第1次印刷

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值