На переднем крае

Не волнуйся, будь ленивым

Дино Эспозито

Dino EspositoВ разработке ПО термин «ленивое выполнение» относится не столько к лени как таковой, сколько к откладыванию некой дорогостоящей операции до того момента, когда она будет действительно необходима для выполнения определенной задачи. В связи с этим ленивое выполнение является важным шаблоном в разработке ПО и может успешно применяться в самых разнообразных ситуациях, в том числе при проектировании и реализации.

Например, один из фундаментальных принципов кодирования по методологии экстремального программирования можно суммировать просто как утверждение «вам это не понадобится», которое явно приглашает быть ленивым и включать в кодовую базу лишь то, что нужно и только когда оно реально потребуется.

С другой стороны, при реализации класса вы должны проявлять лень, когда дело доходит до загрузки данных из какого-либо источника, доступ к которому связан с большими издержками. Шаблон отложенной загрузки фактически иллюстрирует широко распространенную практику определения некоего члена класса, остающегося пустым до тех пор, пока клиентский код не затребует его содержимое. Отложенная загрузка отлично укладывается в контекст ORM-средств (object-relational mapping), таких как Entity Framework и NHibernate. ORM-средства применяются для преобразования структур данных между объектно-ориентированным миром и миром реляционных баз данных. В этом контексте отложенная загрузка означает способность инфраструктуры загружать, например,
Orders для клиента, только когда какой-то код пытается читать предоставляемое свойство-набор Orders класса Customer.

Однако применение отложенной загрузки не ограничивается специфическими реализациями в области ORM-программирования. Более того, отложенная загрузка — это не просто отказ от получения экземпляра неких данных до того, как в нем возникнет реальная потребность. В механизм отложенной загрузки закладывается специальная логика фабрики, которая отслеживает, что должно быть создано, и автоматически создает это, когда запрашивается соответствующий контент.

В Microsoft .NET Framework любой вариант отложенной загрузки приходилось реализовать в своих классах вручную. В этой инфраструктуре отсутствовал встроенный механизм, который мог бы помочь в решении этой задачи. До появления .NET Framework 4, то есть, с чего можно начать
использовать новый класс Lazy<T>.

Встречайте класс Lazy<T>

Lazy<T> — специальная фабрика, используемая как оболочка объекта заданного типа T. Оболочка Lazy<T> предоставляет активный прокси (live proxy) для еще не существующего экземпляра класса. Использовать такие оболочки стоит по многим причинам, самая важная из которых — повышение производительности. За счет отложенной инициализации объектов удается избежать любых вычислений, в которых нет жесткой необходимости, и поэтому можно сократить использование памяти. При должном применении отложенное создание экземпляров объектов позволяет значительно ускорить запуск приложений. Следующий код показывает, как инициализировать объект в ленивом стиле:

var container = new Lazy<DataContainer>();

В этом примере класс DataContainer указывает объект контейнера данных, который ссылается на массив других объектов. Прямо после вызова оператора new применительно к экземпляру Lazy<T>, вы остаетесь с активным экземпляром класса Lazy<T> и ни в коем случае не получите экземпляр указанного типа T. Если вам надо передать экземпляр DataContainer членам других классов, вы должны изменить сигнатуру этих членов так, чтобы они использовали Lazy<DataContainer>, например:

void ProcessData(Lazy<DataContainer> container);

Когда же создается реальный экземпляр DataContainer, чтобы программа могла работать с необходимыми ей данными? Давайте посмотрим на открытый интерфейс программирования класса Lazy<T>. Этот интерфейс довольно лаконичен, так как включает всего два свойства: Value и IsValue­Created. Свойство Value возвращает текущее значение экземпляра, сопоставленного с Lazy-типом, если таковой есть. Это свойство определено так:

public T Value 
{
  get { ... }
}

Свойство IsValueCreated возвращает булево значение и указывает, был ли создан экземпляр Lazy-типа. Вот фрагмент его исходного кода:

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

Переменная m_boxed является внутренним закрытым и изменяемым (volatile) членом класса Lazy<T>, содержащим реальный экземпляр типа T, если таковой есть. Таким образом, IsValueCreated просто проверяет, существует ли активный экземпляр T, и возвращает ответ в виде булева значения. Как упоминалось, член m_boxed закрытый и изменяемый:

private volatile object m_boxed;

