在处理非托管资源的P/Invoke时,何时需要使用GC.KeepAlive(this)?

5
我有一个针对本地组件的TestNet封装器。本地组件通过调用托管回调并使用弱GCHandle检索对.NET包装器的引用并提供上下文来公开阻塞的TestNative :: Foo()。GCHandle是弱的,因为.NET包装器旨在隐藏它正在处理未受管控的资源的事实,并且故意不实现IDisposable接口:如果不弱,则完全会防止收集TestNet实例,从而创建内存泄漏。发生的情况是,在Release构建中,仅当执行托管回调时垃圾回收器才会收集对.NET包装器的引用,甚至在TestNative :: Foo()和出乎意料的TestNet :: Foo()都解除阻塞之前。我自己理解了问题,可以通过在P / Invoke调用后发布GC.KeepAlive(this)来修复它,但由于这方面的知识并不是很普及,似乎很多人做错了。我有几个问题:
  1. 如果托管方法的最后一条指令是对非托管资源的P/Invoke调用,或者在将托管回调从本地代码进行封送时切换到托管执行上下文,那么是否总是需要使用GC.KeepAlive(this)?问题可能是:我应该在任何地方都放置GC.KeepAlive(this)吗?这篇旧的微软博客(原始链接已经失效,这里是缓存)似乎是这样建议的!但这将是一个重大改变,基本上意味着大多数人从未正确地进行过P/Invoke,因为这将要求检查包装器中的大多数P/Invoke调用。例如,是否有规则表明垃圾收集器(编辑:或更好的是终结器)不能在执行上下文为非托管(本机)时运行属于当前线程的对象?
  2. 我可以在哪里找到适当的文档?我找到了CodeAnalysis策略CA2115,指出通常情况下使用GC.KeepAlive(this) 任何时间使用P/Invoke访问非托管资源。通常情况下,处理终结器时很少需要使用GC.KeepAlive(this)
  3. 为什么只在Release版本中发生这种情况?它看起来像是一种优化,但在Debug版本中根本不需要隐藏垃圾收集器的重要行为。

注意:我不反对委托被收集,那是另一个问题,我知道如何正确处理。这里的问题是当P/Invoke调用尚未完成时,持有非托管资源的对象会被收集。

下面是清晰展示该问题的代码。创建一个C#控制台应用程序和一个C++ Dll1 项目,并以Release模式构建它们:

Program.cs:

using System;
using System.Runtime.InteropServices;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            var test = new TestNet();
            try
            {
                test.Foo();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex);
            }
        }
    }

    class TestNet
    {
        [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
        delegate void Callback(IntPtr data);

        static Callback _callback;

        IntPtr _nativeHandle;
        GCHandle _thisHandle;

        static TestNet()
        {
            // NOTE: Keep delegates references so they can be
            // stored persistently in unmanaged resources
            _callback = callback;
        }

        public TestNet()
        {
            _nativeHandle = CreateTestNative();

            // Keep a weak handle to self. Weak is necessary
            // to not prevent garbage collection of TestNet instances
            _thisHandle = GCHandle.Alloc(this, GCHandleType.Weak);

            TestNativeSetCallback(_nativeHandle, _callback, GCHandle.ToIntPtr(_thisHandle));
        }

        ~TestNet()
        {
            Console.WriteLine("this.~TestNet()");
            FreeTestNative(_nativeHandle);
            _thisHandle.Free();
        }

        public void Foo()
        {
            Console.WriteLine("this.Foo() begins");
            TestNativeFoo(_nativeHandle);

            // This is never printed when the object is collected!
            Console.WriteLine("this.Foo() ends");

            // Without the following GC.KeepAlive(this) call
            // in Release build the program will consistently collect
            // the object in callback() and crash on next iteration 
            //GC.KeepAlive(this);
        }

        static void callback(IntPtr data)
        {
            Console.WriteLine("TestNet.callback() begins");
            // Retrieve the weak reference to self. As soon as the istance
            // of TestNet exists. 
            var self = (TestNet)GCHandle.FromIntPtr(data).Target;
            self.callback();

            // Enforce garbage collection. On release build
            self = null;
            GC.Collect();
            GC.WaitForPendingFinalizers();
            Console.WriteLine("TestNet.callback() ends");
        }

        void callback()
        {
            Console.WriteLine("this.callback()");
        }

        [DllImport("Dll1", CallingConvention = CallingConvention.Cdecl)]
        static extern IntPtr CreateTestNative();

        [DllImport("Dll1", CallingConvention = CallingConvention.Cdecl)]
        static extern void FreeTestNative(IntPtr obj);

        [DllImport("Dll1", CallingConvention = CallingConvention.Cdecl)]
        static extern void TestNativeSetCallback(IntPtr obj, Callback callback, IntPtr data);

        [DllImport("Dll1", CallingConvention = CallingConvention.Cdecl)]
        static extern void TestNativeFoo(IntPtr obj);
    }
}

