在非托管资源上执行 P/Invoke 时何时需要 GC.KeepAlive(this)?

cez*_*tko 5 c# pinvoke garbage-collection callback finalizer

我有一个TestNet本机组件的包装器。本机组件公开了一个通过调用托管回调与托管部分进行通信的阻塞 ,以及一个用于检索对 .NET 包装器的引用并提供上下文的弱函数。它很弱,因为 .NET 包装器旨在隐藏正在向用户处理非托管资源的事实,并且故意不实现该接口:非弱它会根本阻止实例被收集,从而造成内存泄漏。发生的情况是,在发布版本中,只有垃圾收集器会在执行托管回调时收集对 .NET 包装器的引用,甚至在两者之前并且令人惊讶地解除阻塞。我自己理解这个问题,我可以通过在 P/Invoke 调用后发出 a 来解决它,但由于这方面的知识不是很广泛,似乎很多人都做错了。我有几个问题:TestNative::Foo()GCHandleGCHandleIDisposableTestNetTestNative::Foo()TestNet::Foo()GC.KeepAlive(this)

  1. 如果GC.KeepAlive(this)最后一条指令是对非托管资源的 P/Invoke 调用,或者仅在这种特殊情况下需要,即在从本机代码封送托管回调时切换到托管执行上下文,则托管方法中始终需要该指令?问题可能是:我应该GC.KeepAlive(this)到处放吗?这个旧的微软博客(原始链接是404,这里被缓存)似乎是这么建议的!但这将改变游戏规则,基本上这意味着大多数人从未正确执行过 P/Invoke,因为这需要在包装器中检查大多数 P/Invoke 调用。例如,是否有一条规则规定,当执行上下文非托管(本机)时,垃圾收集器(编辑:或更好的终结器)无法为属于当前线程的对象运行?
  2. 我在哪里可以找到适当的文档?我可以找到 CodeAnalysis 策略CA2115指向通常在使用 P/Invoke 访问非托管资源时使用的GC.KeepAlive(this) 策略。一般来说,在处理终结器GC.KeepAlive(this)时似乎很少需要。
  3. 为什么这种情况只发生在发布版本中?它看起来像是一种优化,但在调试构建中根本不需要,隐藏了垃圾收集器的重要行为。

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

它遵循的代码清楚地表明了问题。创建一个 C# 控制台应用程序和一个 C++ Dll1项目并在发布模式下构建它们:

程序.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);
    }
}
Run Code Online (Sandbox Code Playgroud)

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();
    }
}
Run Code Online (Sandbox Code Playgroud)

输出是一致的:

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);
    }
}
Run Code Online (Sandbox Code Playgroud)

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

cez*_*tko 3

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

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

是的,如果您不希望 API 用户在异常情况下承担持有托管对象实例的不可收集引用的最后责任,请查看下面的示例。但这不是唯一的方法:在进行 P/Invoke Interop 时,还可以使用一些技术来延长托管对象的生命周期HandleRefSafeHandle

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

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);
    }
}
Run Code Online (Sandbox Code Playgroud)

为了使本机调用始终安全,我们希望仅在返回后调用终结器Foo()。相反,我们可以通过在后台线程中手动调用垃圾收集来轻松强制违规。输出如下:

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);
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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

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

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

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

这是一种优化,也可以在启用优化的调试构建中发生,正如 @SimonMourier 所指出的。默认情况下,它在调试中也不会启用,因为它可能会阻止当前方法范围内的变量调试,如这些其他 答案中所述。

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