不能从远程创建com+对象_COM编程攻略(十二 套间Apartment)

前几编介绍了COM及ATL的一些基础设施:

Froser:COM编程攻略(十一 COM设施之智能指针CComPtr)​zhuanlan.zhihu.com

这一遍主要讲COM中一个最为复杂的部分:套间(Apartment)。

本文着重讲它的基本概念,并且最后附上一个例子。

1、什么是套间

想象你住在一个很大的单身公寓(Apartment)里面,因为疫情的原因,你不能出门。你的小区有很多这样的单身公寓(STA),也有一个集体公寓(MTA)。你有一些小伙伴,他们可以选择入住单身公寓,或者是入住集体公寓。对于每个入住单身公寓的小伙伴,由于不能出门,所以他们之间通信只能依靠社区管理员(Proxy)来传达,而住在集体公寓的小伙伴,他们可以直接通信,但是他们如果想要联系住在单身公寓的小伙伴,那么也只能依靠管理员(Proxy)来传达。

在COM模型中,小伙伴对应的就是线程(Thread),公寓对应的就是套间(Apartment)。在调用CoCreateInstance系列函数之前,我们先要指定,当前线程要放入哪个套间。常用的套间有2种:单线程套间(STA,对应着例子中的单身公寓),多线程套间(MTA,对应着例子中的集体公寓)。每个进程可以有多个STA和1个MTA,如果两个COM对象在不同套间(如一个在STA且一个在MTA,或者两个都在各自的STA),那么它们需要通过代理(Proxy,对应例子中的着社区管理员)来进行转发消息。

总的来说,套间就是一个和线程相关的上下文。拥有同一个上下文的线程中的COM对象可以相互调用,而不同上下文的COM对象,则需要通过代理来转发。

很多其它框架,例如Qt,提供了与套间类似的概念。在Qt中,如果使用QMetaObject::invokeMethod或者QObject::connect时,被调用的对象或者连接的两个对象不在同一个事情,那么只能通过QueuedConnection,在信号发射时,sender会把消息放入receiver所在线程的消息队列,保证receiver一定是在自己的线程处理这个消息。对于单线程模型STA来说,其原理也是类似的。当出现跨越套间的调用时,由于STA保证了所有的自己套间中的COM对象,一定是在自己的线程中被执行。

2、列集(Marshal)和散集(Unmarshal)

列集和散集,其实就是序列化和反序列化。刚刚说到,在跨越套间进行调用时,我们需要一个代理,代理选择如何执行,是直接调用还是把它放入消息队列。无论是何种形式,我们都需要能把这个调用,以及传递的参数等持久化地保存下来,然后发送给消息队列,或者是一台远程电脑(COM支持远程RPC调用)。将一个接口调用持久化(序列化)变成字节流的过程叫作列集,目标线程或者远程电脑收到这个字节流之后,要还原成接口调用,这个反序列化的过程叫散集(Unmarshal)。

MIDL.exe会为我们自动生成列集和散集的函数,如果在idl文件中为接口指定了[oleautomation],那么这个接口就可以用COM提供的默认方式列集和散集:

[object, uuid(5DB12E59-58BF-4594-8C08-C3FA9B215B92), oleautomation]
interface IFoo : IUnknown
{
	HRESULT Test();
};

COM提供了一组API,帮助我们把一个接口列集成二进制流,以及从二进制流中还原一个接口:

HRESULT CoMarshalInterThreadInterfaceInStream(
  REFIID    riid,
  LPUNKNOWN pUnk,
  LPSTREAM  *ppStm
);
HRESULT CoGetInterfaceAndReleaseStream(
  LPSTREAM pStm,
  REFIID   iid,
  LPVOID   *ppv
);
https://docs.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-comarshalinterthreadinterfaceinstream​docs.microsoft.com CoGetInterfaceAndReleaseStream function (combaseapi.h) - Win32 apps​docs.microsoft.com
fa26e0008b3d048da5f609ab562d9ede.png

我们需要列集一个对象,仅仅是在它需要跨越套间时。因为我们不能直接把指针传递给另外一个套间中的线程(你硬是要这么做,COM也不会报错,不过这不是COM的风格;另外,这个对象可能需要发到另外的主机上,你无法传递过去),如果你需要实现跨套间调用,正确的做法是:

  1. 在套间A中调用CoMarshalInterThreadInterfaceInStream,将套间A的接口I封装成一个二进制流IStream。
  2. 将IStream传递给套间B中的线程。如果是在同一个进程,线程可以共享这个IStream。
  3. 套间B中调用CoGetInterfaceAndReleaseStream,将接口I还原,然后调用。

3、套间的初始化与释放

任何一个新的线程,在使用COM函数之前,需要调用CoInitializeEx初始化此线程,为它创建一个套间。

