第二部分,理解com的套间线程
COM套间
为了理解com是如何处理线程的,读者需要掌握套间的概念!套间在应用中是一个逻辑容器,以使com对象可以分享遵循相同的线程访问规则(例在所属线程中,规则限定了对象的方法和属性在有套间和无套间情形中是如何被调用的)。套间本质上只是一个概念,不像对象有自己的属性和方法。没有句柄类型可以引用它,更没有可调用的API操纵它。对于新手来说,这或许是套间难理解的一个重要原因,它是如此的抽象。
如果存在CoCreateApartment()和一些诸如CoEnterApartment()的API函数,如果微软提供带有IApartment接口和操纵线程、对象方法的COM类,套间也许会很容易理解。从编程来说,似乎没有切实的方式去洞察套间。为了帮助新手克服刚开始学习套间遇到的困难,提供以下几点参考:
1、套间由应用创建,没有直接创建或者检查它们存在的函数。
2、线程和对象也通过应用进入套间并且参与套间相关的活动,没有现成的函数完成这一过程。
3、套间模型十分像协议,或者需遵循的规则集合。
在多线程访问众多com对象的操作系统中,我们如何能保证一个线程对com对象属性、方法的调用结果不受另一线程对该对象访问的影响?为了解决上述问题,com套间应运而生,它的出现就是为了保证所谓的现线程安全。通过套间,我们就可以保证本线程访问对象的内部状态免受其它线程访问的影响。
com中包含三种套间模型:单线程套间、多线程套间和中性套间(Neutral Apartment),每一类型套间代表了一种跨线程同步对象内部状态的机制。对于线程和对象,套间遵循以下的基本原则:
1、一个com对象仅且只能存在于一个套间。运行时对象一经创建就确定所属套间,并且直到销毁它一直存在于这个套间。
2、一个com线程(内部创建了 com对象或者调用了com方法的线程)也属于一个套间。与com对象一样,线程从创建到结束都存在于同一套间。
3、属于相同套间的线程和对象遵循相同的线程访问规则。套间内部方法的调用是直接完成的,不需要com额外的辅助。
4、不同套间的线程和对象遵循了不同的线程访问规则。跨套间方法调用通过列集实现,这就需要采用proxies和stubs。
除了保证线程安全,套间的另一好处是透明性,对象和客户端都不需要关心他们采用的套间模型。底层的套间实现细节(特别是列集机制)由com子系统管理,开发者无需关心。
(二)COM对象套间模型设定
从本段开始一直到“EXE COM服务器和套间”,期间涉及的com对象都是在DLL服务器中实现。正如上面提到的,com对象仅属于一个运行的套间,在对象创建时就已经确定。然而首先需要明白的是一个com对象是如何关联它的套间模型的?对于DLL服务器中的com类,当com线程实例化它时,该类会参考注册表“InProcServer32”字段项“ThreadingModel”的字符串值。这个设置有开发者控制。比如,当你使用ATL开发com对象时,你可以指定对象在运行时采用的线程模型。下面列举了线程模型字符串值和代表的套间模型:
序号 注册表值 套间模型
1 “Apartment” 单线程套间
2 “Single”或者空 Legacy单线程套间
3 “Free” 多线程套间
4 “Neutral” Neutral套间
5 “Both” 创建线程的套间模型
“Both”字符串值说明com对象适用于单线程套间和多线程套间。
(三)COM线程套间模型设定
每一个com线程必须通过CoInitializeEx()函数并且设定参数为COINIT_APARTMENTTHREADED或者COINIT_MULTITHREADED进行自身初始化。访问了CoInitializeEx()函数的线程即为com线程,也即线程已经进入套间。直到线程调用CoUninitialize()函数或者自身终止,才会离开套间。
中性线程套间(NTA)
Windows 2000引入了NTA用于性能优化。进行跨套间方法调用时,进入STA和MTA的方法调用引起的线程切换会占用大量开销。而进入NTA的调用不会引起线程切换。如果STA或者MTA线程调用同一个进程中基于NTA的对象,线程会暂时离开其套间,直接执行NTA中的代码。
注:因为在某些场合下,我们很有可能会遭遇到NTA的com组件,所以适当留意一下。
单线程套间(STA)
注:因为VBA只能创建和使用单线程套间,所以理解单线程套间是重点。
单线程套间只能包含一个线程(因此才称为单线程套间),但是可以包含多个对象。包含在单线程套间中的线程有一特别之处——如果线程中的对象需要导出给其它线程,那么它必须有自己的消息循环。线程通过调用CoInitializeEx()并指定函数参数为COINIT_APARTMENTTHREADED或者简介的调用CoInitizlize()函数(CoInitialize()函数实际上会调用参数设定为COINIT_APARTMENTTHREADED的CoInitializeEx())进入单线程套间。进入单线程套间的线程也可认为已经创建了那个套间(毕竟,在套间内没有其它线程第一次创建它)。通过指定“Apartment”到注册表合适位置和在单线程套间内实例化对象,我们可以说com对象进入单线程套间。
(一)STA线程访问规则
STA的线程访问规则如下:
1、所有的STA对象如STA线程一样属于相同的单线程套间。
2、STA内的所有对象只接受该STA线程的方法调用。
第一点理解起来十分自然和简单。然而,请注意在相同DLL服务器、不同STA线程中创建的同一com类的两个对象属于不同的套间。访问这两个对象输入跨套间,必须通过com的列集完成。至于第二点,有两种方式可以访问STA对象:1、STA线程内部访问。这种情形方法的调用被序列化。2、跨线程访问(也即跨套间)。这种情形下STA线程必须包含消息循环,COM才能保证对象仅接受自身STA线程的方法访问。
(二)STA中的消息循环
重要规则:STA线程需要消息循环
如果不理解单线程套间机制,这条规则看起来不那么明显。客户调用基于STA的对象时,调用将被传递到STA中运行的线程。COM通过向STA的隐藏窗口投递消息来完成这种传递。那么,如果STA中的线程不接收和分发消息将发生什么?调用将在RPC通道中消失,永远也不返回。它将永远凋谢在STA的消息队列中。
拥有消息循环的线程是UI线程,它关联着一个或多个窗口,即线程拥有这些窗口。窗口对应的窗口过程由所属线程创建。任何线程发送或者发布消息给所有的窗口,但是只有目标窗口过程才会响应这些消息。发往目标窗口的消息被同步,即窗口会保证按照消息发送或发布的顺序接受处理消息。对于Windows程序开发者来说,窗口处理过程不必是线程安全的。每一个窗口消息会转化成原子操作,只有当前消息处理完才能处理下一消息。在Windows系统中,这给com带来与生俱来的优点:使得com对象轻松的实现线程安全。COM通过发布私有消息给对象关联的隐藏窗口,实现外部套间对STA对象方法的调用。隐藏窗口的窗口过程安排处理对象的访问并且把结果返回调用者。
需要注意的是当涉及到外部套间时,COM总是引入porxies和stubs,如消息循环一样两者形成了单线程套间协议的一部分。有两个重要的知识点需要关注:
1、系统只有当访问来自外部套间时,采用消息循环激发STA对象的方法才是可使用的。如果是来自套间内的访问,完全不需要com的参与,STA线程自身会保证调用以串行方式执行。
2、如果STA线程从消息队列中获取或分发消息失败,线程套间内的com对象将无法接受套间之间的访问。
考虑到上述第2点,诸如Sleep()、WaitForSingleObject()、WaitForMultipleObjects()等影响线程消息处理顺序的函数是非常重要的。如果STA线程需要等待同步对象,为了保证消息循环不被打乱,必须采取特殊处理。
实际应用中某些情形STA线程不需要包含消息循环。
(三)STA优缺点
使用STA最大优点就是简洁。对于com对象服务器来说,除了一些基本的代码,参与的com对象和线程几乎不需要同步代码。com中所有方法的调用会自动串行。因STA对象总是由相同线程访问,即具有线程相似性。正是线程相似性,使得STA对象开发者能够采用线程局部存储保存对象内部数据的状态。VB和MFC采用这种开发技术,所以它们开发的对象是单线程套间对象。在需要支持legacy com组件的情形中采用STA是不可避免的。
任何事物都有两面性,当然使用STA也不例外点。多个线程访问同一com对象时STA架构严重降低了性能。由于每一线程访问对象都会被串行化,所以线程必须等待其他占用线程的返回。等待时间降低了应用响应或者性能。如果线程包含过多的对象时,STA架构也会降低性能。切记单线程套间包含一个线程和一个消息队列,因而STA内各个对象的访问由消息队列串行化。
鉴于STA的优缺点,使用时必须根据实际应用场景来选择。
(四)STA COM对象及其服务器实现
对于开发者,STA com对象的实现一般不必关心内部成员数据的串行化访问。然而,单线程套间本身无法确保com DLL服务器的全局数据和导出的全局函数(如DllGetClassObject()和DllCanUnloadNow())线程安全。切记com服务器对象可以在任何线程中创建,相同Dll服务器中的两个对象可以在各自独立的STA线程中创建,这保证了无需com的串行化机制服务器的全局数据和函数可以由两个不同线程正确访问。
另外,这种情形下线程的消息循环也无法提供任何帮助,毕竟这不是对象的内部状态(如果是程序就要付出代价啦),而是服务器内部状态。由于不同线程的对象可能访问服务器的全局变量和全局函数,因而它们需要适当串行化。这一规则也适用于类的静态成员函数和静态成员变量。
一个众所周知的com服务器变量是全局对象计数,其由常用的全局函数DllGetClassObject()和DllCanUnloadNow()调用。API函数InterlockedIncrement()和InterlockedDecrement()可以同步不同线程对全局对象计数的访问。
总结STA服务器DLL实现的一般原则:
1、服务器dll必须有线程安全的标准入口函数,比如函数DllGetClassObject()和DllCanUnloadNow()。
2、服务器dll的私有全局函数必须是线程安全的。
3、私有全局变量(特别是全局对象计数)必须是线程安全的。
DllGetClassObject()函数功能是提供类对象的访问。类对象根据CLSID返回,并且通过它的接口(通常是IClassFactory)指针引用自身。DllGetClassObject()函数在API函数CoGetClassObject()内部调用,com开发者无需直接调用。通过类对象的CLSID创建对象实例(由IClassFactory::CreateInstance()),我们可以把DllGetClassObject()函数视为com对象创建的开关,请记住该函数最重要的一点:影响全局对象计数。我们调用函数DllGetClassObject(),可以获得标志com服务DLL包含对象是否仍旧存在和发挥功效。DllGetClassObject()函数根据全局对象计数决定返回值,如果com服务器dll中没有对象存在,调用该函数可以从内存中卸载com服务器DLL。
函数DLLGetClassObject()和DllCanUnLoadNow()必须是线程安全的以同步全局对象计数。一般来说,当一个对象创建或者销毁的时候,全局对象计数相应增加或者减少。本文没有对私有全局函数和全局变量的线程安全进行详细介绍,只能留给专家和经验丰富者去探讨。
对于com服务器来说,保证线程安全不是复杂的过程,许多情形下只需要简单的思考。上面的这些讲解对于ATL COM服务器开发者已经足够(除了私有全局数据和全局函数的线程安全),所以他们只需要关注com对象的业务逻辑开发。
(五)STA线程实现
STA线程通过调用CoInitialize()或者CoInitializeEx(COINIT_APARTMENTTHREADED)进行初始化自身。其次,如果线程创建的对象需要导出到其它线程(亦即其它套间),该线程需要提供消息循环处理com对象隐藏窗口的消息。切记com中隐藏窗口的窗口过程接受和处理这些私有消息,STA线程自身不会处理。如下的代码示例了STA线程的框架:
DWORD WINAPI ThreadProc(LPVOID lpvParamater) { /* Initialize COM and declare this thread to be an STA thread. */ ::CoInitialize(NULL); ... ... ... /* The message loop of the thread. */ MSG msg; while (GetMessage(&msg, NULL, NULL, NULL)) { TranslateMessage(&msg);
DispatchMessage(&msg); } ::CoUninitialize(); return 0; }
STA线程十分像WinMain()函数,实际上Windows应用的WinMain()函数也运行在线程中。你可以实现像WinMain()函数一样的STA线程,即在消息循环之前创建窗口并保证窗口有合适的窗口过程。当然,在窗口过程中你可以创建和管理com对象,也可以跨套间访问外部STA对象。如果你不想在线程中创建窗口,你仍然可以创建操纵对象并且跨套间访问外部线程的方法。
(六)不需要消息循环的STA线程
STA线程有时是不需要消息循环的,比如线程仅仅创建和使用对象而不供其它套间访问的情况。示例如下:
int main() { ::CoInitialize(NULL); if (1) { ISimpleCOMObject1Ptr spISimpleCOMObject1; spISimpleCOMObject1.CreateInstance(__uuidof(SimpleCOMObject1)); spISimpleCOMObject1 -> Initialize(); spISimpleCOMObject1 -> Uninitialize(); } ::CoUninitialize(); return 0;
}
上面的控制台程序示例了主线程中创建STA而不需要消息循环。注意我们可以成功的调用Initialize()函数和Uninitialize()函数,因它们在STA内部调用不要列集和消息循环的参与。但是,如果我们调用::CoInitializeEx(NULL, COINIT_MULTITHREADED)函数,main()函数变成了多线程套间,同时程序发生了以下变化:
1、Initialize()函数和Uninitialize()函数的调用需要com列集的协助。
2、com对象spISimpleCOMObject1属于com子系统创建的默认STA套间而不是MTA套间。
3、main()线程本身仍然不要消息循环,但是默认STA需要。
4、调用Initialize()函数和Uninitialize()函数需要消息循环参与。
切记如果STA线程需要消息循环,一定要保证该消息循环稳定而不受中断的执行。
(七)示例聚焦STA
下面我们重点讲解单线程套间。采用的方法是观察com对象方法被调用时执行线程的ID,对于标准STA对象,此ID就是STA对象的ID。如果一个STA对象不属于创建它的线程(即这个线程不是STA线程),那么该线程ID与执行对象方法的线程ID不匹配。
标准STA
以一个简单的例子讲解标准STA。如下所示,例子包括一个简单的STA com对象,对应的com类CSimpleCOMObject2实现了TestMethod1()方法。TestMethod1()显示正在执行线程的ID消息框,代码如下:
STDMETHODIMP CSimpleCOMObject2::TestMethod1() { TCHAR szMessage[256]; sprintf (szMessage, "Thread ID : 0x%X", GetCurrentThreadId()); ::MessageBox(NULL, szMessage, "TestMethod1()", MB_OK); return S_OK; }
我们通过一个简单的测试程序实例化CSimpleCOMObject2并调用方法TestMethod1,代码如下:
int main() { HANDLE hThread = NULL; DWORD dwThreadId = 0; ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); DisplayCurrentThreadId(); if (1) { ISimpleCOMObject2Ptr spISimpleCOMObject2; spISimpleCOMObject2.CreateInstance(__uuidof(SimpleCOMObject2)); spISimpleCOMObject2
-> TestMethod1(); hThread = CreateThread ( (LPSECURITY_ATTRIBUTES)NULL, // SD (SIZE_T)0, // initial stack size (LPTHREAD_START_ROUTINE)ThreadFunc, // thread function (LPVOID)NULL, // thread argument (DWORD)0, // creation
option (LPDWORD)&dwThreadId // thread identifier ); WaitForSingleObject(hThread, INFINITE); spISimpleCOMObject2 -> TestMethod1(); } ::CoUninitialize(); return 0; }
线程函数ThreadFunc()函数如下:
DWORD WINAPI ThreadFunc(LPVOID lpvParameter) { ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); DisplayCurrentThreadId(); if (1) { ISimpleCOMObject2Ptr spISimpleCOMObject2A; ISimpleCOMObject2Ptr spISimpleCOMObject2B; spISimpleCOMObject2A.CreateInstance(__uuidof(SimpleCOMObject2));
spISimpleCOMObject2B.CreateInstance(__uuidof(SimpleCOMObject2)); spISimpleCOMObject2A -> TestMethod1(); spISimpleCOMObject2B -> TestMethod1(); } ::CoUninitialize(); return 0; }
线程函数中包含了展示当前线程ID消息框的函数DisplayCurrentThreadId(),其代码如下:
/* Simple function that displays the current thread ID. */ void DisplayCurrentThreadId() { TCHAR szMessage[256]; sprintf (szMessage, "Thread ID : 0x%X", GetCurrentThreadId()); ::MessageBox(NULL, szMessage, "TestMethod1()", MB_OK); }
上面的例子创建了单线程套间,通过线程ID来解释这一过程。从main()函数开始,详细分析如下:
1、main()函数通过调用CoInitializeEx和参数COINIT_APARTMENTTHREADED进入单线程套间。由此在main()中创建的STA对象属于main()线程的一部分。
2、main()函数调用DisplayCurrentThreadId()函数,此时会显示main()线程的ID,设为thread_id_1。
3、随后程序实例化了com类SimpleCOMObject2,该对象和main()线程属于相同的STA。
4、spISimpleCOMObject2的方法TestMethod1()显示的线程ID肯定也是thread_id_1。随后启动了线程和线程函数ThreadFunc(),主程序调用WaitForSingleObject()等待线程函数ThreadFunc()的结束。
5、线程函数ThreadFunc()再次调用CoInitializeEx和参数COINIT_APARTMENTTHREADED,同时进入单线程套间。注意这里的STA套间是新创建的,不同于main()函数的STA。
6、线程函数中调用了DisplayCurrentThreadId()函数,此时显示的是ThreadFunc()线程的ID,设为thread_id_2。
7、随后我们又创建了两个com对象(spISimpleCOMObject2A和spISimpleCOMObject2B)。
8、线程函数中调用了两个对象的方法TestMethod1()。
9、当对象spISimpleCOMObject2A和spISimpleCOMObject2B调用各自的方法时运行线程ID分别显示。
10、它们显示和函数ThreadFunc()相同的线程ID,即thread_id_2。
11、线程结束后放回主程序。
12、主程序中又一次调用方法TestMethod1(),当然显示的线程ID依然是thread_id_1。
通过示例需要记住的是:同一com类的不同实例可以属于不同独立的STA。对于一个标准的STA模型,重要的是哪个套间实例化了该STA对象。这个例子不需要提供任何的消息徐循环,因为各对象都在他们自己的套间内,并没有跨线程调用。即使我们在main()中调用了WaitForSingleObject()也不会遇到任何麻烦。
默认STA
假如STA对象在一个非STA线程中创建情形会如何?下面的代码示例了此种情况,其中com类 SimpleCOMObject2和函数DisplayCurrentThreadId()同第一个例子。
int main() { ::CoInitializeEx(NULL, COINIT_MULTITHREADED); DisplayCurrentThreadId(); if (1) { ISimpleCOMObject2Ptr spISimpleCOMObject2; /* If a default STA is to be created and used, it will be created */ /* right after spISimpleCOMObject2 (an STA object) is
created. */ spISimpleCOMObject2.CreateInstance(__uuidof(SimpleCOMObject2)); spISimpleCOMObject2 -> TestMethod1(); } ::CoUninitialize(); return 0; }
程序的详细执行过程如下:
1、main()函数调用CoInitializeEx(NULL, COINIT_MULTITHREADED),主线程初始化自身为多线程套间。
2、随即程序调用DisplayCurrentThreadId()函数,显示了main()线程的ID。
3、接着STA对象spISimpleCOMObject2在线程中实例化。
4、注意对象spISimpleCOMObject2在非STA线程中初始化,它不属于main()线程的MTA,属于默认的STA。
5、调用spISimpleCOMObject2对象的方法TestMethod1(),此时显示的线程ID不是main()线程ID。
当所有的STA对象在一个非STA线程中创建时,它们自动归属于一个默认的STA,该STA是在创建对象时创建的。这时在创建对象的线程中获得是对象的代理而非指针。注意,默认的STA必须包含消息循环,由com提供。
做为com套间世界的新人,必须注意:即使访问createInstance或者CoCreateInstance函数,生成的对象有可能是在另一线程实例化的。com的这种行为对于用户是完全透明的,请注意这些小的细节,特别是在调试时。
Legacy STA
Legacy STA属于默认的单线程套间,其对象属于legacy com对象。Legacy意味着那些组件没有任何线程的知识,这些组件必须有设置成“Single”的ThreadingModel注册表或者其它注册表。Legacy STA对象比较重要的一点是这些对象的实例必须在相同的STA中创建,它们一直存在和运行在这个legacy STA中,即使它们创建在以::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)初始化的线程中。
Legacy STA通常是进程中的第一个单线程套间,如果一个legacy STA对象创建在任何STA模型之前,一个Legacy STA由com子系统自动创建。开发legacy STA对象好处在于这些对象实例的访问将被串行化,任何两个legacy STA对象间的调用不要列集的参与。然而,非legacy STA的对象必须通过inter-apartment列集访问legacy STA对象,反之也是如此。我认为这不是一个有吸引力的优点。
另外,所有的Legacy STA对象必须仅能在相同的STA线程创建。
EXE COM服务器和套间
讨论完Dll com服务器,为了文章的完整性接着介绍EXE COM服务器。首先介绍DLL服务器和EXE服务器的两个重要差别。
差别1:对象创建的方式
当COM创建定义在DLL中的com对象时,必须加装该dll,调用导出函数DllGetClassObject()获得com对象工厂类的IClassFactory接口指针。EXE服务器实际上也遵循这个过程:获得com对象工厂类的IClassFactory接口指针并通过它创建对象。那么DLL服务器和EXE服务器差别在那里?
DLL服务器需要导出函数DllGetClassObject()以便com能够提取类工厂,而EXE服务器无需导出任何函数,但要在启动时向com子系统注册类工厂,退出时销毁。这一注册过程通过调用API函数CoRegisterClassObject()实现。
差别2:对象套间模型声明的方式
本文前面提到DLL服务器中的对象通过设置“InProcServer2”注册表中的“ThreadingModel”字符串项声明自己的套间。EXE服务器中的对象不用设置注册表,注册对象类工厂的线程的套间模型决定了对象套间模型。
除了上述两点差别,读者还应该注意的是DLL服务器中的STA对象有可能只服务于线程内的调用,而来客户端对EXE服务器对象的所有方法调用都以跨线程的方式实现,这一过程需要使用列集和stubs,以及对象所属套间线程的消息循环。