понедельник, 19 декабря 2016 г.

Реализация синглтонов в .NET: Field-like vs. Lazy

Недавно один из читателей задал вопрос по поводу разницы между двумя реализациями синглтонов: через обычное статическое поле или же через статическое поле, содержащее Lazy<T>:

public class FieldLikeSingleton
{
    // Вариант C# 6
    public static FieldLikeSingleton Instance { get; } = new FieldLikeSingleton();

   
private FieldLikeSingleton() 
{}
}


public class FieldLikeLazySingleton
{
    private static readonly Lazy<FieldLikeLazySingleton> _instance =
 
       
new Lazy<FieldLikeLazySingleton>(() => new FieldLikeLazySingleton());

   
public static FieldLikeLazySingleton Instance => _instance.Value;

   
private FieldLikeLazySingleton() {}
}

Для простоты, первую реализацию я буду называть field-like реализацией (*), а вторую – ленивой.

 

Существует 2 ключевых отличия в поведения этих двух реализаций, о которых нужно знать заранее. Для некоторых классов или приложений разница может оказаться несущественной, но для других она может быть критической.

(*) Название ‘field-like’ уходит корнями в существующую терминологию, как например, field-like events. Да, с C# 6, field-like реализация теперь содержит свойство, но классическая реализация была основана именно на закрытом поле.

Все различия происходят из-за того, что в первом случае тело конструктора синглтона вызывается в статическом конструкторе, а во-втором случае – в статическом конструкторе вызывается лишь конструктор объекта Lazy<T>, а сам объект создается при первом обращении.

1. Время инициализации объекта-синглтона

  • Field-like синглтон будет проинициализирован при вызове статического конструктора (а не только при обращении к свойству Instance).
  • Правила, определяющие время инициализации типа довольно запутаны. Например, при отсутствии явного статического конструктора синглтон может быть проинициализирован перед вызовом метода, в котором он может использоваться (подробнее об этом в статье О синглтонах и статичечских конструкторах).

Вот небольшой пример:

class FieldLikeSingleton
{
    public static FieldLikeSingleton Instance { get; } = new FieldLikeSingleton();

   
private FieldLikeSingleton()
    {
        Console.WriteLine("FieldLikeSingleton.ctor");
    }

   
public void Foo()
    {
        Console.WriteLine("Foo");
   
}
}


class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Inside Main()");
        if (args.Length == 42)
        {
            Singletons.FieldLikeSingleton.Instance.Foo();
        }
    }
}

При запуске в релизе, на экране нас ждут:

FieldLikeSingleton.ctor
Inside Main()

Поскольку у синглтона нет явного статического конструктора, то правила инициализации типа несколько ослабляются и она происходит до вызова метода. В случае с ленивым синглтоном, мы его проинициализируем непосредственно в момент обращения к свойству Instance.

2. Проблемы с исключениями

  • Исключение, возникшее в конструкторе field-like синглтона будет обернуто в TypeLoadException.
  • Конструктор field-like синглтона будет вызван только один раз.
  • В случае исключение тип синглтона окажется в нерабочем состоянии

Это наиболее ключевое отличие между ленивой и field-like реализацией. В случае field-like синглтона исключения в конструкторе являются по сути фатальными. Когда статический конструктор генерирует исключение, то весь тип помечается невалидным: любое обращение завершается с TypeLoadException (внутри которого будет храниться исходное исключение):

class FieldLikeSingleton
{
    public static FieldLikeSingleton Instance { get; } = new FieldLikeSingleton();
       
   
private FieldLikeSingleton()
    {
        throw new DataException("Oops!");
    }

   
public void Foo()
    {
        Console.WriteLine("Foo");
   
}
}


class Program
{
    static void Main(string[] args)
    {
        try
        {
            FieldLikeSingleton.Instance.Foo();
        }
        catch (TypeLoadException e)
        {
            // e.InnerException is of type DataException
            Console.WriteLine(e.InnerException);
        }

       
Console.WriteLine("Done");
    }
}

Исключение, завернутое в TypeLoadException, по сути, становится необрабатываемым. Мы можем перехватить его в высокоуровневом catch(Exception), но там мы точно ничего не сможем с ним сделать. Наш тип является невалидным и любое обращение даже к любому статическому члену приведет к исключению. По сути, исключение в конструкторе такого синглтона должно приводить к прекращению работы приложения, поскольку его нормальная работа невозможна.

Ленивый синглтон этой проблемой не обладает: если конструктор такого синглтона упадет в первый раз, то поле _instance останется непроинициализированным и при следующем обращении к синглтону будет еще одна попытка инициализации.

Так что выбрать?

Понятно, что исключения в конструкторах синглтонах – это дело нехорошее, но ведь далеко не всегда очевидно, может оно произойти или нет. А если произойдет, то будет ли оно восстанавливаемым или временным (transient)? Если это критическое исключение, то тут все просто: в любом случае нужно крэшить приложение, надеясь на то, что в логах будет полное сообщение, а не лишь свойство Message.

Если же исключение является временным (конструктор обращается к файлу или другому ресурсу, который может быть временно недоступен), то ленивый синглтон позволит восстановить работу приложения, а field-like – нет. Это может быть особенно важным в случае сервисов, который сможет восстановить свою работу в одном случае, и не сможет – в другом.

Я практически всегда отдаю предпочтение ленивой версии. Она на одну строку длиннее, но это небольшая цена за более вменяемый стек исключений в случае ошибки в конструкторе. Если же Lazy<T> недоступен (используется более старая версия фреймворка), то для простых типов я использую field-like синглтон, а для сложных – старый добрый double-checking lock.

Дополнительный ссылки

