《Effective C#》筆記(2) - .NET的資源管理

語言: CN / TW / HK

理解並善用.NET的資源管理機制

.NET環境會提供垃圾回收器(GC)來幫助控制託管記憶體,這使得開發者無須擔心記憶體洩漏等記憶體管理問題。儘管如此,但如果開發者能夠把自己應該執行的那些清理工作做好,那麼垃圾回收器會表現得更為出色。非託管的資源是需要由開發者控制的,例如資料庫連線、GDI+物件、IO等;此外,某些做法可能會令物件在記憶體中所待的時間比你預想的更長,這些都是需要我們去了解、避免的。

GC的檢測過程是從應用程式的根物件出發,把與該物件之間沒有通路相連的那些物件判定為不可達的物件,也就是說,凡是無法從應用程式中的活動物件(live object)出發而到達的那些物件都應該得到回收。應用程式如果不再使用某個實體,那麼就不會繼續引用它,於是,GC就會發現這個實體是可以回收的。 垃圾回收器每次執行的時候,都會壓縮託管堆,以便把其中的活動物件安排在一起,使得空閒的記憶體能夠形成一塊連續的區域。

針對託管堆的記憶體管理工作完全是由垃圾回收器負責的,但是除此之外的其他資源則必須由開發者來管理。 有兩種機制可以控制非託管資源的生存期

  • 一種是finalizer/destructure(解構函式)
  • 另一種是IDisposable介面。

在這兩種方式中,應該優先考慮通過IDisposable介面來更為順暢地將資源及時返還給系統,因為finalizer作為一種防護機制,雖然可以確保物件總是能夠把非託管資源釋放掉,但這種機制有一些缺陷

  • 首先,C#的finalizer執行得並不及時。當垃圾回收器把物件判定為垃圾之後,它會擇機呼叫該物件的finalizer,但開發者並不知道具體的時機,因此,finalizer只能保證由某個型別的物件所分配的非託管資源最終可以得到釋放,但並不保證這些資源能夠在確定的時間點上得到釋放,因此,設計與編寫程式的時候,儘量不要建立finalizer,即便建立了,也不要過多地依賴於它的執行時機。

  • 另外,依賴finalizer還會降低程式的效能,因為垃圾回收器需要執行更多的工作才能終結這些物件。如果GC發現某個物件已經成為垃圾,但該物件還有finalizer需要執行,那麼就無法立刻把它從記憶體中移走,而是要等呼叫完finalizer之後,才能將其移除。呼叫finalizer的那個執行緒並不是GC所在的執行緒。GC在每一個週期裡面會把包含finalizier但是尚未執行的那些物件放在佇列中,以便安排其finalizer的執行工作,而不含finalizer的物件則會直接從記憶體中清理掉。等到下一個週期,GC才會把已經執行了finalizer的那些物件刪掉。

宣告欄位時,儘量直接為其設定初始值

類的建構函式有時不止一個,如果某個成員變數的初始化在建構函式進行,就會有忘記給某些成員變數設定初始值的可能性。為了徹底杜絕這種情況,無論是靜態變數還是例項變數,最好都在宣告的時候直接初始化,而不要等實現每個建構函式的時候再去賦值。

表面上看,在建構函式初始化和在宣告的時候直接初始化等效,但實際上如果選擇在宣告的時候直接初始化,編譯器會把由這些語句所生成的程式碼放在類的建構函式之前。這些語句的執行時機比基類的建構函式更早,它們會按照本類宣告相關變數的先後順序來執行。

但也並不是說,如何時候都優先在宣告的時候直接初始化,在下面三種情況下,宣告的時候直接初始化是不建議的,甚至會帶來問題:

  1. 把物件初始化為0或null。系統在執行開發者所編寫的程式碼之前,本身就會生成初始化邏輯,以便把相關的內容全都設定成0,這是通過底層CPU指令來做的。這些指令會把整塊記憶體全都設定成0,因此,你如果還要編寫初始化語句,讓編譯器會新增相關指令,把那些記憶體再度清零,那就顯得多餘了。

  2. 如果不同的建構函式需要按照各自的方式來設定某個欄位的初始值,那麼就不應該再在宣告的時候初始化了,因為它只適用於那些總是按相同方式來初始化的變數。 就類似這樣的寫法:

public class MyClass
{
  private List<string> labels = new List<string>();
  
  public MyClass(int size)
  {
    labels = new List<string>(size);
  }
}

這會在構造類例項的過程中創建出兩個不同的List物件,而且先創建出來的那個List馬上就會被後建立的List取代,實際上等於是白建立了一次。這是因為欄位的初始化語句會先於建構函式而執行,於是,程式在初始化labels欄位時,會根據其初始化語句的要求創建出一個List,然後,等到執行建構函式時,又會根據其中的賦值語句創建出另一個List,並導致前一個List失效。 編譯器所生成的程式碼相當於下面這樣:

public class MyClass
{
  private List<string> labels;
  
