.NET COM 互操作 - 对抗 RCW 内存泄漏
本文探讨了 RCW 和 COM 中 AddRef 和 Release 调用规则,解决了 RCW 内存泄漏问题。
引言
最近我被分配了一项任务,需要追踪一些由 .NET/COM 互操作引起的内存泄漏/崩溃。如果你曾经历过这些,你就会明白这是一项多么艰巨的任务。
在谷歌搜索文章、阅读它们并进行了一些小实验之后,我形成了一些自己的想法/理解。曾经让我望而生畏的事情现在开始变得有意义了。我想在这里分享它们,与那些仍在 struggling 的人分享。
背景
一般场景是这样的:
- 我们使用 ATL 创建一个 COM 对象(如果你不想使用 ATL 并自己完成所有事情,那也可以,但这将是一项非常艰巨的任务)。我们在 IDL 文件中定义这些接口,并使用 C++ 实现它们。然后 COM 服务器就完成了。
- 我们想在 .NET 中使用它们。我们使用 _tlbimp.exe_ 生成 RCW(实际上是生成的 DLL 文件)。
- 我们通过创建 COM 对象实例并调用其方法来开始使用 COM 对象。
我们面临并试图解决的问题是:
- 当 .NET 应用程序运行结束时,一些 COM 对象没有按预期释放/处置/销毁;因此我们存在内存泄漏。
或者/并且
- 有时我们会遇到 .NET Framework 抛出的异常,提示“xxx 无法使用,因为底层 COM 对象已被分离”。
主要内容
在开始探索之前,我想强调一个事实:始终记住在 .NET/COM 互操作中存在三个层次:你的 .NET 代码、RCW 和原生 COM 服务器。你对你的 .NET 代码拥有完全控制权,但在 RCW/COM 世界中存在许多你必须理解和遵守的规则/规范。否则,你就会遇到上面列出的问题。
为了理解 COM 和 RCW 世界中的这些规则/规范,我将使用一些示例代码。在这个例子中,我定义了 2 个 COM 对象,一个是“CQTTest”,代表一个测试对象,另一个是“CQTAction”,代表一个动作对象。一个测试对象可以包含许多动作对象,其中有一个是活动的。测试和动作都有一个名为“Run”的方法(如果你有一些使用 Quick Test Professional 的经验,你就知道我在说什么)。这些 IDL 文件和实现代码如下所示:
// CodeSample.idl : IDL source for SampleCode // This file will be processed by the MIDL tool to // produce the type library (SampleCode.tlb) and marshalling code. import "oaidl.idl"; import "ocidl.idl"; [ object, uuid(425E8992-D4C3-4054-9307-4E3AD0C088F4), helpstring("IQTAction Interface"), pointer_default(unique) ] interface IQTAction : IUnknown{ [helpstring("method Run")] HRESULT Run(void); }; [ object, uuid(D796C6FC-4DAE-4222-ACB9-F0DAAF53F4C8), helpstring("IQTTest Interface"), pointer_default(unique) ] interface IQTTest : IUnknown{ [helpstring("method GetActiveAction")] HRESULT GetActiveAction([out] IQTAction** pActionOut); [helpstring("method Run")] HRESULT Run(void); [helpstring("Run a specific action")] HRESULT RunAction([in] IQTAction* pActionIn); }; [ uuid(D56E2107-DCF0-4039-9127-7B5B89EFF4C2), version(1.0), helpstring("COMServer 1.0 Type Library") ] library COMServerLib { importlib("stdole2.tlb"); [ uuid(C3FA9039-39F2-411F-8555-8063EF07E17A), helpstring("QTTest Class") ] coclass QTTest { [default] interface IQTTest; }; [ uuid(EFF340AC-86DE-4127-B20B-4B96B64C5064), helpstring("QTAction Class") ] coclass QTAction { [default] interface IQTAction; }; };
// CQTTest.h class ATL_NO_VTABLE CQTTest : public CComObjectRootEx<CComSingleThreadModel>, public CComCoClass<CQTTest, &CLSID_QTTest>, public IQTTest { public: CQTTest() { m_pActiveAction.CoCreateInstance(CLSID_QTAction); //construct an action; } DECLARE_REGISTRY_RESOURCEID(IDR_QTTEST) DECLARE_NOT_AGGREGATABLE(CQTTest) BEGIN_COM_MAP(CQTTest) COM_INTERFACE_ENTRY(IQTTest) END_COM_MAP() DECLARE_PROTECT_FINAL_CONSTRUCT() HRESULT FinalConstruct() { return S_OK; } void FinalRelease() { ::MessageBox(NULL,L"#################QTTest is being disposed",L"info",0); } public: STDMETHOD(GetActiveAction)(IQTAction** pActionOut); STDMETHOD(Run)(void); STDMETHOD(RunAction)(/*in*/IQTAction* pActionIn); private: CComPtr<IQTAction> m_pActiveAction; public: }; OBJECT_ENTRY_AUTO(__uuidof(QTTest), CQTTest) //CQTTest.cpp STDMETHODIMP CQTTest::GetActiveAction(IQTAction** pActionOut) { *pActionOut = m_pActiveAction; (*pActionOut)->AddRef(); // this line is *very important* return S_OK; } STDMETHODIMP CQTTest::RunAction(IQTAction * pActionIn) { pActionIn->Run(); return S_OK; }
以及我的动作对象
// CQTAction.h class ATL_NO_VTABLE CQTAction : public CComObjectRootEx<CComSingleThreadModel>, public CComCoClass<CQTAction, &CLSID_QTAction>, public IQTAction { public: CQTAction() { } DECLARE_REGISTRY_RESOURCEID(IDR_QTACTION) DECLARE_NOT_AGGREGATABLE(CQTAction) BEGIN_COM_MAP(CQTAction) COM_INTERFACE_ENTRY(IQTAction) END_COM_MAP() DECLARE_PROTECT_FINAL_CONSTRUCT() HRESULT FinalConstruct() { return S_OK; } void FinalRelease() { ::MessageBox(NULL,L"********************************** QTAction is being disposed",L"info",0); } public: STDMETHOD(Run)(void); }; OBJECT_ENTRY_AUTO(__uuidof(QTAction), CQTAction) // CQTAction.cpp STDMETHODIMP CQTAction::Run(void) { ::MessageBox(NULL,L"Action is running...",L"info",0); return S_OK; }
请注意,我在 ::FinalRelease() 方法中弹出了消息框,以指示 COM 对象正在被释放。这是我们确认没有内存泄漏发生的方式。
为了理解 RCW 如何与 COM 交互,首先让我们看看原生 C++ 如何与 COM 对话。这是一个小例子:
#include "stdAfx.h" #include "COMServer_i.h" #include "COMServer_i.c" void RunActiveAction(IQTTest * pTest) { CComPtr<IQTAction> pAction; pTest->GetActiveAction(&pAction); pTest->RunAction(pAction); } int main() { CoInitializeEx( NULL, COINIT_APARTMENTTHREADED ); CComPtr<IQTTest> pTest; HRESULT hr = ::CoCreateInstance(CLSID_QTTest,NULL,CLSCTX_INPROC_SERVER,IID_IQTTest,(void **)&pTest); ATLASSERT(SUCCEEDED(hr)); RunActiveAction((IQTTest*)pTest); }
我们称之为“NativeComConsumer”。运行它,我们应该会看到消息框,显示:
1. 动作正在运行;
2. 测试对象正在被释放;
3. 动作对象正在被释放;
这与我们预期的完全一致。
我们来看 RunActiveAction,我们使用 CComPtr 实例来持有对活动动作的引用,我们在 CQTTest::GetActiveAction 中增加了活动动作的引用计数,当该方法调用完成后 CComPtr 被析构时,我们减少了活动动作的引用计数。因此,在 RunActiveAction 调用返回后,活动动作的底层引用计数仍然为一,这与 QTAction 对象最初创建时相同。这表明了基本的 COM 编码规则:如果你想将 COM 对象传出,你需要增加对象的引用计数。
如果我们不遵循这条规则,比如,我们没有
(*pActionOut)->AddRef(); // this line is *very important*
在 CQTTest::GetActiveAction 中,会发生什么?
嗯,这完全取决于客户端如何使用它。以“NativeComConsumer”为例,由于我们没有在 COM 服务器端或 COM 客户端调用 CQTAction::AddRef(),而我们在 RunActiveAction 返回时仍然调用了该活动动作的 CQTAction::Release() 方法(请记住,当此方法返回时,pAction 将调用 ~CComPtr 来释放它指向的 COM 对象的引用计数),因此在该 RunActiveAction 调用返回后不久,我们测试对象中的活动动作 COM 对象将被释放。我们可以通过看到消息框显示动作正在被销毁来确认这一点。因此在这种情况下,我们的应用程序将崩溃,因为当 CQTTest 被释放时,它将销毁其 m_pActiveAction,而 m_pActiveAction 也在尝试释放活动动作。
当然,我们可以在客户端显式调用 CQTAction::AddRef。即,在 RunActiveAction 调用中,我们添加了
(*pAction)->AddRef();
这也可以解决崩溃问题,*但是*,这没有太大意义,因为现在 COM 服务器基本上没有用了。我们不能期望每个 COM 客户端都知道我们做错了什么,因此你需要做一些额外的事情来修复它。此外,当与 Python、VBScript、JavaScript 等脚本语言互操作时,COM 客户端没有能力这样做。这就是为什么在编写 COM 时遵循此规则如此重要的原因。
在 .NET 中进行 COM 互操作有点像在 Python、VBScript 或 JS 中进行互操作。你对实际的 COM 对象没有像在原生 C++ 中那样多的控制。 .NET 框架已经帮助你将 COM 对象包装成一个包装器,你应该在代码中处理这个包装器。这个包装器被称为 RCW。
RCW 充当代理,帮助你的 .NET 代码与 COM 服务器通信。作为 .NET 开发人员,我们处理 RCW 而不是真正的 COM 对象。微软强制开发人员这样做是为了防止我们犯内存错误。但即使如此,如果我们不知道 RCW 的确切工作方式,我们仍然会犯错误。以下是一些最基本的规则:
- 对于每个 COM 对象,只有一个对应的 RCW 实例。**但它对底层 COM 对象持有的引用计数可以不止一个!**
- .NET 框架以类似于 COM 维护其 COM 对象的方式维护 RCW。它们都使用引用计数来指示有多少客户端正在使用我。如果此 RCW 在多个地方被使用,则 RCW 引用计数将增加,但 COM 对象引用计数保持不变。
- 当 RCW 引用计数达到零或 RCW 被 GC 回收时,RCW 持有的所有对 COM 对象的引用都将被释放。
因此,我们通过处理 RCW 引用计数来控制 COM 对象的引用计数。如果我们做错了,底层 COM 对象可能在我们希望它已经释放时仍然存活(内存泄漏),或者在我们希望它仍然存活时它已经被释放(在某些情况下崩溃,或者 .NET 异常说底层 COM 对象已被分离等)。
1. RCW 引用计数何时增加?
.NET 框架认为需要时,RCW 引用计数会增加;我们对此完全无法控制。我们只需要知道 .NET 在何种情况下会增加 RCW 引用计数。这是我在 Stack Overflow 论坛上找到的一个很好的答案:
<cite author="Arthur">
简短的回答:每次 COM 对象从 COM 环境传递到 .NET 时。
详细回答:
- 对于每个 COM 对象,只有一个 RCW 对象 [测试 1] [引用 4]
- 每次从 COM 对象内部请求对象时(调用 COM 对象上返回 COM 对象属性或方法时,返回的 COM 对象的引用计数将增加一),引用计数都会增加 [测试 1]
- 通过将对象转换为对象的其他 COM 接口或移动 RCW 引用时,引用计数不会增加 [测试 2]
- 当对象作为 COM 引发的事件中的参数传递时,引用计数会增加 [引用 1]
</cite>
2. RCW 引用计数何时减少?
我们需要通过调用 Marshal.ReleaseComObject,并传入要减少的 RCW 引用来手动减少 RCW 引用计数。.NET 绝不会帮助你完成此操作,即 .NET 负责增加计数,而你负责减少计数。
为什么?为什么 .NET 框架的设计者在 RCW 引用超出范围时不会减少 RCW 引用计数?就像我们在 COM 世界中那样(请记住在“NativeCOMConsumer”示例中,每个 CComPtr 在超出范围时都会释放底层 COM 对象)。为什么 .NET 以如此不同的策略处理 RCW?
/// in NativeComConsumer, /// when RunActiveAction returns, we won' keep extra reference to pAction; void RunActiveAction(IQTTest * pTest) { CComPtr<IQTAction> pAction; pTest->GetActiveAction(&pAction); pTest->RunAction(pAction); }
在 .NET 中,
/// in .NET, /// when this RunActiveAction returns, still keeps an extra reference to pActionRCW static void RunActiveAction(IQTTest pTestRCW) { IQTAction pActionRCW; pTestRCW.GetActiveAction(out pActionRCW); pTestRCW.RunAction(pActionRCW); }
因此,我们务必调用
Marshal.FinalReleaseComObject(pActionRCW);
在上面的 .NET 示例中。
其根本原因,就像 Ian Griffiths 所说:
1. COM 假设当方法返回时你不会持有对象引用;
2. RCW 假设当方法返回时你 *将* 持有对象引用。