О синглтонах и статичечских конструкторах

13 комментариев:

  1. Сергей, я что-то подзабыл.
    А разве field-like был не более предпочтительным относительно double-checked? Вроде бы в этом блоге и читал, но давно и детали забылись.

    Кстати, было бы интересно почитать про какие-нибудь технологии и подходы, которые заставляют взглянуть на процесс разработки по иному.
    Конкретно у меня это в первую очередь функциональное программирование (иммутабельность для облегчения дебага, лямбды и т.д.). Туда же хочется отнести IO-монады у хаскелла и схожесть async-ов с ними. Пришло понимание о идее отделения логики с ИО (а асинки практически всегда какое-либо ИО), смена концепции тестирования (моки становятся практически не нужны) и т.д.
    Еще, из относительно недавнего могу упомянуть всякие messaging системы типа Service Bus и RabbitMQ. Их можно использовать как для какого-то отложенного исполнения, так и для гарантированного выполнения каких-то нестабильных вещей (очень меня и заказчиков выручило недавно) так и вообще для распараллеливания тяжелых задач по разным нодам, вплоть до подключения этих самых нод на лету.

    Хотелось бы почитать что-то такое, что повлияло на тебя. Вплоть до цикла статей :)

    ОтветитьУдалить
    Ответы
    1. Форкну ответы. Это первый по поводу field-like vs. doublec-checked: у первого остается проблема с исключениями. Если они возможны (скажем так, вполне реальны), то лучше double checked.

      Удалить
    2. Вторая часть: тема эта философская. Я могу подумать над этой темой, чтобы собрать список того, что на меня повлияло, почему и как. Но, как ты написал, у каждого список будет свой. Это не значит, что этим списком будет не интересно делиться (даже наоборот). Так что мысль - ок. Подумаю над ней.

      Удалить
  2. Еще есть небольшая разница в случае многопоточного доступа: type initialization в field-like всегда thread-safe, для чего берет глобальный lock, на котором можно получить deadlock (хотя и маловероятно встретить в реальной жизни). В случае с Lazy, многопоточность контролируется явно параметром конструктора Lazy.

    ОтветитьУдалить
    Ответы
    1. По поводу дедлока: в статье по ссылке я как раз рассматривал пример дедлока в реальной жизни:). Так что он возможен:) Хоть и маловероятен.

      Но да, дополнение годное.

      Удалить
  3. Double-check locking не нужен, есть четвёртый вариант от Джона Скита =)
    http://csharpindepth.com/Articles/General/Singleton.aspx

    ОтветитьУдалить
    Ответы
    1. Четвертый вариант - это тот самый field-like синглтон, который страдает от всех вышеописанных проблем: порядок инициализации, невалидность типа в случае исключения и оборачивание исключения в TypeLoadException.

      Так что, double check locking нужен, если для вас эти проблемы актуальны.

      Удалить
    2. Четвёртый вариант не замена Lazy. А double-check locking, если мы говорим о том самом double-check locking'е, также не спасёт от TypeLoadException.

      А вот в пятом варианте есть ещё и ленивость без Lazy.
      И, кстати, всегда можно принести Lazy (реализация есть на GitHub) туда где его нет. Так что шестой вариант самый лучший =)

      Удалить
    3. Тот самый double check locking спасает от TypeLoadException при условии, что именно экземплярный конструктор синглтона делает определенную работу. Именно поэтому четвертый вариант - не замена double check locking-у.

      По поводу самописного или притянутого Lazy: вариант:)
      И да, шестой вариант самый лучший, но очень важно всем понимат - почему;)

      Удалить
    4. Да, тут вы правы.

      Но не зря у Скита над этим вариантом:
      // Bad code! Do not use!
      =D

      Удалить
  4. "Ленивый синглтон этой проблемой не обладает: если конструктор такого синглтона упадет в первый раз, то поле _instance останется непроинициализированным и

    при следующем обращении к синглтону будет еще одна попытка инициализации."
    Конструктор экземплярный или типа? Впрочем что-то не получается восстановить нормальную работу в обоих случаях. Можно пример? Возможно не так понял мысль.

    После прочтения казалось, что такой код должен работать...

    class Counter
    {
    public static int Value;
    }

    class FieldLikeLazySingleton
    {
    private FieldLikeLazySingleton()
    {
    if (Counter.Value == 0)
    throw new DataException("Oops!");
    }

    private static readonly Lazy _instance =
    new Lazy(() => new FieldLikeLazySingleton());

    public static FieldLikeLazySingleton Instance => _instance.Value;

    public void Foo()
    {
    Console.WriteLine("Foo");
    }
    }

    class Program
    {
    static void Main(string[] args)
    {
    try
    {
    FieldLikeLazySingleton.Instance.Foo();
    }
    catch (DataException)
    {
    Counter.Value++;
    }

    FieldLikeLazySingleton.Instance.Foo();
    Console.WriteLine("Done");
    }
    }

    И кстати опечатка, вылетает TypeInitializationException при field-like. Ваш пример "Done" не выводит поэтому.

    ОтветитьУдалить
    Ответы
    1. Я тоже думал, что такой код должен работать, но оказался не прав. Спасибо я поправлю опечатку и эту фразу.

      Удалить
    2. А поправить? :(

      Lazy кэширует исключения в некоторых случаях: https://docs.microsoft.com/en-us/dotnet/framework/performance/lazy-initialization#exceptions-in-lazy-objects
      Там же описаны способы этому помешать. Пример выше будет работать, если поменять на
      private static readonly Lazy _instance = new Lazy(() => new FieldLikeLazySingleton(), LazyThreadSafetyMode.PublicationOnly);

      Удалить