  public MyClass(int size)
  {
    labels = new List<string>();
    labels = new List<string>(size);
  }
}
  1. 如果初始化變數的過程中有可能出現異常,那麼就不應該使用初始化語句,而是應該把這部分邏輯移動到建構函式裡面。由於成員變數的初始化語句不能包裹在try-catch塊中,因此初始化的過程中一旦發生異常,就會傳播到物件之外,從而令開發者無法在類裡面加以處理,應該把這種初始化程式碼放在建構函式中,以便通過適當的程式碼將異常處理好。

用適當的方式初始化類中的靜態成員

通過靜態初始化語句或者靜態建構函式都可以初始化類中的靜態成員。如果只需給靜態成員分配記憶體即可將其初始化,那麼用一條簡單的初始化語句就足夠了,反之,若是必須通過複雜的邏輯才能完成初始化,則應考慮建立靜態建構函式。 靜態初始化語句與例項欄位的初始化語句一樣,靜態欄位的初始化語句也會先於靜態建構函式而執行,並且有可能比基類的靜態建構函式執行得更早。如果靜態欄位的初始化工作比較複雜或是開銷比較大,那麼可以考慮運用Lazy<T>機制,將初始化工作推遲到首次訪問該欄位的時候再去執行。

靜態建構函式是特殊的函式,會在初次訪問該類所定義的其他方法、變數或屬性之前執行,可以用來初始化靜態變數、實現單例(singleton)模式,或是執行其他一些必要的工作,以便使該類能夠正常運作。 當程式碼初次訪問應用程式空間(application space,也就是AppDomain)裡面的某個型別之前,CLR會自動呼叫該類的靜態建構函式。這種建構函式每個類只能定義一個,而且不能帶有引數。

由於靜態建構函式是由CLR自動呼叫的,因此必須謹慎處理其中的異常。如果異常跑到了靜態建構函式外面,那麼CLR就會丟擲TypeInitialization-Exception以終止該程式。呼叫方如果想要捕獲這個異常,那麼情況將會更加微妙,因為只要AppDomain還沒有解除安裝,這個型別就一直無法建立,也就是說,CLR根本就不會再次執行其靜態建構函式,這導致該型別無法正確地加以初始化,並導致該類及其派生類的物件也無法獲得適當的定義。因此,不要令異常脫出靜態建構函式的範圍。

不要建立無謂的物件

雖然垃圾回收器能夠有效地管理應用程式所使用的記憶體,但在堆上建立並銷燬物件仍需耗費一定的時間,因此應儘量避免過多地建立物件,也不要建立那些根本不用去重新構建的物件。此外,在函式中以區域性變數的形式頻繁建立引用型別的物件也是不合適的,應該把這些變數提升為成員變數,或是考慮把最常用的那幾個例項設定成相關型別中的靜態物件。

絕對不要在建構函式裡面呼叫虛擬函式

這裡有個建構函式裡面呼叫虛擬函式的demo,執行後打印出的結果是"VFunc in B",還是"VFunc in B1",還是"Msg from main"?答案是"VFunc in B1"。

public class B
{
  protected B()
  {
    VFunc();
  }

  protected virtual void VFunc()
  {
    Console.WriteLine("VFunc in B");
  }
}

public class B1 : B
{
    private readonly string msg = "VFunc in B1";

    public B1(string msg)
    {
      this.msg = msg;
    }

    protected override void VFunc()
    {
      Console.WriteLine(msg);
    }

    public static void Init()
    {
      _ = new B1("Msg from main");
    }
}

為什麼會這樣呢,這要從構建某個型別的首個例項時系統所執行的操作說起,步驟如下:

  1. 把存放靜態變數的空間清零。
  2. 執行靜態變數的初始化語句。
  3. 執行基類的靜態建構函式。
  4. 執行本類的靜態建構函式。
  5. 把存放例項變數的空間清零。
  6. 執行例項變數的初始化語句。
  7. 適當地執行基類的例項建構函式。
  8. 執行本類的例項建構函式。

所以會先初始化B1.msg,然後執行基類B的建構函式。基類的建構函式呼叫了一個定義在本類中但是為派生類所重寫的虛擬函式VFunc,於是程式在執行的時候呼叫的就是派生類的版本,因為物件的執行期型別是B1,而不是B。在C#語言中,系統會認為這個物件是一個可以正常使用的物件,因為程式在進入建構函式的函式體之前,已經把該物件的所有成員變數全都初始化好了。儘管如此,但這並不意味著這些成員變數的值與開發者最終想要的結果相符,因為程式僅僅執行了成員變數的初始化語句,而尚未執行建構函式中與這些變數有關的邏輯。

在構建物件的過程中呼叫虛擬函式有可能令程式中的資料混亂,也會讓基類的程式碼嚴重依賴於派生類的實現細節,而這些細節是無法控制的,這種做法很容易出問題。所以應該避免這樣做。

實現標準的dispose模式