HRESULT CoInitializeEx(
  LPVOID pvReserved,
  DWORD  dwCoInit
);

dwCoInit可以取以下值:

COINIT_APARTMENTTHREADED: 这个线程是一个STA。STA的COM对象只会在这个线程被调用。

COINIT_MULTITHREADED: 这个线程是个MTA,MTA上面的COM对象可能会在任意时候被任何MTA线程调用。

在使用完COM对象后,需要调用CoUninitialize来释放套间:

void CoUninitialize();

COM对象的实现和所在的套间密切相关。如果一个COM对象被设计成在MTA中运行,那么它的AddRef和Release应该是原子的,可重入的那些接口也必须要考虑到资源争夺的问题。ATL中有2种ThreadModel:

class CComMultiThreadModel
{
public:
	static ULONG WINAPI Increment(_Inout_ LPLONG p) throw()
	{
		return ::InterlockedIncrement(p);
	}
	static ULONG WINAPI Decrement(_Inout_ LPLONG p) throw()
	{
		return ::InterlockedDecrement(p);
	}
	typedef CComAutoCriticalSection AutoCriticalSection;
	typedef CComAutoDeleteCriticalSection AutoDeleteCriticalSection;
	typedef CComCriticalSection CriticalSection;
	typedef CComMultiThreadModelNoCS ThreadModelNoCS;
};

class CComSingleThreadModel
{
public:
	static ULONG WINAPI Increment(_Inout_ LPLONG p) throw()
	{
		return ++(*p);
	}
	static ULONG WINAPI Decrement(_Inout_ LPLONG p) throw()
	{
		return --(*p);
	}
	typedef CComFakeCriticalSection AutoCriticalSection;
	typedef CComFakeCriticalSection AutoDeleteCriticalSection;
	typedef CComFakeCriticalSection CriticalSection;
	typedef CComSingleThreadModel ThreadModelNoCS;
};

开发者应当根据自己COM对象的使用套间,来决定用上面哪一种ThreadModel。关于ATL的线程模型,可以看这一篇文章:

Froser:COM编程攻略(四 COM对象创建的原理及ATL实现)​zhuanlan.zhihu.com

一旦一个线程被指定为STA,说明它可能会接受来自其它套间的接口调用。因此,CoInitialize的时候,它会创建一个隐藏的窗口用来接收可能来自其它STA的事件,这个事件指明了在此线程中创建的COM对象,在另外的套间中被调用了,然后转发到了此线程处理。所以,STA线程需要有自己的一个消息循环来处理其它套间中的消息:

MSG msg;
while (GetMessage(&msg, 0, 0, 0))
{
	TranslateMessage(&msg);
	DispatchMessage(&msg);
}

如果其它套间调用了此套间的COM对象,那么DispatchMessage后,会在本线程调过去。

如果线程是MTA,那么就不必走消息循环,因为它在任意线程都可以被执行,所以它一般会在一个RPC线程执行,开发者要避免COM对象发生多线程冲突。

4、列集、散集二进制流

我们在套间A中,将接口列集为一个IStream后,这个IStream就不受套间的约束了,它可以被任意套间散集,这个套间可能存在在另外一台机器上面。散集后拿到的接口,是一个代理接口,如果代理接口所在套间和套间A不兼容,套间A是STA则发窗口消息给套间A所在的线程,如果是MTA则在线程中调用。如果代理套间和套间A不是在同一台计算机,那么这个IStream将会用UDP发送过去。

用CoMarshalInterThreadInterfaceInStream列集后的IStream,只能被CoGetInterfaceAndReleaseStream散集一次。如果我们希望不同套间共享一个COM对象,使用这个方法把IStream赋值给一个全局IStream是不可行的,因为一旦它被散集后,它就失效了。

为了解决这个问题,COM提供了一个全局接口表:

Accessing Interfaces Across Apartments - Win32 apps​docs.microsoft.com
fa26e0008b3d048da5f609ab562d9ede.png

它可以将一个COM对象的列集结果注册到一个全局位置,而且它可以被散集任意次。注册是通过IGlobalInterfaceTable::RegisterInterfaceInGlobal。在另外一个套间中,可以通过IGlobalInterfaceTable::GetInterfaceFromGlobal 来获取接口。IGlobalInterfaceTable中并没有说它就是用列集的实现的,不过我觉得它很有可能就是这么做的。

CoMarshalInterThreadInterfaceInStream这个方法其实是创建IStream和列集两个步骤的封装。它首先调用CreateStreamOnHGlobal在全局空间上创建了一个IStream,然后调用CoMarshalInterface朝IStream中写入列集后的数据:

CoMarshalInterface function (combaseapi.h) - Win32 apps​docs.microsoft.com
fa26e0008b3d048da5f609ab562d9ede.png

