close

      寫C#程式也有一段時間了,算一算現在寫的這個程式已是第四個用C#寫的較有規模的程式。一直以來因為懶惰和玩不完的電動,都沒有好好去研究C#中資源釋放的問題,尤其後來這三次寫的程式都需要用到以C++寫成的Library(以C++/CLI寫一個Wrapper Class來包裝),有二次要用到Managed DirectX,對於各種資源的管理更顯得重要。所以這一次就稍微認真了點,去搜尋了一下C#資源釋放的相關文章來做個統整。

      剛接觸C#沒多久的人,通常對於GC(Garbage Collector)都會跟我有一樣的茫然,「GC什麼時候會回收資源?」、「哪些東西會被GC回收?」、「太倚賴GC是不是會造成程式效率低落?」,以下就從基礎開始談起GC的運作機制。

 


 


        在執行.NET的程式時,對於不同型別,CLR(Common Language Runtime)會在不同的地方分配資源空間。對於實值型別(Value Type),當宣告一個變數時,CLR會在Stack(堆疊)中配置一塊空間,設定該變數的值時,其值也直接存放於該空間中,如下圖:

        而對於參考型別(Reference Type),CLR則在Stack中配置一塊存放記憶體位址的空間,在初始化該型別的實體時(ex: new),則在Heap(堆積)上配置該型別所需的空間,再將該空間的位址傳回給存放在Stack中的那塊空間,如下圖:

      這些由CLR自動配置與管理的記憶體,被稱為Managed資源;反之,不受CLR管理的便被稱為Unmanaged資源(ex:  Stream、與資料庫的連結、COM物件……等)。

      而對於資源的釋放,CLR則主要以GC來回收已用不到的記憶體空間,這些空間便被稱為garbage。(上述的記憶體配置及GC的運作可參考MSDN: CLR的自動記憶體管理)

      那麼garbage如何產生或被判定呢?
 
      基本的原則是當該變數「不再有效」時,便會被視為garbage,具體的情況則包括超出該變數的有效範圍(ex:離開了對應的大括號的區域變數)、 將變數指定為null、重新指向其他物件(而原先指向的物件已無法被取得)、重新初始化…等,這時原先變數佔有的空間都會被CLR視為garbage而等待回收。

      若變數為數值型別,則當其超出有效範圍時,CLR會直接回收它在Stack上所佔用的空間;若變數為參考型別,則CLR會先回收它在Stack上佔用的空間,而將Heap上的空間視為garbage,等待GC回收。若參考型別的變數在其有效範圍內重新初始化,則原先所指向的物件亦會被視為garbage。

      然而,被視為garbage的變數,並不是馬上就被GC回收,而是根據GC內的演算法,依變數被判定的generation而有不同。在Managed Heap上的物件會被CLR分為三個generation:0、1、2,數字越大表示存活時間越長。這樣的設計乃是建構在「只壓縮部份的Heap會比一次壓縮整個Heap來得有效率」的事實上,因此將物件分成不同的層級,在回收時針對不同層級做回收,是.NET Framework考量效率後所採用的方法。

      CLR會將新建立的物件擺在第0個generation,當GC進行gen 0的回收時,會將仍為有效的gen 0物件提昇至gen 1。而若gen 0的空間回收後仍不足以建立新物件,則GC會繼續檢查gen 1的物件,存留下來的gen 1物件會被提昇至gen 2(最高只到gen 2,故若gen 2被檢查後仍存活,只會停留在gen 2)。進行gen 1的回收時,也會進行gen 0回收;同理進行gen 2回收時,也會進行gen 1、gen 0的回收。在這樣的設計下,程式中常會應用到的區域物件與暫時物件因都屬於gen 0,故可確保被回收的頻率最高,對一般不會用到大量暫時物件的程式而言,不需擔心資源的浪費。

     至目前為止,我們了解了Managed資源的回收機制,但還有至少二個問題殘留著:
    「若是程式員希望在某個時間點確保Managed資源被回收,應該怎麼做?」
    「若是類別中含有Unmanaged資源,又該如何釋放?」

     對於這二個問題的回答,在這裡便開始要提到程式員如何撰寫明確釋放資源的函式了。在C++中,程式員撰寫類別的Destructor來達成類別資源的釋放,而在C#中,同樣有類似效果的函式有Finalize()Dispose() ,而這二個函式也常令許多C#的初學者混淆,以下便解釋二者的差別:

     前面提到Managed資源會自動被GC回收,但若類別中含有Unmanaged資源,則程式員除了撰寫該Unmanaged資源本身的釋放函式(如Destructor)之外,又該在何處確保這些釋放函式會被呼叫呢?答案就是C#類別的Finalize()函式中。然而,因Finalize()本身的存取層級是protected,所以在實作上,對於類別T,程式員是撰寫名為  「~T()」的函式(如同C++中的解構函式語法),該函式經過編譯後,編譯器便會產生類別T的Finalize()方法,並將函式的內容包在try 區塊中。

     public class T
     {
           Unmgd un;

           ~T()
           {
                   un.Release(); //假設Unmgd的釋放資源函式為Release();亦可呼叫~Unmgd()
           }
     }

     然而,Finalize()的呼叫是不明確的,因為它只被GC給呼叫,亦即程式員只能預期GC在進行某個T的實體的回收時,會呼叫T.Finalize(),卻無法在程式員自己想要的特定位置呼叫Finalize()。另一方面,Finalize()也並非在GC對該物件進行第一次回收時便被呼叫,事實上,GC的第一次回收,僅是將該物件的參考移至FReachable Queue中,標示為不再使用,等到GC下一次的回收時,Finalize()才真正地被呼叫。從這些事實來看,程式員沒有辦法預期Finalize()究竟會在程式中的何處被呼叫,只能被動地等待GC去呼叫它。因此,為了讓程式員能明確執行Unmanaged資源釋放的工作,C#提供了另一個函式Dispose()。

    Dispose()與Finalize()最大的不同,在於Dispose()是明確地在「程式員呼叫」與「using語句區塊結束時」二種情況下被呼叫的。也就是說,當程式員想確保某個類別T中的Unamaged資源會在某個特定位置被釋放掉時,他便可以實作IDisposable介面中的Dispose()函式;而為了提供程式員在操作常用的暫時物件上不會忘記呼叫Dispose(),C#中提供了using陳述式,確保在小括弧中的變數,在離開using區塊時,其Dispose()會被明確地呼叫。

    using(T t = new T())
    {
         .........
    }  //<-此時T.Dispose()會被呼叫

    當一個物件的Dispose()被呼叫後,亦即標示了這個物件為無效,則在第一次的GC回收行程中便會被回收到。值得一提的是,即使某個物件的Dispose()已經被呼叫過了,該物件的Finalize()仍然有可能再被GC給呼叫到,這是為了避免當Dispose()失敗時可能產生的資源浪費。然而,若Dispose()成功地執行了,則應該避免再讓GC去呼叫Finalize(),否則會造成程式效率的低落。因此在撰寫Dispose()函式時,必須記得在裡面加上一行GC.SuppressFinalize(this); 這可以告訴GC不需再去呼叫這個物件的Finalize()。

    關於Dispose()的撰寫,我會再寫一篇文章來整理(因為這篇從過年前擺著到現在了,先早點po出來)。另外也會整理一篇有關C#程式要調用以C++寫成的函式庫時的幾種方法(尤其是Wrapper Class,有一些細節還是一直困惑著我,整理出來會弄得比較清楚)。

    上面寫的若有錯誤還請指正或補充,謝謝



參考資料: 
.NET Framework記憶體回收機制                  
C#資源釋放

arrow
arrow
    全站熱搜

    sedc 發表在 痞客邦 留言(21) 人氣()