使用COM互操作时如何管理对象生命周期?

8
我有一个使用C#编写的托管COM对象以及一个使用C++(MFC和ATL)编写的本地COM客户端和接收器。在启动时,客户端创建该对象并通知其事件接口,并在关闭时取消通知其事件接口并释放该对象。问题在于,COM对象具有对接收器的引用,该引用直到垃圾回收运行时才被释放,在此时,客户端已被撤销,因此通常会导致访问冲突。这可能并不是重要的事情,因为客户端无论如何都会关闭,但如果可能的话,我想优雅地解决这个问题。我需要我的COM对象更及时地释放我的接收器对象,但我不知道从何处开始,因为我的COM对象没有显式与接收器对象一起工作。
public delegate void TestEventDelegate(int i);

[ComVisible(true)]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface ITestObject
{
    int TestMethod();
    void InvokeTestEvent();
}

[ComVisible(true)]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface ITestObjectEvents
{
    void TestEvent(int i);
}

[ComVisible(true)]
[ClassInterface(ClassInterfaceType.None)]
[ComSourceInterfaces(typeof(ITestObjectEvents))]
public class TestObject : ITestObject
{
    public event TestEventDelegate TestEvent;
    public TestObject() { }
    public int TestMethod()
    {
        return 42;
    }
    public void InvokeTestEvent()
    {
        if (TestEvent != null)
        {
            TestEvent(42);
        }
    }
}

客户端是一个标准的基于MFC对话框程序,具有对ATL的支持。我的sink类:
class CTestObjectEventsSink : public CComObjectRootEx<CComSingleThreadModel>, public ITestObjectEvents
{
public:
    BEGIN_COM_MAP(CTestObjectEventsSink)
        COM_INTERFACE_ENTRY_IID(__uuidof(ITestObjectEvents), ITestObjectEvents)
    END_COM_MAP()
    HRESULT __stdcall raw_TestEvent(long i)
    {
        return S_OK;
    }
};

我在我的对话框类中有以下成员:

ITestObjectPtr m_TestObject;
CComObject<CTestObjectEventsSink>* m_TestObjectEventsSink;
DWORD m_Cookie;

在OnInitDialog()函数中:
HRESULT hr = m_TestObject.CreateInstance(__uuidof(TestObject));
if(m_TestObject)
{
    hr = CComObject<CTestObjectEventsSink>::CreateInstance(&m_TestObjectEventsSink);
    if(SUCCEEDED(hr))
    {
        m_TestObjectEventsSink->AddRef(); // CComObject::CreateInstace() gives an object with a ref count of 0
        hr = AtlAdvise(m_TestObject, m_TestObjectEventsSink, __uuidof(ITestObjectEvents), &m_Cookie);
    }
}

在OnDestroy()方法中:

if(m_TestObject)
{
    HRESULT hr = AtlUnadvise(m_TestObject, __uuidof(ITestObjectEvents), m_Cookie);
    m_Cookie = 0;
    m_TestObjectEventsSink->Release();
    m_TestObjectEventsSink = NULL;
    m_TestObject.Release();
}

看起来你忘记了m_TestObjectEventsSink->Release()。由于你存储了CComObject<>的指针,它不是自动释放的,你可能会造成内存泄漏。不确定为什么这是必要的。 - Hans Passant
抱歉,我忘记了那些内容,但是效果与CComObject::CreateInstance()相同,会给你一个引用计数为0的对象。无论如何,我会更新问题的描述。 - Luke
CComObject::CreateInstance()会返回一个引用计数为0的对象;你需要负责调用AddRef()来增加它的引用计数。 - Luke
2个回答

