理解 COM 单线程 Apartment 第一部分






4.95/5 (204投票s)
通过代码示例学习COM单线程单元模型的基本原理。
引言
复杂的COM项目通常需要跨线程传递对象。除了需要从不同线程调用这些对象的成员函数外,有时甚至需要从多个线程触发这些对象的事件。本文分为两部分,面向刚跨越IUnknown
和IDispatch
基础知识理解的入门级COM开发人员,他们现在正考虑在多线程中使用对象。这时就需要理解COM单元(Apartments)了。
我的目标是尽可能详细地解释COM对象成员函数如何能从多个线程调用。我们将探讨COM单元(COM Apartments)的普遍概念,特别是单线程单元(Single Threaded Apartment,STA)模型,以揭示它们的创建目的以及如何实现这些目的。
COM单元本身就是一个值得深入研究的主题。在一篇文章中不可能详细涵盖与之相关的所有内容。因此,我将先专注于单线程单元,稍后在其他文章中再讨论其他单元模型。事实上,仅STA就有很多内容需要讲解,因此这篇文章需要分成两部分。
第一部分将侧重于STA的理论和整体架构的理解。第二部分将通过更复杂的示例来巩固第一部分建立的基础。
我将提供几个具有说明性的测试程序,以及一个我自定义开发的C++类CComThread
,它是一个用于包含COM对象或COM对象引用的Win32线程的包装器/管理器。CComThread
还提供了有用的实用工具,有助于跨线程的COM成员函数调用。
我将展示如何从不同线程调用对象的成员函数。我还会从另一个线程调用对象的事件。在整篇文章中,我将重点讲解单线程单元COM对象和线程,并提及其他单元模型进行比较。我选择详细讲解STA是因为它是向导最常推荐的单元模型。ATL向导设置的默认模型就是STA。这种模型有助于确保对象在线程安全方面的可靠性,而无需实现复杂的线程同步基础设施。
摘要
以下是本文的主要章节以及它们内容的总体概述
COM单元
本节将对COM单元进行一般性介绍。我们将探讨它们是什么,它们的用途,以及为什么需要它们。我们还将讨论单元、线程和COM对象之间的关系,并学习线程和对象如何在单元中协同工作。
单线程单元
本节开始我们对单线程单元的深入研究,并作为后续深入章节的“热身”。我们将清晰地阐述STA的线程访问规则。我们还将看到COM如何有效地利用老旧的消息循环。然后,我们将概述STA的普遍优缺点,然后讨论STA COM对象和STA线程开发背后的实现问题。
演示STA
本节和下一节(“EXE COM服务器与单元”)充满了对几个测试程序的详细描述。这是本文的主要目的:通过清晰的示例来展示概念。在本节中,每个测试程序都旨在演示一种特定类型的STA(初学者可能会惊讶地发现,实际上存在三种类型的STA!)。读者会注意到,我们演示STA的方法非常简单。对我而言,挑战在于用这种简单的测试原理清晰地演示不同类型的STA。
EXE COM服务器与单元
本文的最后一个主要部分将探讨EXE COM服务器及其与单元的关系。列出了一些DLL服务器和EXE服务器之间重要的区别。希望通过本节,读者能够理解类工厂(Class Factories)扮演的重要角色。我故意手写了用于演示程序的源代码,以阐明一些概念。使用ATL向导会使这一过程更加麻烦。
废话不多说,让我们开始探讨COM单元的普遍原理。
COM单元
要理解COM如何处理线程,我们需要理解单元(apartment)的概念。单元是应用程序中COM对象的逻辑容器,它们共享相同的线程访问规则(即,管理对象在单元内外部线程如何调用其成员函数和属性的规则)。
它本质上是概念性的,不呈现为具有属性或成员函数的对象。没有可用于引用它的句柄类型,也没有可以调用来管理它的API。
这也许是新手难以理解COM单元的最重要原因之一。它的性质如此抽象。
如果有一个名为CoCreateApartment()
的API(带有一个指示单元类型的参数),以及一些其他支持API,如CoEnterApartment()
,那么单元的理解和学习可能会容易得多。如果有一个Microsoft提供的coclass,并带有一个像IApartment
这样的接口,其中包含管理单元内线程和对象的成员函数,那会更好。从编程角度来看,似乎没有实质性的方法来观察单元。
为了帮助新手应对初期的学习曲线,我给出了以下关于如何理解单元的建议:
- 它们是隐含创建的。没有直接的函数调用来创建它们或检测它们的出现。
- 线程和对象隐含地进入单元并进行单元相关的活动。也没有直接的函数调用来做到这一点。
- 单元模型更像是协议,或者是一套需要遵循的规则。
COM单元旨在实现什么?
在一个多线程可以合法访问各种COM对象的操作系统环境中,我们如何确保从一个线程调用对象成员函数或属性所期望的结果不会被从另一个线程调用同一对象的成员函数或属性的调用无意中撤销?
COM单元的创建就是为了解决这个问题。COM单元的存在是为了确保所谓的线程安全。这意味着保护对象的内部状态免受来自不同线程的、同样不受控制的对象公共属性和成员函数的访问而造成的无控制的修改。
COM世界中有三种单元模型:单线程单元(Single-Threaded Apartment,STA)、多线程单元(Multi-Threaded Apartment,MTA)和中立单元(Neutral Apartment)。每个单元代表一种机制,对象内部状态可以通过该机制在多个线程之间进行同步。
单元规定了以下对参与线程和对象的通用指南:
- 每个COM对象都被分配到一个且仅一个单元中。这在运行时创建对象时决定。在初始设置完成后,对象在其整个生命周期内都保留在该单元中。
- COM线程(即,创建COM对象或进行COM成员函数调用的线程)也属于一个单元。与COM对象一样,线程所在的单元也在初始化时决定。每个COM线程也保留在其指定的单元中,直到其终止。
- 属于同一单元的线程和对象被认为遵循相同的线程访问规则。在同一单元内进行的成员函数调用直接执行,无需COM的任何协助。
- 来自不同单元的线程和对象被认为遵循不同的线程访问规则。跨单元的成员函数调用通过封送(marshalling)实现。这需要使用代理(proxies)和存根(stubs)。
除了确保线程安全之外,单元为对象和客户端提供的另一个重要好处是,对象或其客户端无需知道或关心其对应方使用的单元模型。单元的低级细节(特别是其封送机制)完全由COM子系统管理,开发人员无需关心。
指定COM对象的单元模型
从现在开始,直到下面的“EXE COM服务器与单元”部分,我们将讨论实现在DLL服务器中的COM对象。
如前所述,COM对象将精确地属于一个运行时单元,这在客户端创建对象时决定。但是,COM对象首先如何指示其单元模型呢?
好吧,对于在DLL服务器中实现的COM coclass,当COM开始实例化它时,它会引用组件的“InProcServer32”注册表条目中名为“ThreadingModel”的注册表字符串值。
此设置由COM对象开发者本身控制。例如,当您使用ATL开发COM对象时,您可以向ATL向导指定对象在运行时使用的线程模型。
下表显示了相应的字符串值以及它们各自指示的单元模型:
序号 | 注册表条目 | 单元模型 |
1 | “Apartment” | STA |
2 | “Single”或值缺失 | 遗留STA |
3 | “Free” | MTA |
4 | “Neutral” | 中立单元 |
5 | “Both” | 创建线程的单元模型。 |
我们将在本文稍后讨论遗留STA。“Both”字符串值表示COM对象可以在STA和MTA中同样良好地运行。也就是说,它可以存在于*任一*模型中。在MTA完全阐述后,我们将在后续文章中回顾这个注册表条目。
指定COM线程的单元模型
现在,关于线程。每个COM线程都必须通过调用APICoInitializeEx()
并传递COINIT_APARTMENTTHREADED
或COINIT_MULTITHREADED
作为第二个参数来初始化自身。
调用了CoInitializeEx()
的线程是COM线程,并被认为进入了一个单元。这会一直持续到线程调用CoUninitialize()
或简单地终止。
单线程单元
单线程单元可以用以下图示说明:
STA只能包含一个线程(因此称为单线程)。但是,STA可以包含任意数量的对象。STA中包含的线程的特殊之处在于,如果对象要导出到其他线程,它必须有一个消息循环。我们将在稍后的小节中回到消息循环的主题,并探讨STA如何使用它们。
线程通过在调用CoInitializeEx()
时指定COINIT_APARTMENTTHREADED
来进入STA,或者简单地调用CoInitialize()
(调用CoInitialize()
实际上会调用CoInitializeEx()
并带有COINIT_APARTMENTTHREADED
)。进入STA的线程也被认为创建了该单元(毕竟,该单元中没有其他线程首先创建它)。
COM对象进入STA,是通过在注册表中指定适当字符串值的“Apartment”,以及在STA线程中实例化来实现的。
在上图中,我们有两个单元。每个单元包含两个对象和一个线程。我们可以推测,每个线程在其生命早期都调用了CoInitialize(NULL)
或CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)
。
我们还可以看出,Obj1
、Obj2
、Obj3
和Obj4
在注册表中都被标记为“Apartment”线程模型,并且Obj1
和Obj2
是在Thread1
中创建的,而Obj3
和Obj4
是在`Thread2`中创建的。
STA线程访问规则
以下是STA的线程访问规则:
- 在STA线程中创建的STA对象将驻留在与其线程相同的STA中。
- STA中的所有对象都只接收来自STA线程的成员函数调用。
第1点是自然的,易于理解。但是,请注意在不同的STA线程中创建的来自同一DLL服务器的同一coclass的两个对象将不在同一个单元中。下图说明了这一点:
因此,Obj1
和Obj2
之间的任何成员函数调用都被视为跨单元调用,必须通过COM封送进行。
关于第2点,STA对象的成员函数只有两种方式被调用:
- 从其自己的STA线程调用。在这种情况下,成员函数调用自然是串行化的。
- 从另一个线程(无论是什么单元)调用。在这种情况下,COM通过规定该STA线程必须包含一个消息循环来确保对象只从其自己的STA线程接收成员函数调用。
我们之前已经提到过消息循环这一点,在我们继续讨论STA内部结构之前,我们必须覆盖消息循环的主题,看看它们是如何与STA紧密联系的。这将在接下来讨论。
消息循环
包含消息循环的线程也称为用户界面线程。用户界面线程与在该线程中创建的一个或多个窗口相关联。通常说该线程拥有这些窗口。窗口的消息处理函数只由拥有该窗口的线程调用。当线程内的DispatchMessage()
API被调用时,就会发生这种情况。
任何线程都可以向任何窗口发送或发布消息,但目标窗口的消息处理函数只会由拥有线程执行。最终结果是,所有发送到目标窗口的消息都同步了。也就是说,保证窗口按发送/发布消息的顺序接收和处理消息。
对Windows应用程序开发者的好处是,消息处理函数不需要是线程安全的。每个窗口消息都成为一个原子操作请求,在处理下一条消息之前会被完整处理。
这为COM提供了一个现成的、内置的Windows设施,可用于实现COM对象的线程安全。简而言之,来自外部单元的对STA对象的成员函数调用都是通过COM向与该对象关联的隐藏窗口发送私有消息来实现的。该隐藏窗口的消息处理函数然后安排调用该对象,并安排返回值回给调用该成员函数的调用者。
请注意,当涉及外部单元时,COM始终会安排代理和存根的参与,因此消息循环仅构成STA协议的*一部分*。
有两点需要注意:
- 上述使用消息循环调用STA COM对象成员函数系统的仅适用于从外部单元(无论采用何种模型)进行的调用。请记住,从STA内部进行的调用无需COM干预。这些调用由STA线程本身的执行顺序自然串行化。
- 如果STA线程未能获取和分派其消息队列中的消息,该线程单元中的COM对象将不会收到传入的跨单元调用。
关于第2点,需要注意的是,像Sleep()
、WaitForSingleObject()
、WaitForMultipleObjects()
这样的API会中断线程消息处理的流程。因此,如果STA线程需要等待某个同步对象,则需要安排特殊的处理以确保消息循环不被中断。稍后在我们研究示例代码时,我们将研究如何做到这一点。
请注意,在某些情况下,STA线程不需要包含消息循环。我们将在稍后“实现STA线程”一节中解释这一点。
现在应该清楚STA是如何实现其线程访问规则的了。
使用STA的好处
使用STA的主要优点是简单。除了COM对象服务器的一些基本代码开销外,参与的COM对象和线程需要的同步代码相对较少。所有成员函数调用都会自动串行化。这对于基于用户界面的COM对象(也称为COM ActiveX控件)尤其有用。
由于STA对象始终从同一线程访问,因此它被认为具有线程亲和性。通过线程亲和性,STA对象开发者可以使用线程本地存储来跟踪对象的内部数据。Visual Basic和MFC使用此技术开发COM对象,因此它们是STA对象。
除了出于优势之外,当需要支持遗留COM组件时,有时也必须使用STA。在Microsoft Windows NT 3.51和Microsoft Windows 95时代开发的COM组件只能使用单线程单元。多线程单元从Windows NT 4.0开始,在Windows 95中通过DCOM扩展可用。
使用STA的缺点
生活中万事万物都有两面性,使用STA也有其缺点。STA架构在对象被许多线程访问时可能会带来显著的性能损失。每个线程对对象的访问都是串行化的,因此每个线程都必须排队等待轮到它使用该对象。等待时间可能导致应用程序响应缓慢或性能下降。
另一个可能导致性能下降的问题是STA包含许多对象。请记住,STA只包含一个线程,因此只有一个线程消息队列。在这种情况下,对STA内不同对象的调用都会被消息队列串行化。每当对STA对象进行成员函数调用时,STA线程可能正忙于服务另一个对象。
使用STA的缺点必须与可能的优势进行权衡。这完全取决于手头项目的架构和设计。
实现STA COM对象及其服务器
实现STA COM对象通常使开发人员无需序列化对象内部成员数据的访问。然而,STA不能保证COM服务器DLL的全局数据和全局导出函数(如DllGetClassObject()
和DllCanUnloadNow()
)的线程安全。请记住,COM服务器的对象可以在任何线程中创建,并且来自同一DLL服务器的两个STA对象可以在两个独立的STA线程中创建。
在这种情况下,服务器的全局数据和函数可能会被两个不同的线程访问,而COM没有任何序列化。线程的消息循环也无法提供帮助。毕竟,这里的风险不是对象内部状态,而是服务器的内部状态。因此,所有对服务器全局变量和函数的访问都需要正确序列化,因为多个对象可能试图从不同线程访问它们。此规则也适用于类静态变量和函数。
COM服务器的一个广为人知的全局变量是全局对象计数。该变量被同样广为人知的全局导出函数DllGetClassObject()
和DllCanUnloadNow()
访问。可以使用InterlockedIncrement()
和InterlockedDecrement()
API来保护对全局对象计数的并发访问(来自不同线程)。DllGetClassObject()
反过来会利用COM对象的类工厂,这些类工厂也必须进行线程安全检查。
因此,以下是实现STA服务器DLL的一般指南:
- 服务器DLL必须具有线程安全的标准入口点函数(例如,
DllGetClassObject()
和DllCanUnloadNow()
)。 - 服务器DLL的私有(未导出)全局函数必须是线程安全的。
- 私有全局变量(尤其是全局对象计数)必须是线程安全的。
DllGetClassObject()
函数的作用是向调用者提供一个类对象。该类对象根据CLSID返回,并由其接口之一(通常是IClassFactory
)的指针引用。DllGetClassObject()
不是由COM对象使用者直接调用的。它实际上是从CoGetClassObject()
API内部调用的。
正是通过这个类对象创建CLSID的实例(通过IClassFactory::CreateInstance()
)。我们可以将DllGetClassObject()
函数视为COM对象创建的入口。关于DllGetClassObject()
的重要一点是,它会影响全局对象计数。
DllCanUnloadNow()
函数向其调用者返回一个值,该值确定COM服务器DLL是否仍包含正在服务的客户端的活动对象。此DllCanUnloadNow()
函数使用全局对象计数来确定其返回值。如果没有更多活动对象,调用者可以安全地将COM服务器DLL从内存中卸载。
DllGetClassObject()
和DllCanUnloadNow()
函数应安排为线程安全,以便至少保持全局对象计数同步。对象创建和销毁时(即,在对象的构造函数和析构函数中)是全局对象计数增减的常见方式。以下示例代码说明了这一点:
CSomeObject::CSomeObject()
{
// Increment the global count of objects.
InterlockedIncrement(&g_lObjsInUse);
}
CSomeObject::~CSomeObject()
{
// Decrement the global count of objects.
InterlockedDecrement(&g_lObjsInUse);
}
上面的代码片段显示了在对象由C++类CSomeObject
实现的构造函数中使用InterlockedIncrement()
API来增加全局对象计数器“g_lObjsInUse
”。相反,在CSomeObject
的析构函数中,使用InterlockedDecrement()
API来减少“g_lObjsInUse
”。
对于如何确保私有全局函数和全局变量的线程安全,无法给出具体细节。这必须留给开发人员的专业知识和经验。
确保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; }
上面的代码片段看起来有点像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; }
上述示例显示了控制台应用程序的主线程,其中通过调用CoInitialize()
建立了STA。请注意,此线程中没有定义消息循环。我们还继续创建一个基于ISimpleCOMObject1
接口的COM对象。请注意,我们对Initialize()
和Uninitialize()
的调用成功了。这是因为成员函数调用是在同一个STA内部进行的,不需要封送和消息循环。
但是,如果我们调用::CoInitializeEx(NULL, COINIT_MULTITHREADED)
而不是CoInitialize()
,从而使main()
线程成为MTA线程而不是STA线程,则会发生四件事:
- 对
Initialize()
和Uninitialize()
的调用将借助COM封送进行。 - COM对象
spISimpleCOMObject1
将驻留在COM子系统创建的默认STA中。 main()
线程仍然不需要任何消息循环,但是...- 在调用
Initialize()
和Uninitialize()
时会使用消息循环。
在这种情况下使用的消息循环是在默认STA中定义的消息循环。我们将在“默认STA”一节中讨论默认STA。
请注意,每当您确实需要为STA线程提供消息循环时,则必须确保该消息循环被持续服务而不中断。
演示STA
现在我们将尝试演示STA。我们的方法是观察当COM对象成员函数被调用时执行的线程ID。对于标准的STA对象,此ID必须与其STA线程匹配。
如果STA对象不驻留在其创建的线程中(即,该线程不是STA线程),则该线程的ID将不匹配执行对象成员函数的线程的ID。此基本原理贯穿本文的示例。
标准STA
现在让我们观察STA的实际运行情况。首先,我们检查标准STA。一个进程可以包含任意数量的标准STA。我们的示例使用一个简单的示例STA COM对象(coclass SimpleCOMObject2
,实现接口ISimpleCOMObject2
)。此STA对象的源代码位于本文配套ZIP文件中的“SimpleCOMObject2”文件夹中。ISimpleCOMObject2
接口只包含一个方法: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; }
我们还将使用一个示例测试程序,该程序实例化coclass SimpleCOMObject2
并调用其方法。此测试程序的源代码可以在源代码ZIP文件的“Test Programs\VCTests\DemonstrateSTA\VCTest01”文件夹中找到。
该测试程序由一个main()
函数组成...
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; }
...以及一个名为DisplayCurrentThreadId()
的实用函数,该函数显示一个消息框,其中包含当前运行线程的ID。
/* 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); }
上面的示例显示了两个STA的创建。我们通过线程ID来证明这一点。让我们仔细看看这个程序,从main()
函数开始:
main()
函数调用CoInitializeEx()
API,参数为COINIT_APARTMENTTHREADED
。这使得main()
线程进入STA。从现在开始,在main()
线程中创建的任何STA对象都将成为main()
线程所领导的STA的一部分。- 我们调用
DisplayCurrentThreadId()
函数。显示main()
线程的ID。假设这是thread_id_1
。 - 接下来,实例化coclass
SimpleCOMObject2
(由spISimpleCOMObject2
表示)。该对象是STA对象,因此它将与main()
线程位于同一个STA中。 - 调用
spISimpleCOMObject2
上的TestMethod1()
方法。TestMethod1()
将显示执行TestMethod1()
的线程的ID。您会注意到,这将是thread_id_1
。也就是说,它将与main()
线程ID相同。接下来,我们启动一个由ThreadFunc()
入口函数领导的线程。之后,我们通过调用WaitForSingleObject()
API并等待ThreadFunc()
线程的句柄来等待ThreadFunc()
结束。 - 在
ThreadFunc()
线程中,我们调用CoInitializeEx()
API,参数为COINIT_APARTMENTTHREADED
。这使得ThreadFunc()
线程进入STA。请注意,这个STA与main()
的STA不同。这是该进程的第二个STA。 - 我们调用
DisplayCurrentThreadId()
,并注意到ThreadFunc()
线程的线程ID确实不同。假设这是thread_id_2
。 - 接下来,我们创建
SimpleCOMObject2
的两个实例(spISimpleCOMObject2A
和spISimpleCOMObject2B
)。 - 然后,我们调用
spISimpleCOMObject2A
和spISimpleCOMObject2B
的TestMethod1()
方法。 - 调用
spISimpleCOMObject2A
和spISimpleCOMObject2B
的TestMethod1()
方法时,执行这些方法的线程ID将逐个显示。 - 您会注意到,此ID将与
ThreadFunc()
的线程ID相同。也就是说,它将显示为thread_id_2
。 ThreadFunc()
线程将结束,我们返回到main()
。- 我们再次调用
TestMethod1()
方法来调用spISimpleCOMObject2
,以表明spISimpleCOMObject2
没有任何变化。TestMethod1()
仍将在main()
线程上运行(即ID:thread_id_1
)。
我们在此演示了两个STA的直接创建,它们分别由main()
线程和ThreadFunc()
线程初始化。main()
的STA随后包含STA对象spISimpleCOMObject2
。ThreadFunc()
线程还将包含STA对象spISimpleCOMObject2A
和spISimpleCOMObject2B
。以下示例说明了这一点:
需要注意的重要一点是,spISimpleCOMObject2
、spISimpleCOMObject2A
和spISimpleCOMObject2B
都是相同coclass的实例,但它们可能驻留在独立的STA中。对于标准STA对象,重要的是哪个STA首先实例化它。
在此示例中还要注意,我们在main()
和ThreadFunc()
中都没有提供任何消息循环。它们不是必需的。两个STA中的对象都在各自的单元内使用,并且不跨线程使用。我们甚至在main()
中包含了一个WaitForSingleObject()
调用,它没有引起任何麻烦。没有用到这些STA对象的隐藏窗口。没有消息被发送到这些隐藏窗口,因此不需要消息循环。
在下一节中,我们将讨论所谓的默认STA。我们还将通过示例代码进行演示。这些示例还将增强我们刚才研究的示例的有效性。
默认STA
当STA对象在非STA线程中实例化时会发生什么?让我们看第二组示例代码,这些代码将在下面给出。这组新的源代码列在“Test Programs\VCTests\DemonstrateDefaultSTA\VCTest01”中。它还使用了与上一个示例相同的coclass SimpleCOMObject2
(实现接口ISimpleCOMObject2
)的示例STA COM对象。当前示例还使用了实用函数DisplayCurrentThreadId()
,该函数在调用时显示当前运行线程的ID。
让我们检查一下代码:
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; }
让我们仔细看看程序:
main()
函数调用CoInitializeEx(NULL, COINIT_MULTITHREADED)
。这样,main()
线程将自身初始化为属于MTA。- 接下来,我们调用
DisplayCurrentThreadId()
。将显示main()
线程的ID。 - 然后,在当前线程中实例化一个STA对象
spISimpleCOMObject2
。 - 请注意,
spISimpleCOMObject2
是一个STA对象,它在非STA线程中实例化。spISimpleCOMObject2
不会驻留在MTA中,而是会在一个默认STA中创建。 - 我们调用
spISimpleCOMObject2
上的TestMethod1()
。您会注意到,执行TestMethod1()
的线程ID不与main()
线程相同。
发生的情况是,spISimpleCOMObject2
将驻留在默认STA中。在进程中,所有在非STA线程中创建的STA对象都将驻留在默认STA中。
此默认STA在受影响的对象(在本例中为spISimpleCOMObject2
)创建时被创建。下图说明了这一点:
如上图所示,由于spISimpleCOMObject2
驻留在默认STA而不是main()
的MTA中,因此main()
对spISimpleCOMObject2 -> TestMethod1()
的调用是一个跨单元成员函数调用。这需要封送,因此main()
从COM收到的不是spISimpleCOMObject2
的实际指针,而是它的一个代理。
由于跨单元调用实际上会执行,因此默认STA必须包含消息循环。这由COM提供。
COM单元新手请注意这一有趣的现象:即使CreateInstance()
或CoCreateInstance()
的调用发生在某个线程中,但生成的对象实际上可能在另一个线程中实例化。这由COM在后台透明地完成。因此,请注意COM的这种微妙操作,尤其是在调试过程中。
现在让我们看一个更复杂的例子。这次,我们使用“Test Programs\VCTests\DemonstrateDefaultSTA\VCTest02”中列出的源代码。这套新源代码还使用了与上一个示例相同的coclass SimpleCOMObject2
(实现接口ISimpleCOMObject2
)的示例STA COM对象。当前示例还使用了实用函数DisplayCurrentThreadId()
,该函数在调用时显示当前运行线程的ID。
让我们检查一下代码:
int main() { HANDLE hThread = NULL; DWORD dwThreadId = 0; ::CoInitializeEx(NULL, COINIT_MULTITHREADED); 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;
DWORD WINAPI ThreadFunc(LPVOID lpvParameter) { ::CoInitializeEx(NULL, COINIT_MULTITHREADED); DisplayCurrentThreadId(); if (1) { ISimpleCOMObject2Ptr spISimpleCOMObject2A; ISimpleCOMObject2Ptr spISimpleCOMObject2B; spISimpleCOMObject2A.CreateInstance(__uuidof(SimpleCOMObject2)); spISimpleCOMObject2B.CreateInstance(__uuidof(SimpleCOMObject2)); spISimpleCOMObject2A -> TestMethod1(); spISimpleCOMObject2B -> TestMethod1(); } ::CoUninitialize(); return 0; }
让我们仔细看看程序:
main()
函数调用CoInitializeEx(NULL, COINIT_MULTITHREADED)
,从而使main()
线程进入MTA。- 我们调用
DisplayCurrentThreadId()
并记录main()
线程的ID。假设这是thread_id_1
。 - 然后,我们实例化coclass
SimpleCOMObject2
,它实现接口ISimpleCOMObject2
。这个对象是spISimpleCOMObject2
。 - 我们调用这个STA对象的
TestMethod1()
。执行TestMethod1()
的线程ID将显示出来。您会注意到,此ID不是thread_id_1
。也就是说,它将与main()
线程ID不同。假设此ID是thread_id_2
。 - 然后,我们启动第二个线程,执行入口函数
ThreadFunc()
,该线程将自身初始化为属于MTA。 - 当我们调用
DisplayCurrentThreadId()
时,将显示此第二个线程的ID。假设这是thread_id_3
。 - 在第二个线程中,实例化了coclass
SimpleCOMObject2
(实现ISimpleCOMObject2
)的两个STA对象。 - 我们调用第二个线程中两个STA对象的
TestMethod1()
。您将看到,执行TestMethod1()
的线程ID不是thread_id_3
。也就是说,它将不与ThreadFunc()
线程的ID相同。 - 实际上,执行
TestMethod1()
的线程ID是thread_id_2
!也就是说,它与main()
的spISimpleCOMObject2
在同一个线程上运行。
我们在这里展示的是默认STA创建和使用的一个更复杂的例子。spISimpleCOMObject2
是一个STA对象,它在非STA线程(main()
线程)中实例化。spISimpleCOMObject2A
和spISimpleCOMObject2B
也在非STA线程(ThreadFunc()
线程)中实例化。因此,所有三个对象spISimpleCOMObject2
、spISimpleCOMObject2A
和spISimpleCOMObject2B
都将驻留在默认STA中,该STA在spISimpleCOMObject2
创建时首次创建。
我强烈鼓励读者修改源代码并查看不同的结果。将一个或多个::CoInitializeEx()
调用从使用COINIT_APARTMENTTHREADED
改为COINIT_MULTITHREADED
,反之亦然。在“CSimpleCOMObject2::TestMethod1()
”中设置断点,以查看从STA线程调用和从MTA线程调用时的区别。
在后一种情况下,您会看到调用是间接的,并且涉及一些RPC调用(见下图)。
这些调用是跨单元调用期间启动的封送代码的一部分。
遗留STA
还有另一种类型的默认STA,称为遗留STA。遗留COM对象将驻留在此STA中。所谓的遗留是指那些完全不知道线程的COM组件。这些对象的ThreadingModel注册表条目必须设置为“Single”,或者根本没有在注册表中留下ThreadingModel条目。
关于这些遗留STA对象要记住的重要一点是,这些对象的所有实例都将在同一STA中创建。即使它们是在初始化为::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)
的线程中创建的,如果遗留STA已创建,它们仍将在其中运行。
遗留STA通常是进程中创建的第一个STA。如果在任何STA创建之前创建了遗留STA对象,则COM子系统将创建一个。
开发遗留STA对象的优势在于,所有对该对象所有实例的访问都会被序列化。您不需要在任何两个遗留STA对象之间进行任何跨单元封送。然而,驻留在非遗留STA中的非遗留STA对象如果想要调用遗留STA对象,则仍然必须安排跨单元封送。反之亦然(遗留STA对象调用驻留在非遗留STA中的非遗留STA对象)也需要跨单元封送。我认为这不是一个很有吸引力的优势。
让我们展示两个例子。我们将要涵盖的第一个例子使用了一个遗留STA COM对象,coclass为LegacyCOMObject1
。该COM对象的源代码列在“LegacyCOMObject1”中。此COM对象的功能与我们之前示例中coclass为SimpleCOMObject2
的COM对象类似。LegacyCOMObject1
还有一个名为TestMethod1()
的方法,该方法也显示了TestMethod1()
函数执行所在线程的ID。
使用LegacyCOMObject1
的测试程序源代码列在“Test Programs\VCTests\DemonstrateLegacySTA\VCTest01”中。此测试程序还使用了相同的实用函数DisplayCurrentThreadId()
,该函数在调用时显示当前运行线程的ID。
让我们看看测试程序的代码:
int main(){ ::CoInitializeEx(NULL,COINIT_APARTMENTTHREADED); /*::CoInitializeEx(NULL, COINIT_MULTITHREADED); */ DisplayCurrentThreadId(); if (1) { ILegacyCOMObject1Ptr spILegacyCOMObject1; spILegacyCOMObject1.CreateInstance(__uuidof(LegacyCOMObject1)); spILegacyCOMObject1 -> TestMethod1(); } ::CoUninitialize(); return 0; }
在此,我添加了一个对::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)
的调用,并注释掉了对::CoInitializeEx(NULL, COINIT_MULTITHREADED)
的调用。我添加了注释掉的代码,以便轻松说明当main()
线程是非STA线程时的效果。只需取消注释此代码(并注释掉它上面的代码!),然后查看不同的结果。稍后会详细介绍。
让我们仔细看看程序:
- 运行
main()
的线程进入标准STA。 - 我们显示
main()
线程的ID。假设这是thread_id_1
。 - 然后,我们创建一个遗留STA对象,coclass为
LegacyCOMObject1
。 - 我们调用遗留STA对象的
TestMethod1()
方法。 - 显示
TestMethod1()
运行所在线程的ID。 - 您会发现,此线程ID将是
thread_id_1
。
在上述示例中发生的情况很简单:spILegacyCOMObject1
(一个遗留STA对象)在进程中创建的第一个STA中实例化(即main()
的STA)。因此,main()
的STA被指定为遗留STA,并且spILegacyCOMObject1
将驻留在该遗留STA中。请注意:进程中创建的第一个STA是特殊的,因为它也是遗留STA。
如果我们像下面这样切换参数到COINIT_MULTITHREADED
:
int main() { /* ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); */ ::CoInitializeEx(NULL, COINIT_MULTITHREADED); DisplayCurrentThreadId(); if (1) { ILegacyCOMObject1Ptr spILegacyCOMObject1; spILegacyCOMObject1.CreateInstance(__uuidof(LegacyCOMObject1)); spILegacyCOMObject1 -> TestMethod1(); } ::CoUninitialize(); return 0; }
以下是结果:
- 运行
main()
的线程进入MTA。 - 我们显示
main()
线程的ID。假设这是thread_id_1
。 - 然后,我们创建一个遗留STA对象,coclass为
LegacyCOMObject1
。 - 我们调用遗留STA对象的
TestMethod1()
方法。 - 显示
TestMethod1()
运行所在线程的ID。 - 您会发现,此线程ID不是
thread_id_1
。
上述示例中的情况也很简单:spILegacyCOMObject1
(一个遗留STA对象)在MTA中实例化。它不能驻留在MTA中,因此COM会创建一个默认的遗留STA。因此,spILegacyCOMObject1
将驻留在COM生成的遗留STA中。
正如上述两个示例所示,遗留STA对象的功能与标准STA对象非常相似。但是,有一个区别:所有遗留STA对象只能在同一STA线程中创建。我们将通过另一个示例代码来演示这一点。
下一个示例代码也使用了与上一个示例相同的LegacyCOMObject1
对象。此测试程序还使用了相同的实用函数DisplayCurrentThreadId()
,该函数在调用时显示当前运行线程的ID。示例代码列在“Test Programs\VCTests\DemonstrateLegacySTA\VCTest02”中。
在这里,一个名为ThreadMsgWaitForSingleObject()
的新实用函数首次亮相。它是一个很酷的功能,在许多应用程序中都很有用。我将在本文第二部分介绍这个函数,因为它本身就值得密切关注。目前,只需注意ThreadMsgWaitForSingleObject()
将允许线程等待一个句柄,同时处理任何传入的消息。它封装了消息循环和WaitForSingleObject()
的功能。正如您在示例代码中将看到的,此函数对我们非常有用。
让我们看看测试程序的代码:
int main() { HANDLE hThread = NULL; DWORD dwThreadId = 0; ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); DisplayCurrentThreadId(); if (1) { ILegacyCOMObject1Ptr spILegacyCOMObject1; spILegacyCOMObject1.CreateInstance(__uuidof(LegacyCOMObject1)); spILegacyCOMObject1 -> TestMethod1(); hThread = CreateThread ( (LPSECURITY_ATTRIBUTES)NULL, (SIZE_T)0, (LPTHREAD_START_ROUTINE)ThreadFunc, (LPVOID)NULL, (DWORD)0, (LPDWORD)&dwThreadId ); ThreadMsgWaitForSingleObject(hThread, INFINITE); spILegacyCOMObject1 -> TestMethod1(); } ::CoUninitialize(); return 0; }
DWORD WINAPI ThreadFunc(LPVOID lpvParameter) { ::CoInitializeEx(NULL, COINIT_MULTITHREADED); DisplayCurrentThreadId(); if (1) { ILegacyCOMObject1Ptr spILegacyCOMObject1A; ILegacyCOMObject1Ptr spILegacyCOMObject1B; spILegacyCOMObject1A.CreateInstance(__uuidof(LegacyCOMObject1)); spILegacyCOMObject1B.CreateInstance(__uuidof(LegacyCOMObject1)); spILegacyCOMObject1A -> TestMethod1(); spILegacyCOMObject1B -> TestMethod1(); } ::CoUninitialize(); return 0; }
让我们仔细看看程序:
- 运行
main()
的线程进入STA。 - 我们显示
main()
线程的ID。假设这个线程ID是thread_id_1
。 - 然后,我们创建
LegacyCOMObject1
(spILegacyCOMObject1
)的一个实例。 - 我们调用
spILegacyCOMObject1
的TestMethod1()
方法。 - 显示执行
TestMethod1()
的线程ID。您将注意到,这是thread_id_1
。 - 然后,我们启动一个由
ThreadFunc()
领导的线程。之后,我们通过调用ThreadMsgWaitForSingleObject()
等待该线程结束。 ThreadFunc()
线程被初始化为非STA线程。- 在
ThreadFunc()
线程中,我们实例化了LegacyCOMObject1
的两个实例(spILegacyCOMObject1A
和spILegacyCOMObject1B
)。 - 我们调用
spILegacyCOMObject1A
和spILegacyCOMObject1B
的TestMethod1()
。 - 揭示了执行每个
TestMethod1()
调用的线程ID。您会注意到,这是thread_id_1
。 - 然后,
ThreadFunc()
线程将完成,我们返回到main()
线程。 - 我们调用
TestMethod1()
来调用spILegacyCOMObject1
,并注意到执行spILegacyCOMObject1
的TestMethod1()
的线程ID没有改变。它仍然是thread_id_1
。
让我们分析一下最新的测试程序。运行main()
的线程进入标准STA。这个STA是进程中创建的第一个STA。请记住,进程中创建的第一个STA也是遗留STA,因此main()
的STA是遗留STA。现在,spILegacyCOMObject1
(在main()
中)被创建为普通STA对象,它驻留在与main()
中新创建的STA相同的STA中。
当第二线程(由ThreadFunc()
领导)启动时,它作为MTA启动。因此,在此线程中创建的任何STA对象都不能驻留在该MTA中(它不能使用ThreadFunc()
的线程)。spILegacyCOMObject1A
和spILegacyCOMObject1B
都是STA对象,因此它们不能驻留在ThreadFunc()
的MTA中。现在,如果spILegacyCOMObject1A
和spILegacyCOMObject1B
是普通STA,则会为它们创建一个新的STA来驻留。但是,它们是遗留STA,因此它们必须驻留在遗留STA中(如果已经存在,并且一个已经存在)。
最终结果是它们将被容纳在main()
线程中创建的遗留STA中。这就是为什么当您从ThreadFunc()
调用TestMethod1()
时,该调用实际上被封送到main()
线程。在ThreadFunc()
的MTA单元(TestMethod1()
调用源自此处)和main()
的STA单元(TestMethod1()
调用执行于此处)之间实际上存在跨单元封送。
下图说明了这一点,其中spILegacyCOMObject1A
在ThreadFunc()
中创建:
请注意图中的第3点:“创建调用被COM封送到遗留STA”。为了使创建调用成功,COM必须与遗留STA通信,并告诉它创建spILegacyCOMObject1A
。此通信需要在目标遗留STA中存在消息循环。因此,需要ThreadMsgWaitForSingleObject()
的服务。
EXE COM服务器与单元
到目前为止,我们已经讨论了实现在DLL内部的COM服务器。但是,如果不提及实现在EXE中的COM服务器,本文将不完整。我的目标是展示在EXE服务器内部如何实现单元,特别是STA。让我们先研究DLL服务器和EXE服务器之间的两个主要区别。
区别1:对象创建方式
当COM希望创建一个在DLL内部实现(或实现在)的COM对象时,它会加载DLL,连接其导出的DllGetClassObject()
函数,调用它,并获得COM对象类工厂对象的IClassFactory
接口指针。正是通过这个IClassFactory
接口指针,COM对象才得以创建。
EXE服务器的情况也类似:获取要创建的COM对象的类工厂对象的IClassFactory
接口指针,然后通过它创建COM对象。在此之前发生的事情是DLL服务器和EXE服务器之间的区别。
DLL服务器导出了DllGetClassObject()
函数供COM提取类工厂,但EXE服务器不能导出任何函数。EXE服务器必须在启动时在COM子系统中注册其类工厂,并在关闭时注销该类工厂。此注册通过APICoRegisterClassObject()
完成。
区别2:指示对象单元模型的**方式
如本文前面所述,在DLL中实现的对象的单元模型是通过在对象“InProcServer32”注册表条目中适当设置“ThreadingModel”注册表字符串值来指示的。
在EXE服务器中实现的对对象不设置此注册表值。相反,注册对象类工厂的线程的单元模型决定了对象的单元模型。
::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); ... ... ... IUnknown* pIUnknown = NULL; DWORD dwCookie = 0; pCExeObj02_Factory -> QueryInterface(IID_IUnknown, (void**)&pIUnknown); if (pIUnknown) { hr = ::CoRegisterClassObject ( CLSID_ExeObj02, pIUnknown, CLSCTX_LOCAL_SERVER, REGCLS_MULTIPLEUSE | REGCLS_SUSPENDED, &dwCookie ); pIUnknown -> Release(); pIUnknown = NULL; }
在上段代码片段中,我们正在尝试在一个线程中为CLSID_ExeObj02
COM对象注册一个类工厂。请注意开头的::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)
调用。此调用指示COM,CLSID_ExeObj02
COM对象将驻留在STA中。调用CoRegisterClassObject()
的线程是该STA中的唯一线程。这意味着该线程中将存在一个消息循环,并且客户端创建的任何CLSID_ExeObj02
对象的访问都将被此消息循环序列化。
如果CoInitializeEx()
调用使用COINIT_MULTITHREADED
代替,CLSID_ExeObj02
COM对象将驻留在MTA中。这意味着CLSID_ExeObj02
COM对象及其类工厂对象可以从任何线程访问。这些线程可以是EXE服务器内部实现的线程(作为实现逻辑的一部分),也可以是RPC线程池的线程,其目的是服务外部客户端的成员函数调用。因此,CLSID_ExeObj02
COM对象的实现必须确保内部序列化到任何所需的程度。在许多方面,这比STA效率更高。
除了上述两个区别之外,请注意,虽然DLL服务器中的STA对象只能从其所属的STA线程内部接收成员函数调用,但从客户端到EXE COM服务器中的STA对象的*所有*成员函数调用都将不可避免地从外部线程调用。这意味着需要使用封送代理和存根,当然,还需要对象所属的STA线程中存在消息循环。
演示COM EXE服务器中的STA
一如既往,我们将尝试通过示例代码来演示COM EXE服务器内部的STA。本节的示例代码相当复杂。可以在以下文件夹中找到:本文配套的示例代码中的“Test Programs\VCTests\DemonstrateExeServerSTA”。这套示例代码有三个部分:
- 接口(“Interface\ExeServerInterfaces”子文件夹)。
- 实现(“Implementation\ExeServerImpl”子文件夹)。
- 客户端(“Client\VCTest01”子文件夹)。
请注意,为了使用ExeServerImpl
COM服务器,您需要编译“Implementation\ExeServerImpl”中的代码,然后在命令提示符窗口中键入以下命令来注册生成的*ExeServerImpl.exe*:
ExeServerImpl RegServer
请勿在“RegServer”之前输入任何“-”或“\”。
接口
接口部分的代码实际上是一个ATL项目(“ExeServerInterfaces.dsw”),我用它来定义三个接口:IExeObj01
、IExeObj02
和IExeObj03
。这三个接口各只包含一个方法(同名):TestMethod1()
。这个ATL项目还指定了三个coclass,它们由CLSID_ExeObj01
(指定包含IExeObj01
接口的实现)、CLSID_ExeObj02
(指定包含IExeObj02
接口的实现)和CLSID_ExeObj03
(指定包含IExeObj03
接口的实现)标识。
在此项目中,这些接口和coclass没有有意义的实现。我创建这个项目是为了使用ATL向导来帮助我管理IDL文件,并自动生成相应的“ExeServerInterfaces.h”和“ExeServerInterfaces_i.c”文件。生成的这些文件同时被实现代码和客户端代码使用。
我使用一个单独的ATL项目来生成上述文件,因为我希望我的实现代码是非ATL的。我想要一个基于简单Windows应用程序的COM EXE实现,这样我就可以放入各种自定义结构,以便更清晰地说明STA。使用ATL向导,事情会有点不那么灵活。
实现
实现部分的代码提供了接口和coclass的实现,这些接口和coclass在接口部分进行了描述。除了CExeObj02
之外,每个TestMethod1()
的实现只包含一个消息框显示。
STDMETHODIMP CExeObj01::TestMethod1() { TCHAR szMessage[256]; sprintf (szMessage, "0x%X", GetCurrentThreadId()); ::MessageBox(NULL, szMessage, "CExeObj01::TestMethod1()", MB_OK); return S_OK; }
STDMETHODIMP CExeObj03::TestMethod1() { TCHAR szMessage[256]; sprintf (szMessage, "0x%X", GetCurrentThreadId()); ::MessageBox(NULL, szMessage, "CExeObj03::TestMethod1()", MB_OK); return S_OK; }
这样做的目的是显示调用每个方法时正在执行的线程的ID。这应该与其包含的STA线程的ID匹配。我让CExeObj02
有点特别。这个C++类提供了IExeObj02
的实现。它还包含一个指向IExeObj01
对象的指针。
class CExeObj02 : public CReferenceCountedObject, public IExeObj02 { public : CExeObj02(); ~CExeObj02(); ... ... ... protected : IExeObj01* m_pIExeObj01; };
在CExeObj02
构造期间,我们将实例化m_pIExeObj01
。
CExeObj02::CExeObj02() { ::CoCreateInstance ( CLSID_ExeObj01, NULL, CLSCTX_LOCAL_SERVER, IID_IExeObj01, (LPVOID*)&m_pIExeObj01 ); }
这样做的目的是稍后展示CExeObj02
和m_pIExeObj01
背后的对象将在不同的STA中运行。看看这个类的TestMethod1()
实现。
STDMETHODIMP CExeObj02::TestMethod1() { TCHAR szMessage[256]; sprintf (szMessage, "0x%X", GetCurrentThreadId()); ::MessageBox(NULL, szMessage, "CExeObj02::TestMethod1()", MB_OK); return m_pIExeObj01 -> TestMethod1(); }
将显示两个消息框:第一个显示CExeObj02
的线程ID,第二个显示m_pIExeObj01
的线程ID。当稍后运行客户端代码时,这些ID将不同。
除了提供接口的实现外,实现代码还为每个coclass提供了类工厂。它们是CExeObj01_Factory
、CExeObj2_Factory
和CExeObj03_Factory
。
现在让我们将注意力集中在WinMain()
函数上。
int APIENTRY WinMain ( HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow ) { MSG msg; HRESULT hr = S_OK; bool bRun = true; DisplayCurrentThreadId(); hr = ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); ... ... ... if (bRun) { DWORD dwCookie_ExeObj01 = 0; DWORD dwCookie_ExeObj02 = 0; DWORD dwCookie_ExeObj03 = 0; DWORD dwThreadId_RegisterExeObj02Factory = 0; DWORD dwThreadId_RegisterExeObj03Factory = 0; g_dwMainThreadID = GetCurrentThreadId(); RegisterClassObject<CExeObj01_Factory>(CLSID_ExeObj01,&dwCookie_ExeObj01); dwThreadId_RegisterExeObj02Factory = RegisterClassObject_ViaThread (ThreadFunc_RegisterExeObj02Factory, &dwCookie_ExeObj02); dwThreadId_RegisterExeObj03Factory = RegisterClassObject_ViaThread (ThreadFunc_RegisterExeObj03Factory, &dwCookie_ExeObj03); ::CoResumeClassObjects(); // Main message loop: while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } StopThread(dwThreadId_RegisterExeObj02Factory); StopThread(dwThreadId_RegisterExeObj03Factory); ::CoRevokeClassObject(dwCookie_ExeObj01); ::CoRevokeClassObject(dwCookie_ExeObj02); ::CoRevokeClassObject(dwCookie_ExeObj03); } ::CoUninitialize(); return msg.wParam; }
我省略了一些与EXE服务器注册和注销相关的WinMain()
代码,这些代码与我们在此处的讨论无关。我已经将代码缩小到只显示运行时类工厂注册过程。
我创建了两个辅助函数RegisterClassObject()
和RegisterClassObject_ViaThread()
来简化对CoRegisterClassObject()
的调用。
这些是简单的辅助函数,为了避免偏离主题,我不会在这篇文章中讨论它们,只提供这些函数功能的总结:
RegisterClassObject()
- 基于类名(作为模板参数提供)实例化一个类工厂,然后将该类工厂注册给COM,作为给定参数给RegisterClassObject()
函数的COM对象的类工厂。RegisterClassObject_ViaThread()
- 启动一个线程,其任务是使用RegisterClassObject()
函数注册一个类工厂。
每当EXE COM服务器启动时,它都会注册所有三个类工厂(尽管并非所有这些都由WinMain()
线程执行)。
请注意函数开头的CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)
调用。这一点很重要,它使由WinMain()
线程注册的类工厂创建的COM对象属于一个STA(即WinMain()
线程当前所在的STA)。这个类工厂是CExeObj01_Factory
,它创建的对象CLSID是CLSID_ExeObj01
。
在执行完类工厂注册后,WinMain()
进入一个消息循环。此消息循环服务于客户端创建的所有CLSID_ExeObj01
COM对象的成员函数调用。
现在让我们观察其他线程的运行情况。
DWORD WINAPI ThreadFunc_RegisterExeObj02Factory(LPVOID lpvParameter) { MSG msg; PStructRegisterViaThread pStructRegisterViaThread = (PStructRegisterViaThread)lpvParameter; ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); DisplayCurrentThreadId(); pStructRegisterViaThread -> dwThreadId = GetCurrentThreadId(); RegisterClassObject<CExeObj02_Factory> (CLSID_ExeObj02, &(pStructRegisterViaThread -> dwCookie)); SetEvent(pStructRegisterViaThread -> hEventRegistered); // Main message loop: while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } ::CoUninitialize(); return 0; }
DWORD WINAPI ThreadFunc_RegisterExeObj03Factory(LPVOID lpvParameter) { MSG msg; PStructRegisterViaThread pStructRegisterViaThread = (PStructRegisterViaThread)lpvParameter; ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); DisplayCurrentThreadId(); pStructRegisterViaThread -> dwThreadId = GetCurrentThreadId(); RegisterClassObject<CExeObj03_Factory> (CLSID_ExeObj03, &(pStructRegisterViaThread -> dwCookie)); SetEvent(pStructRegisterViaThread -> hEventRegistered); // Main message loop: while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } ::CoUninitialize(); return 0; }
每个线程都执行相同的操作。
- 每个都通过调用
CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)
将自身初始化为 STA 线程。 - 每个都显示其正在运行的线程的 ID (
DisplayCurrentThreadId()
)。 - 每个分别注册
CExeObj02_Factory
和CExeObj03_Factory
的类工厂。 - 每个都进入消息循环。
我们最终获得的结果可以总结在下表中
序号 | 线程函数 | 类工厂 | COM coclass | 线程模型 |
1 | WinMain() |
CExeObj01_Factory |
CLSID_ExeObj01 |
STA |
2 | ThreadFunc_ RegisterExeObj02Factory() |
CExeObj02_Factory |
CLSID_ExeObj02 |
STA |
3 | ThreadFunc_ RegisterExeObj03Factory() |
CExeObj03_Factory |
CLSID_ExeObj03 |
STA |
客户端
现在让我们转向客户端。客户端代码很简单。它包含一个 main()
函数,该函数实例化 CLSID_ExeObj01
、CLSID_ExeObj02
和 CLSID_ExeObj03
coclass 的两个实例。每个实例都通过指向相应接口 IExeObj01
、IExeObj02
和 IExeObj03
的指针进行引用。
int main()
{
IExeObj01* pIExeObj01A = NULL;
IExeObj01* pIExeObj01B = NULL;
IExeObj02* pIExeObj02A = NULL;
IExeObj02* pIExeObj02B = NULL;
IExeObj03* pIExeObj03A = NULL;
IExeObj03* pIExeObj03B = NULL;
HRESULT hr = ::CoInitializeEx(NULL, COINIT_MULTITHREADED);
::CoCreateInstance
(
CLSID_ExeObj01,
NULL,
CLSCTX_LOCAL_SERVER,
IID_IExeObj01,
(LPVOID*)&pIExeObj01A
);
::CoCreateInstance
(
CLSID_ExeObj01,
NULL,
CLSCTX_LOCAL_SERVER,
IID_IExeObj01,
(LPVOID*)&pIExeObj01B
); ::CoCreateInstance ( CLSID_ExeObj02, NULL, CLSCTX_LOCAL_SERVER, IID_IExeObj02, (LPVOID*)&pIExeObj02A ); ::CoCreateInstance ( CLSID_ExeObj02, NULL, CLSCTX_LOCAL_SERVER, IID_IExeObj02, (LPVOID*)&pIExeObj02B ); ::CoCreateInstance ( CLSID_ExeObj03, NULL, CLSCTX_LOCAL_SERVER, IID_IExeObj03, (LPVOID*)&pIExeObj03A ); ::CoCreateInstance ( CLSID_ExeObj03, NULL, CLSCTX_LOCAL_SERVER, IID_IExeObj03, (LPVOID*)&pIExeObj03B ); ... ... ... }
请注意,我们在 main()
的开头调用了 CoInitializeEx(NULL, COINIT_MULTITHREADED)
。与使用 DLL 服务器不同,此调用不会影响我们创建的 COM 对象使用的线程模型。
然后,客户端代码继续调用每个接口指针的 TestMethod1()
方法,然后释放所有接口指针。
if (pIExeObj01A) { pIExeObj01A -> TestMethod1(); } if (pIExeObj01B) { pIExeObj01B -> TestMethod1(); } if (pIExeObj02A) { pIExeObj02A -> TestMethod1(); } if (pIExeObj02B) { pIExeObj02B -> TestMethod1(); } if (pIExeObj03A) { pIExeObj03A -> TestMethod1(); } if (pIExeObj03B) { pIExeObj03B -> TestMethod1(); }
让我们观察一下客户端应用程序运行时会发生什么。
- 当第一次调用
::CoCreateInstance()
时,COM 将启动我们的 EXE COM 服务器。 - 然后,我们的 COM 服务器将运行其
WinMain()
函数。它做的第一件事是弹出一个消息框显示WinMain()
的线程 ID。假设这是thread_id_1
。 - 接下来,
CLSID_ExeObj01
COM 对象的类工厂将在WinMain()
线程中注册。因此,CLSID_ExeObj01
COM 对象将驻留在由WinMain()
线程管理的 STA 中。 - 我们的 COM 服务器将启动由
ThreadFunc_RegisterExeObj02Factory()
线程管理,该线程将注册CLSID_ExeObj02
COM 对象的类工厂。此线程的 ID 在线程开始时由消息框显示。假设这是thread_id_2
。 CLSID_ExeObj02
COM 对象将驻留在由 ID 为thread_id_2
的线程管理的 STA 中。- 我们的 COM 服务器将启动由
ThreadFunc_RegisterExeObj03Factory()
线程管理,该线程将注册CLSID_ExeObj03
COM 对象的类工厂。此线程的 ID 在线程开始时由消息框显示。假设这是thread_id_3
。 CLSID_ExeObj03
COM 对象将驻留在由 ID 为thread_id_3
的线程管理的 STA 中。- 回到客户端代码。当在
pIExeObj01A
上调用TestMethod1()
时,将显示正在执行的线程的 ID。您会注意到这是thread_id_1
,这与上面的第 3 点一致。 - 在
pIExeObj01B
上调用TestMethod1()
时也将显示相同的 ID。 - 当在
pIExeObj02A
上调用TestMethod1()
时,将依次显示两个线程 ID。第一个是在调用pIExeObj02A -> TestMethod1()
时正在执行的线程的 ID,即thread_id_2
,这与上面的第 5 点一致。 - 当显示第二个消息框时,我们将看到正在运行的线程的 ID,当调用
pIExeObj02A
中包含的CLSID_ExeObj01
COM 对象时。这个 ID **不是**thread_id_2
,而是thread_id_1
!这与上面的第 3 点完全一致。 - 在
pIExeObj02B
上调用TestMethod1()
时将显示相同的 ID 对。 - 当调用下面的语句中的
pIExeObj03A -> TestMethod1()
和pIExeObj03B -> TestMethod1()
时,我们将看到执行它们的线程 ID 是thread_id_3
。这再次与上面的第 7 点一致。
如果您在 CExeObj01::TestMethod1()
和 CExeObj02::TestMethod1()
中设置断点,您会从调用堆栈中观察到它们之间的调用实际上已被封送。
我们 thus 演示了在 COM EXE 服务器内部使用的 STA。我强烈建议读者尝试使用代码,并观察将一个或多个线程从 STA 更改为 MTA 所产生的效果。这是一个学习的好方法。
在结束这个主要部分的最后,请允许我介绍实现代码的两个简短变体。第一个展示了在类注册线程中未提供适当消息循环所产生的显著影响。第二个展示了未提供消息循环的完全无害的影响!
变体 1
让我们来分析第一种情况。在 EXE COM 服务器代码的 main.cpp 文件中,我们按如下方式修改 ThreadFunc_RegisterExeObj02Factory()
函数。
DWORD WINAPI ThreadFunc_RegisterExeObj02Factory(LPVOID lpvParameter) { MSG msg; PStructRegisterViaThread pStructRegisterViaThread = (PStructRegisterViaThread)lpvParameter; ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); DisplayCurrentThreadId(); pStructRegisterViaThread -> dwThreadId = GetCurrentThreadId(); RegisterClassObject<CExeObj02_Factory> (CLSID_ExeObj02, &(pStructRegisterViaThread -> dwCookie)); SetEvent(pStructRegisterViaThread -> hEventRegistered); Sleep(20000); /* Add Sleep() statement here. */ /* Main message loop: */ while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } ::CoUninitialize(); return 0; }
我们只需在消息循环上方添加一个 Sleep()
语句。再次编译 EXE COM 服务器。在调试模式下运行客户端(以便您观察实例化 coclass CLSID_ExeObj02
时会发生什么,如下调用 CoCreateInstance()
)。
::CoCreateInstance ( CLSID_ExeObj02, NULL, CLSCTX_LOCAL_SERVER, IID_IExeObj02, (LPVOID*)&pIExeObj02A );
您会注意到此调用似乎挂起。但请稍等,如果您耐心等待大约 20 秒,该调用就会成功。发生了什么?原来,因为 CLSID_ExeObj02
是一个 STA 对象,对 CoCreateInstance()
的调用导致需要与注册 CLSID_ExeObj02
类工厂的线程的消息循环进行通信。
通过 Sleep()
语句阻塞线程,该线程的消息循环不会得到服务。在这种情况下,创建实例的调用将不会返回。但是,一旦 Sleep()
语句返回,消息循环就会启动,创建实例的调用就会得到服务并最终返回。
因此,请注意 COM EXE 服务器 STA 线程中消息循环的重要性。
变体 2
这次,让我们按如下方式修改 ThreadFunc_RegisterExeObj02Factory()
函数。
DWORD WINAPI ThreadFunc_RegisterExeObj02Factory(LPVOID lpvParameter) { MSG msg; PStructRegisterViaThread pStructRegisterViaThread = (PStructRegisterViaThread)lpvParameter; ::CoInitializeEx(NULL, COINIT_MULTITHREADED);/*1.Make this an MTA thread.*/ DisplayCurrentThreadId(); pStructRegisterViaThread -> dwThreadId = GetCurrentThreadId(); RegisterClassObject<CExeObj02_Factory> (CLSID_ExeObj02, &(pStructRegisterViaThread -> dwCookie)); SetEvent(pStructRegisterViaThread -> hEventRegistered); Sleep(INFINITE); /* 2. Set to Sleep() infinitely. */ /* 3. Comment out Main message loop. */ /* while (GetMessage(&msg, NULL, 0, 0)) */ /* { */ /* TranslateMessage(&msg); */ /* DispatchMessage(&msg); */ /* } */ ::CoUninitialize(); return 0; }
这次,我们将线程更改为 MTA 线程,将 Sleep()
的参数设置为 INFINITE
,并完全注释掉消息循环。
您会发现,在客户端上对 coclass CLSID_ExeObj02
调用 ::CoCreateInstance()
会成功,尽管 CLSID_ExeObj02
现在是 MTA 对象,对其 TestMethod1()
方法的调用可能会显示不同的线程 ID。
我们在这里清楚地表明,只要注册类工厂的 MTA 线程保持活动状态(通过 Sleep(INFINITE)
),对类工厂的调用就会成功(顺便说一句,无需任何消息循环)。
请注意,无论 ThreadFunc_RegisterExeObj02Factory()
是 STA 线程还是 MTA 线程,如果它在注册其类工厂后退出,在客户端实例化 coclass CLSID_ExeObj02
时将会出现不可预测的结果。
结论
我当然希望您已从本长文的解释性文本和示例代码中受益。我已经尽了我最大的努力做到详尽和全面,为单线程单元 (STA) 的概念奠定坚实的基础。
在本部分一中,我演示了一些 COM 已铺平道路的跨 apartment 方法调用。我们还看到了 COM 如何自动安排对象在适当的 apartment 线程中创建。代理和存根是内部生成的,代理的封送是透明执行的,开发人员无需了解。
在第二部分,我们将涉及更多与 STA 相关的 COM 高级功能。我们将展示如何执行 COM 对象指针从一个 apartment 到另一个 apartment 的显式封送。我们还将展示对象如何从外部线程触发事件。我们将探讨低级代码。
致谢和参考文献
- The Essence of COM, A Programmer's Workbook (第 3 版),作者:David S. Platt。出版商:Prentice Hall PTR。
- Inside COM,作者:Dale Rogerson。出版商:Microsoft Press。
- Essential COM,作者:Don Box。出版商:Addison-Wesley。
"Test Programs\VCTests\DemonstrateExeServerSTA\Implementation\ExeServerImpl" 子文件夹中的示例代码使用了两个源文件 REGISTRY.H 和 REGISTRY.CPP,这些文件取自 Dale Rogerson 的著作 "Inside COM"。