通过P/Invoke传递实例方法时的委托

5

令我惊讶的是,今天我发现了一个强大的功能。由于看起来太美好而难以置信,我想确保它不仅仅是因为某种奇怪的巧合而起作用。

我一直认为,当我的p/invoke(到c/c++库)调用期望一个(回调)函数指针时,我必须传递一个静态c#函数的委托。例如,在下面的代码中,我总是引用KINSysFn的委托到该签名的静态函数。

[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate int KINSysFn(IntPtr uu, IntPtr fval, IntPtr user_data );

然后使用这个委托参数调用我的P/Invoke:

[DllImport("some.dll", EntryPoint = "KINInit", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern int KINInit(IntPtr kinmem, KINSysFn func, IntPtr tmpl);

但是现在我刚尝试并传递了一个实例方法的委托,它也起作用了!例如:
public class MySystemFunctor
{
    double c = 3.0;
    public int SystemFunction(IntPtr u, IntPtr v, IntPtr userData) {}
}

// ...

var myFunctor = new MySystemFunctor();
KINInit(kinmem, myFunctor.SystemFunction, IntPtr.Zero);

当然,我知道在托管代码中,将“this”对象与实例方法打包形成相应的委托没有任何技术问题。
但令我惊讶的是,“MySystemFunctor.SystemFunction”的“this”对象也会传递到本地dll,该dll仅接受静态函数,并不包含任何关于“this”对象或将其与函数一起打包的设施。
这是否意味着任何此类委托都会被单独转换(编组?)为静态函数,其中对相应“this”对象的引用在函数定义中以某种方式硬编码?否则,如何区分不同的委托实例,例如,如果我有:
var myFunctor01 = new MySystemFunctor();
// ...
var myFunctor99 = new MySystemFunctor();

KINInit(kinmem, myFunctor01.SystemFunction, IntPtr.Zero);
// ...
KINInit(kinmem, myFunctor99.SystemFunction, IntPtr.Zero);

这些不能都指向同一个函数。如果我动态创建了无限数量的MySystemFunctor对象呢?每个这样的代理在运行时是否都会“展开”/编译为自己的静态函数定义?


我认为你知道这个问题的答案。你的代码证明了它。 - David Heffernan
@DavidHeffernan:你是指关于它内部实现的问题吗?我只是提出了一个假设,如果它是真的或者有我没有考虑到的东西,那就太好了。 - oliver
2个回答

10
是的,你猜对了。在CLR内部有许多代码执行这些操作,它会自动生成适配器函数的机器码用于将调用从本机代码转换为托管代码。本机代码将获得指向该适配器函数的函数指针。参数值可能需要转换,这是标准的平台调用(pinvoke)封送处理程序的职责,并且始终进行调整以匹配对托管方法的调用。其中之一是提供“this”的存储委托的Target属性。它会调整堆栈帧,将链接连接到前一个托管帧,以便GC可以看到它再次需要查找对象根目录。
然而,有一个非常棘手的小细节,几乎让每个人都陷入麻烦。当回调不再需要时,这些适配器函数会自动清除。CLR无法从本机代码中获取任何帮助来确定这一点,它发生在委托对象被垃圾回收时。也许你已经察觉到了问题,那么在你的程序中是什么决定了委托对象被回收的时间呢?
 var myFunctor = new MySystemFunctor();

那是一个方法的本地变量。它不会存活太久,下一次垃圾收集将销毁它。如果本机代码通过thunk持续进行回调,当它不再存在时会出现严重的崩溃。当你在实验中使用代码时,很难看到这一点。

你必须确保这种情况不会发生。将委托对象存储在类中可能有效,但那么你需要确保类对象存活的时间足够长。无从得知,需要尝试解决。当你注销这些回调时,问题通常可以自行解决,因为这需要存储对象引用以供稍后使用。你也可以将它们存储在静态变量中或使用GCHandle.Alloc(),但这当然会失去快速获得实例回调的好处。通过测试确保正确完成,调用者中调用GC.Collect()。

值得注意的是,通过显式 new 委托,你做对了。C#语法糖不需要这样做,使得正确性更难以得到保证。如果回调仅在你进行的本机代码的 Pinvoke 调用期间发生(例如EnumWindows),那么你就不必担心它,因为Pinvoke marshaller会确保委托对象保持被引用状态。


在我的情况下,对KINInit的调用只是存储回调。之后会调用另一个例程KINSolve,并且猜猜看,它假设回调仍然存在。所以我从你的评论中得出的结论是,在调用KINInit和KINSolve之间保持对mySystemFunctor的引用就可以了,是吗? - oliver
编辑以让您感觉更好。将它们存储在类的字段中,当KINSolve()是实例方法时,您就可以放心了。尽管这听起来不像是一个确保没有进一步回调的清理方法。考虑可能的KINTerminate()或Dispose()方法。 - Hans Passant
但仅仅将委托实例存储在字段中可能不足够,因为在调用KINSolve的方法期间(假设它不使用该委托或其他实例字段),实例可能会被回收(连同委托一起)。 - Evk
存储委托在实例字段中是否有保证会有帮助?即使您保持包含类的存在,理论上JIT也可以检测到该字段未从存储它的函数之外读取,并将其降级为局部变量。这表明需要在其中使用GC.KeepAlive的终结器。我知道当前的JITter不会这样做,但它可能会这样做,对吗? - Charlieface

3

记录一下:我已经掉进了Hans Passant提到的陷阱中。强制垃圾回收导致空引用异常,因为委托是短暂的。

KINInit(kinmem, myFunctor.SystemFunction, IntPtr.Zero);
// BTW: same with:
// KINInit(kinmem, new KINSysFn(myFunctor.SystemFunction), IntPtr.Zero);

GC.Collect();
GC.WaitForPendingFinalizers();

KINSol(/*...*); // BAAM! NullReferenceException

幸运的是,我已经将关键的两个 P/Invoke(KINInit 设置回调委托和 KINSolve 实际使用回调)封装到一个专门的托管类中。解决方案如前所述,是通过将委托引用保留在类成员中:

// ksf is a class member of delegate type KINSysFn that keeps ref to delegate instance
ksf = new KINSysFn(myFunctor.SystemFunction); 
KINInit(kinmem, ksf, IntPtr.Zero);

GC.Collect();
GC.WaitForPendingFinalizers();

KINSol(/*...*);

再次感谢您,汉斯。由于只要没有垃圾回收发生,它就可以工作,所以我从未注意到这个缺陷!

我猜可能因为没有回答问题而被踩。 - David Heffernan

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