领先技术

别着急,不要怕延迟

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 枚举

说明
None 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