В C# ключевое слово volatile обозначает член, который может быть модифицирован параллельно выполняемым потоком. Это ключевое слово используется для членов, доступных в многопоточной среде, но не имеющих (в основном по соображения производительности) никакой защиты от возможного одновременного обращения нескольких параллельно выполняемых потоков. К тонкостям работы с Lazy<T> в многопоточной среде я вернусь позже. А пока достаточно сказать, что открытые и защищенные члены Lazy<T> по умолчанию безопасны в многопоточной среде. Реальный экземпляр типа T впервые создается, когда любой код пытается обратиться к члену Value. Детали создания объекта зависят от атрибутов, которые можно (но не обязательно) указывать через конструктор 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 не требуется, чтобы избежать исключения из-за null-ссылки при обращении к Value. Следующий код работает совершенно нормально:

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

Аксессор get свойства Value проверяет, существует ли упакованное значение; если нет, он запускает логику, которая создает экземпляр обертываемого типа и возвращает его.

Процесс создания экземпляра

Конечно, если конструктор Lazy-типа (DataContainer в предыдущем примере) генерирует исключение, его обработка возлагается на ваш код. Это исключение — TargetInvocationException — типично при работе с .NET-механизмом отражения, когда ему не удается косвенно создать экземпляр типа.

Логика оболочки Lazy<T> просто обеспечивает создание экземпляра типа T, однако она ни в коем случае не гарантирует, что вы не получите исключение «null-ссылка» при обращении к любому открытому члену T. Рассмотрим следующий фрагмент кода:

