C#互操作之GC回收陷阱

起因

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

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章