起因
其实这得从好多年前的一个BUG说起.
那一年, 刚刚接触C++不久, 遇到的一个空引用之类的错误,反复调试却没有发现C++有任何的问题
单独跑C#测试也没有任何问题, 屏蔽C++的回调才找到出问题的地方。
示例代码
为了复现那个BUG的样子,我甩个DEMO出来。
C++的代码是下面这样的,公布SetCallback函数,由C#设置回调地址,然后在C++非托管线程中不断调用该回调
/// 数据回调申明
typedef void (WINAPI *DataCallback)(int nData);
#ifdef __cplusplus
extern "C"
{
#endif
#define CDLLINVOKE_EXPORTS __declspec(dllexport)
CDLLINVOKE_EXPORTS void SetCallback(DataCallback pPt);
#ifdef __cplusplus
}
#endif
DataCallback m_pCallback = NULL;
///
/// 产生数据
///
DWORD WINAPI GenerateData(PVOID pParam)
{
int nCnt = 0;
while (true)
{
Sleep(20);
if(NULL!= m_pCallback)
{
m_pCallback(nCnt);
}
nCnt ++;
}
return 0;
}
///
/// 设置数据回调
///
CDLLINVOKE_EXPORTS void SetCallback(DataCallback pPt)
{
m_pCallback = pPt;
CreateThread(NULL, 0, GenerateData, NULL, 0, NULL);
}
C#代码是下面这样的。通过对CDllInvoke.dll的互操作设置回调地址,然后将非托管的回调数据打印出来。
namespace ConsoleApplication1
{
class CDllInvoke
{
const string DllName = "CDllInvoke.dll";
[UnmanagedFunctionPointer(System.Runtime.InteropServices.CallingConvention.StdCall)]
delegate void DataCallback(int nData);
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
static extern void SetCallback(DataCallback pCall);
void Callback(int nData)
{
Console.WriteLine("收到回调:{0}", nData);
}
public void Run()
{
SetCallback(Callback);
}
}
class Program
{
static void Main(string[] args)
{
CDllInvoke test = new CDllInvoke();
test.Run();
//GC.Collect() 模拟GC自动回收。
while (true) { Thread.Sleep(100); } } } }
生成C++代码为CDllInvoke.dll ,生成C#代码为exe执行程序。然后执行exe。
异常发生
当然,这个程序并不一定会出现异常。 为了加快异常发生。各位可在C#代码test.Run()后面那行 注释取消。GC.Collect()
结果执行完后程序立即就崩溃了,相信看到这里,大家已经明白我的意思了。
大胆假设
很明显这个问题与GC回收有关
我们知道,在编译 SetCallback(Callback); 这句话的时候,编译器会自动创建一个代理。也就是说上面这句代码与下面这两句,对编译器来讲是没有什么区别的
DataCallback pCall = new DataCallback(Callback);
SetCallback(pCall);
而实例pCall在set过后就设置到了非托管代码,GC并不知道该引用的存在,判断到引用计数器为0,于是就释放掉了这个实例。
而在C++回调处,还把它当成一个正常的函数指针调用,最后导致了异常的发生。
小心求证
空口无凭,我们可以通过查看编译后IL代码证明我的假设(不知道IL的看这里)
这里选择通过VS2010自带的工具,IL 反汇编程序反编译。(该工具可在开始菜单->Microsoft Visual Studio 2010 目录下找到,对了,我是假设你安装了VS的的)
Run方法对应的 IL代码【在Release编译后用IL反编译】
版本一:创建代理的实例,然后赋值
public void Run()
{
DataCallback pCall = new DataCallback(Callback);
SetCallback(pCall);
}
.method public hidebysig instance void Run() cil managed
{
// 代码大小 20 (0x14)
.maxstack 3
.locals init ([0] class ConsoleApplication1.CDllInvoke/DataCallback pCall)
IL_0000: ldarg.0
IL_0001: ldftn instance void ConsoleApplication1.CDllInvoke::Callback(int32)
IL_0007: newobj instance void ConsoleApplication1.CDllInvoke/DataCallback::.ctor(object,
native int)
IL_000c: stloc.0
IL_000d: ldloc.0
IL_000e: call void ConsoleApplication1.CDllInvoke::SetCallback(class ConsoleApplication1.CDllInvoke/DataCallback)
IL_0013: ret
} // end of method CDllInvoke::Run
版本二:直接使用语法糖,设置方法地址
public void Run()
{
SetCallback(Callback);
}
.method public hidebysig instance void Run() cil managed
{
// 代码大小 18 (0x12)
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldftn instance void ConsoleApplication1.CDllInvoke::Callback(int32)
IL_0007: newobj instance void ConsoleApplication1.CDllInvoke/DataCallback::.ctor(object,
native int)
IL_000c: call void ConsoleApplication1.CDllInvoke::SetCallback(class ConsoleApplication1.CDllInvoke/DataCallback)
IL_0011: ret
} // end of method CDllInvoke::Run
事实证明,两个版本的IL代码还是有一些不同的 (不要问我为什么看得懂IL代码,我也是现学现用):
版本一的IL代码中,还是有一个局部变量pCall的存在;而在版本二中,是不存在该局部变量的。
尽管有这个区别,两个版本却都使用了newobj 创建了一个实例,版本一将实例赋值给局部变量,版本二将实例保存在堆栈。
所以, 虽然我前面的推测不太准确,但是区别并不大。
两个版本的程序执行都会发生同样的错误,而出错的直接原因均是局部变量被GC回收。
解决方法
知道原因,解决就不难了,既然是局部变量被回收,那就延长变量的生命周期。
版本三:延长代理实例的生命周期,解决回收的问题
class CDllInvoke
{
const string DllName = "CDllInvoke.dll";
[UnmanagedFunctionPointer(System.Runtime.InteropServices.CallingConvention.StdCall)]
delegate void DataCallback(int nData);
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
static extern void SetCallback(DataCallback pCall);
void Callback(int nData)
{
Console.WriteLine("收到回调:{0}", nData);
}
DataCallback pCall;
public void Run()
{
pCall = new DataCallback(Callback);
SetCallback(pCall);
}
}
.method public hidebysig instance void Run() cil managed
{
// 代码大小 30 (0x1e)
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldarg.0
IL_0002: ldftn instance void ConsoleApplication1.CDllInvoke::Callback(int32)
IL_0008: newobj instance void ConsoleApplication1.CDllInvoke/DataCallback::.ctor(object,
native int)
IL_000d: stfld class ConsoleApplication1.CDllInvoke/DataCallback ConsoleApplication1.CDllInvoke::pCall
IL_0012: ldarg.0
IL_0013: ldfld class ConsoleApplication1.CDllInvoke/DataCallback ConsoleApplication1.CDllInvoke::pCall
IL_0018: call void ConsoleApplication1.CDllInvoke::SetCallback(class ConsoleApplication1.CDllInvoke/DataCallback)
IL_001d: ret
} // end of method CDllInvoke::Run
顺便贴出了最终版本的Run方法IL反汇编代码,各位感受下。