共用方式為


本文章是由機器翻譯。

領先技術

別著急,人懶沒問題

Dino Esposito

在軟體發展中,術語延遲指的是盡可能久地推遲特定的高開銷活動的閒置時間。軟體延遲過程中其實也在進行操作,但意味著任何操作僅當需要完成某一特定任務時才會發生。就這一點而言,延遲是軟體發展中的一種重要模式,可以成功地應用於包括設計與實施在內的各種情景中。

例如,極限程式設計方法中的一種基本編碼實踐就被簡單地概括為“您不會需要它”,那就是一種明確的延遲要求 - 當且僅當您需要這些功能時,才需要在基本代碼中包含它。

從另一個角度來看,在實施類的過程中,當要從難以訪問的源中載入資料時,您也可能需要延遲。事實上,延遲載入模式解釋了這種普遍接受的解決方案,即定義一個類成員,但使其保持為空,直到其他某些用戶端代碼實際需要其內容時為止。延遲載入完全適合在物件關係映射 (ORM) 工具(如實體框架和 NHibernate)環境中使用。ORM 工具用於映射物件導向的環境與關聯式資料庫之間的資料結構。例如,在這種環境中,延遲載入指的就是僅當某些代碼嘗試讀取 Customer 類上公開的 Orders 集合屬性時,
框架才能載入 Customer 的 Orders。

但是,延遲載入並不限於特定的實施方案(如 ORM 程式設計)。而且,延遲載入指的就是在某些資料實際可用之前不獲取該資料的實例。換言之,延遲載入就是要有特殊工廠邏輯,即跟蹤必須要創建的內容,最後在實際請求該內容時以靜默方式創建該內容。

在 Microsoft .NET Framework 中,開發人員早就在我們的類中手動實施了所有延遲行為。在 .NET Framework 4 問世之前,從未有過內置的機制來説明完成此任務。在 .NET Framework 4 中,我們可以開始
使用全新的 Lazy<T>類。

瞭解 Lazy<T>類

Lazy<T>是一個特殊的工廠,您可以用來包裝給定 T 類型的物件。Lazy<T>包裝代表一個尚不存在的類實例的即時代理。使用 Lazy 包裝的理由有很多,其中最重要的莫過於可以提高性能。延遲初始化物件可以避免所有不必要的計算,從而減少記憶體消耗。如果加以合理利用,延遲初始化物件也可以成為一種加快應用程式啟動的強大工具。以下代碼說明了以延遲方式初始化物件的方法:

var container = new Lazy<DataContainer>();

在本例中,DataContainer 類表示的是一個引用了其他物件陣列的純資料容器物件。 在剛剛對 Lazy<T>實例調用完 new 運算子之後,返回的只是一個即時的 Lazy<T>類實例;無論如何都不會得到指定類型 T 的實例。 如果您需要向其他類的成員傳遞一個 DataContainer 實例,則必須更改這些成員的簽名才能使用 Lazy<DataContainer>,如下所示:

void ProcessData(Lazy<DataContainer> container);

何時創建 DataContainer 的實際實例,以便程式可以處理其所需的資料? 讓我們來看看 Lazy<T>類的公共程式設計介面。 該公共介面非常小,因為它只包含兩個屬性:Value 和 IsValueCreated。 如果存在與 Lazy 類型關聯的實例,則屬性 Value 就會返回該實例的當前值。 該屬性的定義如下:

public T Value 
{
  get { ... }
}

屬性 IsValueCreated 可以返回一個 Boolean 值,表示 Lazy 類型是否已經過產生實體。 以下是該屬性的原始程式碼中的一段摘錄:

public bool IsValueCreated
{
  get
  {
    return ((m_boxed != null) && (m_boxed is Boxed<T>));
  }
}

如果 Lazy<T>類包含 T 類型的實際實例(如果有),則 m_boxed 成員就是該類的一個內部私有的不穩定成員。 因此,IsValueCreated 只需檢查是否存在 T 的即時實例,然後返回一個 Boolean 答案。 如前文所述,m_boxed 成員是私有的並且不穩定(如以下程式碼片段所示):

private volatile object m_boxed;

在 C# 中,volatile 關鍵字表示成員可以被併發運行的執行緒修改。 volatile 關鍵字用於下麵這樣的成員:這類成員可以在多執行緒環境中使用,但無法防止多個可能的併發執行緒同時對其進行訪問(本意是出於性能因素考慮)。 我們稍後再回到 Lazy<T>的執行緒方面上來。 目前,可以肯定地說,預設情況下 Lazy<T>的公共成員和受保護成員是執行緒安全的。 當有任意代碼首次嘗試訪問 Value 成員時,就會創建類型 T 的實際實例。 物件創建方面的詳細資訊取決於各種執行緒屬性,這些屬性可以通過 Lazy<T>構造函數來指定。 應該明確的是,執行緒模式的含義僅當 boxed 值實際上已初始化或首次被訪問時才很重要。