dispose模式用於對非託管資源進行釋放,託管資源是指受GC管理的記憶體資源,而非託管資源與之相對,則不受GC的管理,當使用完非託管資源後,必須顯式釋放它們。 最常用的非託管資源型別是包裝作業系統資源的物件,如檔案、視窗、網路連線或資料庫連線。 雖然垃圾回收器可以跟蹤封裝非託管資源的物件的生存期,但無法瞭解如何釋出並清理這些非託管資源。 比如System.IO.File中的FileStream,它屬於.NET的類被GC管理,但它的內部又依賴了作業系統提供的API,因此可以看作是一個Wrapper, 因此要實現dispose模式,在自身被GC銷燬的時候,釋放檔案控制代碼。

標準的dispose(釋放/處置)模式既會實現IDisposable介面,又會提供finalizer,以便在客戶端忘記呼叫IDisposable.Dispose()的情況下也可以釋放資源。

在類的繼承體系中,位於根部的那個基類應該做到以下幾點:

  • 實現IDisposable介面,以便釋放資源。
  • 如果本身含有非託管資源,那就新增finalizer,以防客戶端忘記呼叫Dispose()方法。若是沒有非託管資源,則不用新增finalizer。
  • Dispose方法與finalizer(如果有的話)都把釋放資源的工作委派給虛方法,使得子類能夠重寫該方法,以釋放它們自己的資源。

繼承體系中的子類應該做到以下幾點:

  • 如果子類有自己的資源需要釋放,那就重寫由基類所定義的那個虛方法,如果沒有則不必重寫。
  • 如果子類自身的某個成員欄位表示的是非託管資源,那麼就實現finalizer,否則就不必實現。
  • 記得呼叫基類的同名函式。

下面兩個類UnManaged與MyUnManaged作為非託管資源的示例,假設UnManaged類中直接使用了非託管資源:

public class UnManaged : IDisposable
{
  private bool alreadyDisposed;

  public void Dispose()
  {
    Dispose(true);
    GC.SuppressFinalize(this);
  }

  protected virtual void Dispose(bool isDisposing)
  {
    if (alreadyDisposed)
      return;
    if (isDisposing)
    {
      // free managed resource here
    }

    // free unmanaged resource here
    alreadyDisposed = true;
  }

  public void ExampleMethod()
  {
    if (alreadyDisposed)
      throw new ObjectDisposedException(nameof(UnManaged), "Call methods on disposed object");

    // do something
  }

  ~UnManaged()
  {
    Dispose(false);
  }
}

public class MyUnManaged : UnManaged
{
  private bool alreadyDisposedInDerived;

  protected override void Dispose(bool isDisposing)
  {
    if (alreadyDisposedInDerived)
      return;
    if (isDisposing)
    {
      // free managed resource here
    }

    // free unmanaged resource here

    base.Dispose(isDisposing); // call base.Disposes

    alreadyDisposedInDerived = true;
  }
}

UnManaged直接使用了非託管資源,因此需要解構函式。雖然前面提到存在解構函式的物件不會被GC立即回收,但作為一種防範機制是必須的,如果使用者忘呼叫Dispose,finalizer仍然確保非託管資源可以得到釋放。儘管程式效能或許會因此而有所下降,但只要客戶程式碼能夠平常呼叫Dispose方法,就不會有這個問題。Dispose方法中通過GC.SuppressFinalize(this)來通知GC不必再執行finalizer。

實現IDisposable.Dispose()方法時,要注意以下四點:

  1. 把非託管資源全都釋放掉。
  2. 把託管資源全都釋放掉(這也包括不再訂閱早前關注的那些事件)。
  3. 設定相關的狀態標誌,用以表示該物件已經清理過了。如果物件已經清理過了之後還有人要訪問其中的公有成員,那麼你可以通過此標誌得知這一狀況,從而令這些操作丟擲ObjectDisposedException。
  4. 阻止垃圾回收器重複清理該物件。這可以通過GC.SuppressFinalize(this)來完成。

但finalizer中執行的操作與Dispose有所區別,它只應釋放非託管資源,因此為了程式碼複用,添加了Dispose的過載方法protected virtual void Dispose(bool isDisposing),它宣告為protected virtual,可以被子類重寫。被IDisposable.Dispose()方法呼叫時,isDisposing引數是true,那麼應該同時清理託管資源與非託管資源,finalizer中呼叫時isDisposing為false,則只應清理非託管資源。

還有另外一些注意事項:

  • 基類與子類物件採用獨立的disposed標誌來表示其資源是否得到釋放,這麼寫是為了防止出錯。假如共用同一個標誌,那麼子類就有可能在釋放自己的資源時率先把該標誌設定成true,而等到基類執行Dispose(bool)方法時,則會誤以為其資源已經釋放過了。
  • Dispose(bool)與finalizer都必須編寫得很可靠,也就是要具備冪等(idempotent)的性質,這意味著多次呼叫Dispose(bool)的效果與只調用一次的效果應該是完全相同的。
  • 在編寫Dispose或finalizer等資源清理的方法時,只應該釋放資源,而不應該做其他的處理,否則極有可能導致記憶體洩漏等問題。

參考書籍

《Effective C#:改善C#程式碼的50個有效方法(原書第3版)》 比爾·瓦格納

分享到: