COM高手总结的八个经验和教训之二
STA 线程需要消息循环
上一部分中描述的应用程序还有另一个致命缺陷。看看您是否能指出来。
这个特殊的应用程序恰好是用 MFC 编写的。在一开始,它使用了 MFC 的 AfxBeginThread 函数启动一系列辅助线程。每个辅助线程要么调用 CoInitialize 要么调用 AfxOleInit(MFC 中类似 CoInitialize 的函数)来初始化 COM。某些辅助线程则调用 CoCreateInstance 来创建 COM 对象,并将所返回的接口指针封送到其他辅助线程。从创建这些对象的线程中调用对象将非常顺利,但从其他线程的调用却从不返回。您知道这是为什么吗?
如果您认为问题与消息循环(或缺少消息循环)相关,那么答案完全正确。事实确实如此。当一个线程调用 CoInitialize 或 AfxOleInit 时,它是放在单线程单元 (STA) 中。当 COM 创建一个 STA 时,它会创建一个随附的隐藏窗口。以 STA 中的对象为目标的方法调用将转换为消息,并放入与该 STA 关联的窗口的消息队列中。当运行在该 STA 中的线程检索到代表方法调用的消息时,隐藏窗口的窗口过程就会将消息转换回方法调用。COM 使用 STA 执行调用序列化。STA 中的对象一次不可能接收一个以上的调用,因为每个调用都要传递给一个而且是惟一一个运行在对象单元中的线程。
如果基于 STA 的线程无法处理消息会怎么样呢?如果它没有消息循环又会怎么样呢?针对该 STA 中对象的单元间方法调用将不再返回;它们将在消息队列中被永远搁置。MFC 辅助线程中没有消息循环,因此如果寄宿在这些 STA 中的对象要从其他单元的客户端接收方法调用,那么 MFC 辅助线程和 STA 是配合不好的。
这个故事的寓意何在呢?STA 线程需要消息循环,除非您肯定它们不会包含要从其他线程调用的对象。消息循环可以像这样简单:
MSG msg; while (GetMessage (&msg, 0, 0, 0)) DispatchMessage (&msg); |
另一种方案是将 COM 线程移到 MTA 中(或者在 Windows 2000 中,移到中立线程单元,即 NTA 中),这里没有消息队列依赖项。
单元模型对象必须保护共享数据
另一个困扰 COM 开发人员的通病是标记为 ThreadingModel=Apartment 的进程内对象。这项指定告诉 COM,对象的实例必须只能在 STA 中创建。它还可让 COM 自由地将这些对象实例放在任何主机进程的 STA 中。
假设客户端应用程序有五个 STA 线程,每个线程都使用 CoCreateInstance 来创建同一个对象的一个实例。如果线程是基于 STA 的,且对象标记为 ThreadingModel=Apartment,则这五个对象实例将在对象创建者的 STA 中创建。因为每个对象实例都在占用其 STA 的线程上运行,因此所有五个对象实例都可以并行运行。
到目前为止,一切良好。现在考虑一下,如果这些对象实例共享数据会发生什么情况。因为对象都在并发线程上执行,两个或更多的对象可能会同时尝试访问同一 个数据。除非所有这些访问都是读取访问,否则就会酿成灾难。问题可能不会很快显现出来;它们会以和时间紧密相关的错误形式出现,因此很难诊断和重现。这就 解释了以下事实的原因:ThreadingModel=Apartment 对象应该包括可同步对共享数据的访问的代码,除非您能够确定对象的客户端不会对执行访问的方法进行重叠调用。
问题在于,太多的 COM 开发人员相信 ThreadingModel=Apartment 能够使他们免于编写线程安全的代码。事实并非如此 ? 至少不完全如此。ThreadingModel=Apartment 并不意味着对象必须是完全线程安全的,它代表的是一个对 COM 的承诺,即访问两个或更多对象实例共享的数据(或此对象和其他对象的实例共享的数据)时是以线程安全的方式进行的。而提供该线程安全性的任务应该由您,即 对象实现者来负责。共享数据的类型和大小多种多样,但大多是以全局变量、C++ 类中的静态成员变量和函数中声明的静态变量的形式出现。即使是以下这样无害的语句也会在 STA 中出问题:
static int nCallCount = 0; nCallCount++; |
因为这个对象的所有实例都将共享一个 nCallCount 实例,编写这些语句的正确方式如下:
static int nCallCount = 0; InterlockIncrement (&nCallCount); |
注意:您可以使用临界区、互锁函数或您希望的任何方式,但不要忘了访问基于 STA 的对象共享的数据时要进行同步化!
谨慎启动用户
这里还有一个问题让许多 COM 开发人员都吃过苦头。去年春天,有一家公司向我紧急呼救,他们的开发人员使用 COM 构建了一个分布式应用程序,其中客户端进程运行在与远程服务器的 Singleton 对象相连接的网络工作站上。在测试过程中,他们遇到了一些非常奇怪的行为。在一种测试场景中,客户端对 CoCreateInstanceEx 的调用可使它们与 Singleton 对象正常连接。而在另一个场景中,对 CoCreateInstanceEx 的相同调用产生了多个对象实例和多个服务器进程,使客户端无法与同一个对象 实例连接,从而实际影响了应用程序。在这两个场景中,硬件和软件是完全相同的。
此问题似乎与安全有关。当处理远程激活请求的 COM 服务控制管理器 (SCM) 在另一台机器上启动一个进程时,它会为该进程分配一个标识。除非另外指定,它选择的标识就是启动用户的标识。换句话说,分配给服务器进程的标识与启动它的 客户端进程的标识相同。在这种情况下,如果 Bob 登录机器 A,并使用 CoCreateInstanceEx 连接机器 B 上的 Singleton 对象,而 Alice 也在机器 C 上如法炮制,就会启动两个不同的服务器进程(至少在两台不同的 WinStation 上),实际上使客户端无法再用 Singleton 语义与共享的对象实例连接。
两个测试场景之所以会产生大相径庭的结果,其原因就是在一个场景(那个可以工作的场景)中,所有测试人员都使用只为测试而设置的一个特殊帐户以同一个人 的身份登录。而在另一个场景中,测试人员都使用他们的普通用户帐户登录。当两个或更多的客户端进程具有相同标识时,它们可以成功连接到配置为假定启动用户 标识的服务器进程。但是,如果客户端有不同的标识,SCM 会使用多个服务器进程(每个唯一客户端标识一个)分隔分配给不同对象实例的标识。
![]() 图 1 DCOMCNFG 中的用户帐户 |
找到问题以后,解决起来就很简单了:配置 COM 服务器,让其使用特定的用户帐户而不是假定启动用户的标识。完成这一任务的一种方式是在服务器机器上运行 DCOMCNFG(Microsoft 的 DCOM 配置工具),并将“launching user ”更改为“This user”(请参见图 1)。如果您喜欢通过编程方式进行更改(可能从安装程序着手),请在主机注册表的 HKEY_CLASSES_ROOT/AppID 部分的 COM 服务器项中添加 RunAs 值(请参见图 2)。
![]() 图 2 添加 RunAs 值到注册表中 |
您还需要使用 LsaStorePrivateData 将 RunAs 帐户的密码存储为 LSA 密钥,并使用 LsaAddAccountRights 确保帐户拥有“Logon as batch job”的权限。(有关具体操作的示例,请参见 Platform SDK 中的 DCOMPERM 示例。请特别注意名为 SetRunAsPassword 和 SetAccountRights 的函数。)
使用线程或异步调用来避免 DCOM 超时设定太长
总是有人问我当 DCOM 无法完成远程实例化请求或方法调用时出现的超时设定太长的问题。典型的场景如下:客户端调用 CoCreateInstanceEx 来实例化远程机器上的一个对象,但是这台机器临时离线了。在 Windows NT 4.0 上,激活请求不会立即失败,DCOM 可能会花上一分钟或更长时间来返回失败的 HRESULT。DCOM 还可能花费很长时间,使指向已不再存在或其主机已离线的远程对象的方法调用失败。如果可能,开发人员应该如何避免这些较长的超时设定呢?
要回答这个问题,几句话是讲不清楚的。DCOM 高度依赖于基础网络协议和 RPC 子系统。并没有什么神奇的设置可让您限制 DCOM 超时设定的持续时间。但是,我经常使用两种技巧来避免较长超时设定的负作用。
在 Windows 2000 中,当调用在 COM 信道中挂起时,您可以使用异步方法调用来释放调用线程。(有关异步方法调用的介绍,请参 MSDN Magazine 2000 年 4 月刊的“Windows 2000: Asynchronous Method Calls Eliminate the Wait for COM Clients and Servers Alike”。如果异步调用在合理时间内没有返回,您可以通过调用用于初始化调用的调用对象上的 ICancelMethodCalls::Cancel 来取消它。
Windows NT 4.0 不支持异步方法调用,甚至在 Windows 2000 中也不支持异步激活请求。怎么解决呢?从背景线程调用远程对象(或是实例化该对象的请求)。使主线程在事件对象上阻塞,并指定超时设定值以反映您愿意等待 的时间长度。当调用返回时,让背景线程来设置事件。假设主线程使用 WaitForSingleObject 阻塞,当 WaitForSingleObject 返回时,返回值可以告诉您,返回是因为方法调用或激活请求返回,还是因为您在 WaitForSingleObject 调用中指定的超时设定到期。您不能在 Windows NT 4.0 中取消挂起调用,但是至少主线程可以自由地执行自己的任务。
下面的代码演示了基于 Windows NT 4.0 的客户端如何才能从背景线程调用对象。
// // Placing a Method Call from a Background Thread / HANDLE g_hEvent; IStream* g_pStream; // Thread A g_hEvent = CreateEvent (NULL, FALSE, FALSE, NULL); CoMarshalInterThreadInterfaceInStream (IID_IFoo, pFoo, &g_pStream); DWORD dwThreadID; CreateThread (NULL, 0, ThreadFunc, NULL, 0, &dwThreadID); DWORD dw = WaitForSingleObject (g_hEvent, 5000); if (dw == WAIT_TIMEOUT) { // Call timed out } else { // Call completed } ... // Thread B IFoo* pFoo; CoGetInterfaceAndReleaseStream (g_pStream, IID_IFoo, (void**) &pFoo); pFoo->Bar (); // Make the call! SetEvent (g_hEvent); CloseHandle (g_hEvent); |
在此示例中,线程 A 封送了一个 IFoo 接口指针,并启动线程 B。线程 B 取消封送了该接口指针,并调用 IFoo::Bar。无论调用返回所花费的时间有多长,线程 A 都不会阻塞超过 5 秒钟,因为它在 WaitForSingleObject 的第二个参数中传递的是 5,000 (单位为微秒)。这并不是太好的办法,但是如果“无论在线路的另一端发生什么情况,线程 A 都不会挂起”这一点很重要的话,忍受这种麻烦也算值得。
共享对象并不容易
从我收到的邮件和在会议上被问到的问题判断,困扰许多 COM 程序员的一个问题是如何将两个或更多的客户端与一个对象实例连接。要回答这个问题,写出长篇大论(或是一本小册子)都很容易,但其实只要说明与现有对象的 连接既不容易也不自动化,就足够了。COM 提供了大量创建对象的方式,包括很受欢迎的 CoCreateInstance(Ex) 函数。但是 COM 缺乏一种通用的命名服务,允许使用名称或 GUID 来标识对象实例。而且它没有提供内置的方式来创建对象,然后将它标识为调用的目标以检索接口指针。
这是不是意味着将多个客户端与单一对象实例连接就不可能了呢?当然不是。实现这一点有五种方式。在这些资源链接中,您可以找到更多信息甚至是示例代码, 来指导您的操作。请注意,这些技术从一般意义上讲不能互换;通常,环境因素会决定哪种方式(如果有)适用于手边的任务: Singleton 对象 Singleton 对象就是只实例化一次的对象。可能会有 10 个客户端调用 CoCreateInstance 来“创建”Singleton 对象,但实际上,它们都是接收指向同一对象的接口指针。ATL COM 类可通过在其类的声明中添加 DECLARE_CLASSFACTORY_SINGLETON 语句,来转换为 Singleton。
文件名字对象 如果一个对象实现了 IpersistFile,并在运行中对象表 (ROT) 中使用文件名字对象(它封装了传递给对象的 IPersistFile::Load 方法的文件名称)注册了自己,那么客户端就可以使用文件名字对象连接对象的现有实例了。实际上,文件名字对象允许使用文件名称来命名对象实例,对象可在这 些文件名称中存储它们的持久性数据。它们甚至能够跨机器工作。
CoMarshalInterface 和 CoUnmarshalInterface 保存接口指针的 COM 客户端可以与其他客户端共享这些接口指针,只要它们愿意封送指针。COM 为愿意将接口指针封送给同一进程中其他线程的线程提供了优化(请参见教训 2),但是如果客户端线程属于其他进程,CoMarshalInterface 和 CoUnmarshalInterface 就是实现接口共享的关键途径了。