Dll1.cpp:

#include <iostream>

extern "C" typedef void (*Callback)(void *data);

class TestNative
{
public:
    void SetCallback(Callback callback1, void *data);
    void Foo();
private:
    Callback m_callback;
    void *m_data;
};

void TestNative::SetCallback(Callback callback, void * data)
{
    m_callback = callback;
    m_data = data;
}

void TestNative::Foo()
{
    // Foo() will never end
    while (true)
    {
        m_callback(m_data);
    }
}

extern "C"
{
    __declspec(dllexport) TestNative * CreateTestNative()
    {
        return new TestNative();
    }

    __declspec(dllexport) void FreeTestNative(TestNative *obj)
    {
        delete obj;
    }

    __declspec(dllexport) void TestNativeSetCallback(TestNative *obj, Callback callback1, void * data)
    {
        obj->SetCallback(callback1, data);
    }

    __declspec(dllexport) void TestNativeFoo(TestNative *obj)
    {
        obj->Foo();
    }
}

输出始终如一:
this.Foo() begins
TestNet.callback() begins
this.callback()
this.~TestNet()
TestNet.callback() ends
TestNet.callback() begins
System.NullReferenceException: Object reference not set to an instance of an object.

如果在TestNet.Foo()中取消注释GC.KeepAlive(this)的调用,程序将正确地永远不会结束。

https://stackoverflow.com/a/52954023/11683? - GSerg
1
https://devblogs.microsoft.com/oldnewthing/20100810-00/?p=13193? - GSerg
@GSerg 是的!那篇文章与这个问题非常相关,指向了我已经找到的 CA2115!因此,很明显,在对象引用不再使用之后,即使在 P/Invoke 调用中间,如果有并发 GC 策略,对象也可以随时被标记为 GC 回收。但问题仍然存在:在当前线程执行本机代码时,该对象的 finalizer 是否也会被调用?我认为这是不可能的:它在我的代码中“发生”,因为执行上下文已经切换到管理代码以调用回调函数。 - ceztko
1
问题不在于Debug与Release,而在于优化与未优化。在Debug模式下检查“优化代码”,您应该会看到相同的问题:https://dev59.com/UZffa4cB1Zd3GeqP73GY和https://dev59.com/pmQn5IYBdhLWcg3wETk5#17131389. 无论如何,您所做的似乎非常复杂。为什么不使用IDisposable标准和成员回调,例如像这样:https://pastebin.com/raw/z0eL1iQf - Simon Mourier
@SimonMourier 我没有实现 IDisposable 模式,因为我没有持有文件句柄、套接字或其他建议使用 IDisposable 的资源:这只是一个 C++ 本地 API,C# 用户不应该被迫依赖于 using。无论如何:我刚刚意识到 GC.KeepAlive() API 正好符合我的回调使用情况,基本上回答了我的第二个问题。你的答案完美地涵盖了问题3)。尚未回答的问题是是否始终需要 GC.KeepAlive(this) - ceztko
显示剩余10条评论
1个回答

3

总结了非常有用的评论和研究:

1)如果最后一条指令是使用实例持有的非托管资源进行P/Invoke调用,那么在托管实例方法中是否始终需要GC.KeepAlive(this)

是的,在病态情况下,如果您不希望API的用户对托管对象实例保持不可回收的引用的最后责任,则请参阅以下示例。但这并不是唯一的方法:HandleRefSafeHandle技术也可以用于在执行P/Invoke Interop时延长托管对象的生命周期。