3
首先,我要说的是,我已经使用了您的示例代码来实现您所描述的内容的副本,但是在测试调试或发布版本时,我没有看到任何访问违规的情况。
因此,有可能存在其他解释(例如,如果您持有与本地客户端的其他接口,则可能需要调用Marshal.ReleaseCOMObject)。
关于何时/何时不应调用ReleaseCOMObject,请参阅MSDN的这里中的全面说明。
话虽如此,您是正确的,您的C# COM对象不能直接与COM客户端的sink对象配合使用,但它可以通过C#事件对象与其进行通信。这允许您实现自定义事件对象,以便您可以捕获客户端对AtlAdviseAtlUnadvise的调用效果。
例如,您可以按以下方式重新实现事件(添加一些调试输出):
private event TestEventDelegate _TestEvent;
public event TestEventDelegate TestEvent
{
    add
    {
        Debug.WriteLine("TRACE : TestObject.TestEventDelegate.add() called");
        _TestEvent += value;
    }
    remove
    {
        Debug.WriteLine("TRACE : TestObject.TestEventDelegate.remove() called");
        _TestEvent -= value;
    }
}

public void InvokeTestEvent()
{
    if (_TestEvent != null)
    {
        _TestEvent(42);
    }
}

如果您想继续进行调试输出,可以在MFC/ATL应用程序中添加类似的诊断信息,以确切了解接收器接口的引用计数何时更新(请注意,这假定两个项目均为Debug版本)。例如,我向接收器实现添加了一个Dump方法:

class CTestObjectEventsSink : public CComObjectRootEx<CComSingleThreadModel>, public ITestObjectEvents
{
public:
    BEGIN_COM_MAP(CTestObjectEventsSink)
        COM_INTERFACE_ENTRY_IID(__uuidof(ITestObjectEvents), ITestObjectEvents)
    END_COM_MAP()
    HRESULT __stdcall raw_TestEvent(long i)
    {
        return S_OK;
    }
    void Dump(LPCTSTR szMsg)
    {
        TRACE("TRACE : CTestObjectEventsSink::Dump() - m_dwRef = %u (%S)\n", m_dwRef, szMsg);
    }
};

然后,通过IDE运行Debug客户端应用程序,您可以看到发生了什么。首先,在创建COM对象时:

HRESULT hr = m_TestObject.CreateInstance(__uuidof(TestObject));
if(m_TestObject)
{
    hr = CComObject<CTestObjectEventsSink>::CreateInstance(&m_TestObjectEventsSink);
    if(SUCCEEDED(hr))
    {
        m_TestObjectEventsSink->Dump(_T("after CreateInstance"));
        m_TestObjectEventsSink->AddRef(); // CComObject::CreateInstace() gives an object with a ref count of 0
        m_TestObjectEventsSink->Dump(_T("after AddRef"));
        hr = AtlAdvise(m_TestObject, m_TestObjectEventsSink, __uuidof(ITestObjectEvents), &m_Cookie);
        m_TestObjectEventsSink->Dump(_T("after AtlAdvise"));
    }
}