CoMarshalInterface的参数mshlflags,指定了这个IStream能被散集多少次,在CoMarshalInterThreadInterfaceInStream中,它使用的是MSHLFLAGS_NORMAL,也就是被散集一次之后,它就失效了。

同样,CoGetInterfaceAndReleaseStream先调用了CoUnmarshalInterface来散集,然后调用IStream::Release()来释放字节流。

CoUnmarshalInterface function (combaseapi.h) - Win32 apps​docs.microsoft.com
fa26e0008b3d048da5f609ab562d9ede.png

COM支持自定义散集,方法是通过自己实现IMarshal接口,不过这个已经超出这篇文章的范围了,而且大部分情况,我们用COM默认的IMarshal实现就足够了,所以这里就不详细说了。

列集后的二进制流是一个和套间无关的对象,我们把它发送给目标计算机,它便能还原成我们列集前的接口了。

5、一个浅显的例子

IStream* g_stream;
HANDLE g_event;
unsigned __stdcall threadStart(void* pUnk)
{
	HRESULT hr = CoInitializeEx(NULL, COINIT_MULTITHREADED);
	WaitForSingleObject(g_event, INFINITE);
	IMessage* pMessage = NULL;
	hr = CoGetInterfaceAndReleaseStream(g_stream, IID_IMessage, (void**)&pMessage);
	pMessage->Foo();
	CoUninitialize();
	return 0;
}

int main()
{
	g_event = CreateEvent(NULL, FALSE, FALSE, NULL);

	unsigned int id;
	_beginthreadex(NULL, 0, threadStart, NULL, 0, &id);

	HRESULT hr = S_OK;
	hr = CoInitializeEx(NULL, COINIT_MULTITHREADED);
	if (SUCCEEDED(hr))
	{
		IMessage* pMessage = NULL;
		CLSID clsid;
		hr = CLSIDFromProgID(L"Foo", &clsid);
		if (SUCCEEDED(hr))
			hr = CoCreateInstance(clsid, NULL, CLSCTX_INPROC_SERVER, IID_IMessage, (void**)&pMessage);

		if (pMessage)
			hr = CoMarshalInterThreadInterfaceInStream(IID_IMessage, pMessage, &g_stream);
		SetEvent(g_event);
	}
	MSG msg;
	while (GetMessage(&msg, 0, 0, 0) > 0)
	{
		TranslateMessage(&msg);
		DispatchMessage(&msg);
	}

	CoUninitialize();
	return 0;
}

我们来看一段测试代码,来印证我们上面的说法。这段代码非常不严谨,它存在着IUnknown的内存泄露,并且程序会不断进行消息循环不会关闭。不过,这不妨碍我们运行它。

首先,我们在主线程里面创建了一个事件,接下来开启了一个子线程。接下来,通过CoInitializeEx我们初始化了一个MTA,然后创建了一个IMessage对象,并且将它列集,再通知子线程可以继续了。接下来主线程进入了消息循环。

在子线程中,同样先初始化了一个MTA,接下来等待主线程列集。列集完毕后,g_stream可以拿来用了,于是散集获取接口,并且调用其Foo方法。

我们会发现,MTA下Foo方法是在另外一个子线程调用的,这说明Foo无法预测它会在哪个线程被执行,因此它的AddRef和Release等操作,应当设计为原子操作。

如果我们把CoInitializeEx的第二个参数换为COINIT_APARTMENTTHREADED来初始化一个STA,那么,在子线程中调用的Foo方法并不会马上执行,而是会发送一个消息到主线程(因为IMessage实例是主线程创建的),主线程通过DispatchMessage,调用到了rpc模块,然后rpc模块再调用了IMessage::Foo(),可谓是非常迂回了。

最后我们要记得在结束线程的时候,通过CoUninitialize清理套间,养成良好的习惯。

最后要说明的是,我们可以为我们自己编写的dll模块指定套间类型。我们在注册表HKCRCLSID{你的GUID}InprocServer32下添加一个ThreadModel字符串,它的值可以为:

  • Apartment:此模块一定在STA中运行
  • Free:此模块一定在MTA中运行
  • Both:此模块既可以在STA中运行,也可以在MTA中运行。

加入一个模块默认继承了CComObjectRoot,使用了单线程模型,那么应当指明此模块一定是在STA中执行的。只要使用者按照COM所提供的机制老老实实Marshal/Unmarshal,那么你的模块就不会出现线程间的竞争问题。

目前为止,我们介绍了套间的基本概念和用法,但是没有介绍其具体是如何实现的,用到了哪些接口,什么叫做代理和桩(Proxy, Stub),这个在下一篇文章中会进行介绍。

Froser:COM编程攻略(十三 列集细节,代理,桩)​zhuanlan.zhihu.com
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值