天天看點

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反彙編代碼,各位感受下。