这将产生以下调试输出(您可以看到来自 AtlAdvise 调用的 C# 跟踪): TRACE:CTestObjectEventsSink :: Dump() - m_dwRef = 0(在CreateInstance之后)
TRACE:CTestObjectEventsSink :: Dump() - m_dwRef = 1(在AddRef之后)
TRACE:TestObject.TestEventDelegate.add()被调用
TRACE:CTestObjectEventsSink :: Dump() - m_dwRef = 2(在AtlAdvise之后) 这看起来正如预期的那样,我们有一个引用计数为2-一个来自本机代码的 AddRef 和另一个(可能来自) AtlAdvise
现在,您可以检查当调用 InvokeTestEvent() 方法时会发生什么 - 在这里我执行了两次:
m_TestObject->InvokeTestEvent();
m_TestObjectEventsSink->Dump(_T("after m_TestObject->InvokeTestEvent() first call"));
m_TestObject->InvokeTestEvent();
m_TestObjectEventsSink->Dump(_T("after m_TestObject->InvokeTestEvent() second call"));

这是相应的跟踪记录。
TRACE : CTestObjectEventsSink::Dump() - m_dwRef = 3 (after m_TestObject->InvokeTestEvent() first call)   
TRACE : CTestObjectEventsSink::Dump() - m_dwRef = 3 (after m_TestObject->InvokeTestEvent() second call) 

你可以看到,在第一次事件被触发时,会有额外的AddRef操作。我猜测这是直到垃圾回收时才得以释放的引用。
最后,在OnDestroy中,我们可以看到引用计数再次下降。代码如下:
if(m_TestObject)
{
    m_TestObjectEventsSink->Dump(_T("before AtlUnadvise"));
    HRESULT hr = AtlUnadvise(m_TestObject, __uuidof(ITestObjectEvents), m_Cookie);
    m_TestObjectEventsSink->Dump(_T("after AtlUnadvise"));
    m_Cookie = 0;
    m_TestObjectEventsSink->Release();
    m_TestObjectEventsSink->Dump(_T("after Release"));
    m_TestObjectEventsSink = NULL;
    m_TestObject.Release();
}

跟踪输出如下:

TRACE : CTestObjectEventsSink::Dump() - m_dwRef = 3 (在 AtlUnadvise 之前)
TRACE : TestObject.TestEventDelegate.remove() 被调用
TRACE : CTestObjectEventsSink::Dump() - m_dwRef = 3 (在 AtlUnadvise 之后)
TRACE : CTestObjectEventsSink::Dump() - m_dwRef = 2 (在 Release 之后)

所以你可以看到,AtlUnadvise并不会影响引用计数(其他人也注意到了这一点),但请注意,我们从C# COM对象事件的remove访问器中得到了一个跟踪信息,这是强制执行一些垃圾收集或其他拆除任务的可能位置。

总结:

  1. 你报告了使用你发布的代码产生的访问冲突,但我无法重现该错误,因此可能与你描述的问题无关。
  2. 你询问如何与COM客户端接收器交互,我已经展示了一种使用自定义事件实现的潜在方法。这得到了支持,同时显示了两个COM组件之间的交互方式。

我希望这是有帮助的。在这篇旧但非常优秀的博客文章中有一些其他的COM处理技巧和更多的解释。


我不确定那篇博客文章是否相关。它在托管代码中使用了一个COM对象,并将其定制为可确定释放。而在我的情况下,COM对象本身是托管的,并且在本机代码中被使用。在托管代码中,我从未引用过事件接收器,因此无法对其调用ReleaseComObject()。我将尝试在event.remove()期间强制进行GC,并查看是否有所作为。 - Luke
强制在 event.remove() 过程中执行 GC 似乎没有减少引用计数。 - Luke
@Luke 是的,抱歉,博客文章在那里是因为我过去发现它很有用,但你说得对,它是针对与你相反的情况。此外,我也尝试了GC.Collect,但我认为COM包装器仍然保持打开状态,直到对象本身消失。我的主要关注点是缺乏访问冲突,所以我无法继续尝试调试原因。我认为通过能够捕获AtlUnadvise,您可能能够为该问题应用清除操作。 - Roger Rowland
访问冲突在示例程序中并未发生,因为它是底层行为的最小复制(直到进程退出才释放接收器)。在实际代码中,接收器析构函数访问了一个已经被销毁的全局对象。不幸的是,由于这是我们所有事件接收器的通用机制(其中有很多),因此无法更改此机制。 - Luke
@Luke 我明白了 - 把 sink dtor 代码移到 AtlUnadvise 的同一位置是可行的吗? - Roger Rowland

1

我知道这是一个非常老的问题,但最近我们也遇到了这个问题,我们找到的解决方案是在关闭进程时添加调用CoEEShutDownCOM()

调用此方法似乎强制CLR释放对我们的(非托管)COM对象的任何引用。请注意,文档中说它在.Net 4中已经被弃用(并且他们建议使用另一种替代方法,我们还没有尝试过)。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接