起因
其實這得從好多年前的一個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反彙編代碼,各位感受下。