預設情況下,類型 T 的實例是通過調用 Activator.CreateInstance 進行反射獲取的。 以下是一個典型的與 Lazy<T>類型進行交互的簡單示例:

var temp = new Lazy<DataContainer>();
Console.WriteLine(temp.IsValueCreated);
Console.WriteLine(temp.Value.SomeValue);

請注意,在調用 Value 之前,並不一定要對 IsValueCreated 進行檢查。 通常情況下,僅當(無論出於何種原因)您需要瞭解某個值當前是否與 Lazy 類型關聯時,才必須查看 IsValueCreated 的值。 您無需檢查 IsValueCreated 即可避免發生對 Value 的空引用異常。 以下代碼即可保證正常運行:

var temp = new Lazy<DataContainer>();
Console.WriteLine(temp.Value.SomeValue);

Value 屬性的 getter 會檢查 boxed 值是否已經存在;如果不存在,則會觸發邏輯創建一個包裝類型實例,並返回該實例。

產生實體過程

當然,當該 Lazy 類型(上例中的 DataContainer)的構造函數引發異常時,您的代碼會負責處理該異常。 所捕獲異常屬於 TargetInvocationException 類型,該異常是 .NET 反射無法間接創建某類型實例時收到的典型異常。

Lazy<T>包裝邏輯只能確定是否已創建類型 T 的實例,並不能保證您在訪問 T 上的任意公共成員時都不會收到空引用異常。 以下麵的程式碼片段為例:

public class DataContainer
{
  public DataContainer()
  {
  }

  public IList<String> SomeValues { get; set; }
}

現在假設您嘗試從用戶端程式調用以下代碼:

var temp = new Lazy<DataContainer>();
Console.WriteLine(temp.Value.SomeValues.Count);

在這種情況下,您將收到一個異常,這是因為 DataContainer 物件的 SomeValues 屬性為空,而非 DataContainer 本身為空。 引發該異常是因為 DataContainer 的構造函數沒有正常初始化其所有成員;該錯誤與 lazy 方法的實施無關。

Lazy<T>的 Value 屬性為唯讀屬性,即一旦經過初始化,Lazy<T>物件將始終返回同一個類型 T 實例或同一個值(當 T 為數值型別時)。 您無法修改實例,但可以訪問該實例可能擁有的所有公共屬性。

以下是配置 Lazy<T>物件向 T 類型傳遞臨時參數的方法:

temp = new Lazy<DataContainer>(() => new Orders(10));

其中一個 Lazy<T>構造函數會接受一個委託,您可以通過該委託指定為 T 構造函數產生正確輸入資料所需的任何操作。 在首次訪問包裝的 T 類型的 Value 屬性之前,不會運行該委託。

執行緒安全初始化

預設情況下,Lazy<T>是執行緒安全的,即多個執行緒可以訪問一個實例,並且所有線程都會收到同一個
T 類型的實例。 讓我們來看看執行緒方面的內容,執行緒僅在首次訪問 Lazy 物件時很重要。

第一個訪問 Lazy<T>物件的執行緒將觸發類型 T 的初始化過程。 所有後續獲得 Value 的存取權限的執行緒都會收到第一個執行緒(無論什麼執行緒)生成的回應。 換言之,如果第一個執行緒在調用類型 T 的構造函數時引發了異常,則所有後續調用(無論什麼執行緒)都會收到同樣的異常。
按照設計,不同的執行緒無法從同一個 Lazy<T>實例獲得不同的回應。 這是您選擇預設的 Lazy<T>構造函數時獲得的行為。

但是,Lazy<T>類也可以運行另一個構造函數:

public Lazy(bool isThreadSafe)

Boolean 參數表示您是否需要執行緒安全。 如前文所述,預設值為 true,就表示可以提供上述行為。

但是,如果您傳遞的是 false,則將只從一個執行緒(初始化該 Lazy 類型的執行緒)訪問 Value 屬性。 未定義當有多個執行緒嘗試訪問 Value 屬性時的行為。

接受 Boolean 值的 Lazy<T>構造函數是一種更常見簽名的特殊情況,在這種情況下,您要通過 LazyThreadSafetyMode 枚舉向 Lazy<T>
構造函數傳遞值。 图 1 說明了該枚舉中每個值的作用。

圖 1 LazyThreadSafetyMode 枚舉

描述
Lazy<T>實例不是執行緒安全的,並且未定義當從多個執行緒訪問該實例時的行為。
PublicationOnly 允許多個執行緒同時嘗試初始化 Lazy 類型。 第一個完成的執行緒是獲勝者,所有其他執行緒生成的結果都將被丟棄。
ExecutionAndPublication 為了確保只有一個執行緒能夠以執行緒安全的方式初始化 Lazy<T>實例而使用了鎖。

您可以使用以下任一構造函數來設置 PublicationOnly 模式:

public Lazy(LazyThreadSafetyMode mode)
public Lazy<T>(Func<T>, LazyThreadSafetyMode mode)