public class DataContainer
{
  public DataContainer()
  {
  }

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

Вообразите, что вы пытаетесь вызвать следующий код из клиентской программы:

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

В этом случае вы получите исключение, потому что свойство SomeValues объекта DataContainer равно null, а не потому что сам DataContainer — null. Исключение генерируется из-за того, что конструктор DataContainer не выполнил должную инициализацию всех членов этого объекта; эта ошибка не имеет ничего общего с реализацией подхода с отложенным выполнением.

Свойство Value оболочки Lazy<T> предназначено только для чтения, а значит, после инициализации объект Lazy<T> всегда возвращает один и тот же экземпляр типа T или одно и то же значение, если T является значимым типом. Вы не можете модифицировать экземпляр, но можете обращаться к любым открытым свойствам этого экземпляра.

Вот как можно сконфигурировать объект Lazy<T>, чтобы передавать специальные параметры типу T:

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

Один из конструкторов Lazy<T> принимает делегат, через который вы можете указывать любую операцию, необходимую для создания корректных входных данных для конструктора T. Этот делегат не выполняется, пока не происходит первого обращения к свойству Value обернутого типа T.

Инициализация, безопасная в многопоточной среде

По умолчанию Lazy<T> безопасен в многопоточной среде, т. е. к объекту могут обращаться несколько потоков одновременно и все они получат один и тот же экземпляр
типа T. Давайте рассмотрим некоторые аспекты, связанные с потоками, которые важны лишь при первом обращении к Lazy-объекту.

Первый же поток, который обратится к объекту Lazy<T>, инициирует процесс создания экземпляра для типа T. Все последующие потоки, получающие доступ к Value, будут получать ответ, сгенерированный в результате обращения первого потока. Другими словами, если первый поток вызывает исключение при запуске конструктора типа T, то и все последующие вызовы — независимо от конкретного потока — будут возвращать то же исключение.
Разные потоки не могут получать разные ответы от одного экземпляра Lazy<T>. Именно таково поведение этого класса, если вы выбираете конструктор Lazy<T> по умолчанию.

Однако у класса Lazy<T> есть и дополнительный конструктор:

public Lazy(bool isThreadSafe)

Булев аргумент указывает, нужна ли вам безопасность в многопоточной среде. Как упоминалось, по умолчанию это значение равно true, обеспечивая вышеописанное поведение.

Если вместо этого вы передадите false, свойство Value будет доступно только из одного потока — того, который инициализировал Lazy-тип. Поведение становится неопределенным в случае попытки нескольких потоков получить доступ к свойству Value.

Конструктор Lazy<T>, принимающий булево значение, является особым случаем более универсальной сигнатуры, позволяющей передавать в конструктор
Lazy<T> значение из перечисления LazyThreadSafetyMod. Смысл каждого значения этого перечисления поясняется в рис. 1.

Рис. 1. Перечисление TheLazyThreadSafetyMode

Значение Описание
Нет Экземпляр Lazy<T> небезопасен в многопоточной среде, и его поведение не определяется при доступе из нескольких потоков.
PublicationOnly Тип Lazy могут одновременно пытаться инициализировать несколько потоков. Первый поток обрабатывает wins, а результаты, созданные другими потоками, отбрасываются.
ExecutionAndPublication Используются блокировки для обеспечения того, что только один поток может безопасно инициализировать экземпляр Lazy<T> в многопоточной среде.

Вы можете задать режим PublicationOnly, используя любой из следующих конструкторов:

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

Значения на рис. 1, отличные от PublicationOnly, задаются неявно, когда вы используете конструктор, принимающий булево значение:

public Lazy(bool isThreadSafe)

В этом конструкторе, если аргумент isThreadSafe равен false, выбирается режим работы с потоками None. А если тот же аргумент установлен в true, режим задается как ExecutionAndPublication. Последний режим также действует, когда вы выбираете конструктор по умолчанию.

Режим PublicationOnly занимает место где-то между полной безопасностью потоков, гарантируемой ExecutionAndPublication, и отсутствием оной при выборе None. PublicationOnly позволяет параллельным потокам попытаться создать экземпляр типа T, но успех гарантируется лишь одному из потоков. Экземпляр T, созданный таким образом, потом совместно используется всеми потоками независимо от того, с какими значениями каждый из них мог бы создать этот экземпляр.

Между None, PublicationOnly и ExecutionAndPublication есть интересное различие, касающееся исключения, которое может быть сгенерировано при инициализации. В режиме PublicationOnly исключение, генерируемое при инициализации, не кешируется; соответственно каждый поток, пытающийся считать Value, получает шанс на повторную инициализацию, если экземпляр T недоступен. Другое отличие PublicationOnly от None в том, что исключение в режиме PublicationOnly не генерируется, если конструктор T пытается рекурсивно обращаться к Value. Но в режимах None и ExecutionAndPublication в такой ситуации генерируется исключение InvalidOperation.

Отказ от безопасности в многопоточной среде дает выигрыш в производительности, но нужно быть осторожным, чтобы избежать неприятных ошибок и конкуренции за ресурсы. Поэтому я советую использовать режим LazyThreadSafetyMode, только если вам крайне важна производительность.

Если вы выбираете LazyThreadSafetyMode.None, на вас возлагается ответственность за то, чтобы инициализация экземпляра Lazy<T> никогда не происходила более чем из одного потока. Иначе вы можете получить непредсказуемые результаты. Если при инициализации возникает исключение, оно кешируется и вновь генерируется при каждом последующем обращении к Value в рамках одного потока.

Инициализация ThreadLocal

По своей архитектуре Lazy<T> не позволяет разным потокам управлять собственным экземпляром типа T. Однако, если вам это нужно,
вы должны перейти на другой класс — ThreadLocal<T>. Вот как он используется:

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

Конструктор принимает делегат и с его помощью инициализирует переменную, локальную для потока. Каждый поток хранит собственные данные, совершенно недоступные другим потокам. В отличие от Lazy<T> в ThreadLocal<T> свойство Value предназначено и для чтения, и для записи. Поэтому каждое обращение независимо от последующего и может давать другие результаты, в том числе исключение. Если вы не предоставляете делегат через конструктор ThreadLocal<T>, встраиваемый объект инициализируется значением по умолчанию, предусмотренным для данного типа, в частности значением null, когда T является классом.

Реализация Lazy-свойств

По большей части вы используете Lazy<T> для свойств в своих классах, но вопрос в том, в каких именно? ORM-средства сами обеспечивают отложенную загрузку, поэтому, если вы применяете эти средства, уровень доступа к данным, вероятно, не является той частью приложения, где вы сумеете найти классы, в которых можно было бы разместить «ленивые» свойства. Если вы не пользуетесь ORM-средствами, то уровень доступа к данным — отличное место для свойств с отложенной загрузкой.

Отложенная загрузка может оказаться весьма подходящей и в тех частях приложения, где применяется внедрение зависимостей. В .NET Framework 4 инфраструктура Managed Extensibility Framework (MEF) как раз реализует расширяемость и инверсию управления на основе Lazy<T>. Даже если MEF не используется напрямую, управление зависимостями отлично подходит для «ленивых» свойств.

Реализация свойства с отложенной загрузкой внутри класса не является чем-то неординарным, как можно убедиться на рис. 2.

Рис. 2. Пример «ленивого» свойства

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 и дает вам отличный шанс избегать пустой траты вычислительных мощностей, создавая экземпляры ресурсоемких объектов только при необходимости и лишь в тот момент, когда они начинают использоваться.

Дино Эспозито  - автор книги Programming ASP.NET MVC (Microsoft Press) и соавтор книги Microsoft .NET: Architecting Applications for the Enterprise (Microsoft Press, 2008). Проживает в Италии и часто выступает на отраслевых мероприятиях по всему миру. С ним можно связаться через его блог weblogs.asp.net/despos.

Выражаю благодарность за рецензирование статьи эксперту  Грегу Паперину (Greg Paperin).