该示例将通过保持本机资源的托管实例来随后调用本机方法:

Original answer: "Yes, you should call GC.KeepAlive(this) at the end of the method. Otherwise, the user of the API will have the responsability to hold a non-collectible reference for the instance of the managed object in pathological cases. But it's not the only way: HandleRef or SafeHandle techiniques can also be used to prolong the lifetime of a managed object when doing P/Invoke Interop."

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            new Thread(delegate()
            {
                // Run a separate thread enforcing GC collections every second
                while(true)
                {
                    GC.Collect();
                    Thread.Sleep(1000);
                }
            }).Start();

            while (true)
            {
                var test = new TestNet();
                test.Foo();
                TestNet.Dump();
            }
        }
    }

    class TestNet
    {
        static ManualResetEvent _closed;
        static long _closeTime;
        static long _fooEndTime;

        IntPtr _nativeHandle;

        public TestNet()
        {
            _closed = new ManualResetEvent(false);
            _closeTime = -1;
            _fooEndTime = -1;
            _nativeHandle = CreateTestNative();
        }

        public static void Dump()
        {
            // Ensure the now the object will now be garbage collected
            GC.Collect();
            GC.WaitForPendingFinalizers();

            // Wait for current object to be garbage collected
            _closed.WaitOne();
            Trace.Assert(_closeTime != -1);
            Trace.Assert(_fooEndTime != -1);
            if (_closeTime <= _fooEndTime)
                Console.WriteLine("WARN: Finalize() commenced before Foo() return");
            else
                Console.WriteLine("Finalize() commenced after Foo() return");
        }

        ~TestNet()
        {
            _closeTime = Stopwatch.GetTimestamp();
            FreeTestNative(_nativeHandle);
            _closed.Set();
        }

        public void Foo()
        {
            // The native implementation just sleeps for 250ms
            TestNativeFoo(_nativeHandle);

            // Uncomment to have all Finalize() to commence after Foo()
            //GC.KeepAlive(this);
            _fooEndTime = Stopwatch.GetTimestamp();
        }

        [DllImport("Dll1", CallingConvention = CallingConvention.Cdecl)]
        static extern IntPtr CreateTestNative();

        [DllImport("Dll1", CallingConvention = CallingConvention.Cdecl)]
        static extern void FreeTestNative(IntPtr obj);

        [DllImport("Dll1", CallingConvention = CallingConvention.Cdecl)]
        static extern void TestNativeFoo(IntPtr obj);
    }
}

为了使原生调用始终安全,我们期望在Foo()返回后才调用finalizer。但是,我们可以通过在后台线程中手动调用垃圾回收来轻松地强制执行违规操作。输出如下:

"最初的回答"

Finalize() commenced after Foo() return
WARN: Finalize() commenced before Foo() return
Finalize() commenced after Foo() return
Finalize() commenced after Foo() return
Finalize() commenced after Foo() return
WARN: Finalize() commenced before Foo() return
Finalize() commenced after Foo() return

2) 我在哪里可以找到文档?

GC.KeepAlive()的文档提供了一个与原问题中托管回调非常相似的示例。 HandleRef 还对托管对象和Interop的生命周期有非常有趣的考虑:

如果您使用平台调用来调用托管对象,并且在平台调用调用之后未在其他地方引用该对象,则垃圾回收器可能会完成托管对象的终结。此操作释放资源并使句柄无效,导致平台调用失败。使用 HandleRef 包装句柄可以保证在平台调用完成之前不会对托管对象进行垃圾回收。

@GSerg发现的链接[1]还解释了对象何时可收集,指出this引用不在根集中,从而允许在实例方法未返回时也可以收集它。

3) 为什么这只在发布版本中发生?

这是一种优化,也可能在启用优化的 Debug 构建中发生,如 @SimonMourier 所指出的。在 Debug 中默认情况下也未启用它,因为它可能会防止调试当前方法作用域中的变量,正如这些 其他 答案中所解释的那样。

[1] https://devblogs.microsoft.com/oldnewthing/20100810-00/?p=13193?


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