图 1 中除 PublicationOnly 以外的值都是在使用接受 Boolean 值的構造函數時隱式設置的:

public Lazy(bool isThreadSafe)

在該構造函數中,如果參數 isThreadSafe 為 false,則選定的執行緒模式為 None。 如果參數 isThreadSafe 設置為 true,則執行緒模式設置為 ExecutionAndPublication。 ExecutionAndPublication 也是您選擇預設構造函數時的工作模式。

使用 ExecutionAndPublication 時可以保證完全線程安全,使用 None 時缺乏執行緒安全,而使用 PublicationOnly 模式則介於二者之間。 PublicationOnly 允許多個併發執行緒嘗試創建類型 T 實例,但只允許一個執行緒是獲勝者。 獲勝者創建的 T 實例隨後會在所有其他執行緒(無論每個執行緒計算的實例如何)之間共用。

就初始化過程中可能引發異常方面,None 和 ExecutionAndPublication 之間有一個很有趣的區別。 當設置為 PublicationOnly 且初始化過程中產生的異常未寫入緩存時,如果 T 實例不可用,則嘗試讀取 Value 的每個後續執行緒都有機會重新初始化該實例。 PublicationOnly 和 None 之間的另一個區別是,當 T 的構造函數嘗試遞迴訪問 Value 時,PublicationOnly 模式中不會引發任何異常。 當 Lazy<T>類以 None 或 ExecutionAndPublication 模式工作時,該情況會引發 InvalidOperation 異常。

放棄執行緒安全可以獲得原有的性能優勢,但要注意防止出現令人討厭的 Bug 和爭用情況。 因此,建議您僅當性能極為關鍵時才使用 LazyThreadSafetyMode.None 選項。

使用 LazyThreadSafetyMode.None 時,您需要負責確保絕不會發生從多個執行緒對 Lazy<T>實例進行初始化的情況。 否則,可能會產生不可預料的結果。 如果初始化過程中引發異常,則對於該執行緒,對 Value 的所有後續訪問都會緩存和引發相同的異常。

ThreadLocal 初始化

按照設計,Lazy<T>禁止不同的執行緒管理其各自的類型 T 實例。 但是,如果您希望允許該行為,
您必須選擇其他類(ThreadLocal<T>類型)。 以下是該類的使用方法:

var counter = new ThreadLocal<Int32>(() => 1);

構造函數會接受一個委託,並使用該委託來初始化 thread-local 變數。 每個執行緒都會保留自己的資料,其他執行緒完全無法訪問該資料。 與 Lazy<T>不同,ThreadLocal<T>上的 Value 屬性是可讀寫的。 因此,每個訪問與下一個訪問之間是獨立的,可能產生包括引發(或不引發)異常在內的不同結果。 如果您未通過 ThreadLocal<T>構造函數提供操作委託,則嵌入的物件將使用該類型的預設值 null(當 T 為一個類時)進行初始化。

實現 Lazy 屬性

大多數情況下,您要使用 Lazy<T>作為您自己的類中的屬性,但到底是哪些類中要使用它呢? ORM 工具本身提供了延遲載入功能,因此如果您使用的是這些工具,在資料訪問層所在的應用程式片段中很可能找不到可能承載 lazy 屬性的候選類。 如果您使用的不是 ORM 工具,則資料訪問層肯定非常適合 lazy 屬性。

可以在其中使用依賴關係注入的應用程式片段也可能非常適合延遲。 在 .NET Framework 4 中,託管可擴展性框架 (MEF) 只使用 Lazy<T>來實現控制項的可擴展性和反轉。 即使您不是直接使用 MEF,依賴關係的管理也非常適合 lazy 屬性。

在類中實現 lazy 屬性並不困難,如圖 2 所示。

圖 2 Lazy 屬性示例

public class Customer
{
   private readonly Lazy<IList<Order>> orders;

   public Customer(String id)
   {
      orders = new Lazy<IList<Order>>( () =>
      {
         return new List<Order>();
      }
      );
   }

   public IList<Order> Orders
   {
      get
      {
         // Orders is created on first access
         return orders.Value;
      }
   }
}

補充說明

總而言之,延遲載入是一個抽象的概念,指的是僅當真正需要資料時才載入資料。 在 .NET Framework 4 問世之前,開發人員需要自己開發延遲初始化邏輯。 Lazy<T>類擴展了 .NET Framework 程式設計工具包,可讓您在當且僅當嚴格需要高開銷物件時,才在恰好開始使用這些物件之前對這些物件進行產生實體,從而避免浪費計算資源。

Dino Esposito  是 Microsoft Press 出版的《Programming ASP.NET MVC》一書的作者,也是 《Microsoft .NET:Architecting Applications for the Enterprise》(Microsoft Press, 2008) 一書的合著者。Esposito 定居於義大利,經常在世界各地的業內活動中發表演講。您可訪問他的博客,網址為 weblogs.asp.net/despos

衷心感謝以下技術專家對本文的審閱: Greg Paperin