понедельник, 1 августа 2011 г.

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

Изначально автор хотел назвать эту статью следующим образом: «О синглтонах, статических конструкторах и инициализаторах статических полей, о флаге beforeFieldInit и о его влиянии на deadlock-и статических конструкторов при старте сервисов релизных билдов в .Net Framework 3.5», однако в связи с тем, что многострочные названия по неведомой автору причине так и не прижились в современном компьютерном сообществе, он (автор) решил сократить это название, чудовищным образом исказив его исходный смысл.

-------------------------

Любая реализация паттерна Синглтон в общем случае преследует две цели: во-первых, реализация должна быть потокобезопасной, чтобы предотвратить создание более одного экземпляра в многопоточном мире .Net; а во-вторых, эта реализация должна быть «отложенной» (lazy), чтобы не создавать экземпляр (потенциально) дорого объекта раньше времени или в тех случаях, когда он вообще может не понадобиться. Но поскольку основное внимание при прочтении любой статьи про реализацию Синглтона отводится многопоточности, то на «ленивость» зачастую не хватает ни времени не желания.

Давайте рассмотрим одну из наиболее простых и популярных реализаций паттерна Синглтон (*), основанную на инициализаторе статического поля:

public sealed class Singleton
{
    private static readonly Singleton instance = new Singleton();

    // Explicit static constructor to tell C# compiler
    // not to mark type as beforefieldinit
    static Singleton()
    { }

    private Singleton()
    { }

    public static Singleton Instance { get { return instance; } }
}

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

Статический конструктор и инициализаторы полей

Статический конструктор – это штука, предназначенная для инициализации типа, которая должна быть вызвана перед доступом к любому статическому или не статическому члену, а также перед созданием экземпляра класса. Однако если класс в языке C# не содержит явного объявления статического конструктора, то компилятор помечает его атрибутом beforeFieldInit, что говорит среде времени выполнения о том, что тип можно инициализировать отложенным (“relaxed”) образом. Однако, как показывает практика, в .Net Framework до 4-й версии, это поведение можно назвать каким угодно, но не «отложенным».

Итак, давайте рассмотрим следующий код:

class Singleton
{
    //static Singleton()
    //{
    //    Console.WriteLine(".cctor");
    //}
    public static string S = Echo("Field initializer");

    public static string Echo(string s)
    {
        Console.WriteLine(s);
        return s;
    }
}

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Starting Main...");
        if (args.Length == 1)
        {
            Console.WriteLine(Singleton.S);
        }
        Console.ReadLine();
    }
}

Поскольку в данном случае явный статический конструктор класса Singleton отсутствует, то компилятор к этому типу добавляет атрибут beforeFieldInit. Согласно спецификации, в этом инициализация статического поля произойдет до первого обращения к этому полю, причем может она может произойти задолго до этого обращения. На практике при использовании .Net Framework 3.5 и ниже, это приводит к тому, что инициализация статического поля произойдет до вызова метода Main, даже если условие args.Legnth == 1 не будет выполнено. Все это приводит к тому, что при запуске указанного выше кода мы получим следующее:

Field initializer
Starting Main...

Как видно, что статическое поле будет проинициализировано, хотя сам тип в приложении не используется. Практика показывает, что в большинстве случаев при отсутствии явного конструктора, JIT-компилятор вызывает инициализатор статических переменных непосредственно перед вызовом метода, в котором используется эта переменная. Если раскомментировать статический конструктор класса Singleton, то поведение будет именно таким, которое ожидает большинство разработчиков – инициализатор поля вызван не будет и при запуске приложения на экране будет только одна строка: Starting Main…”.

ПРИМЕЧАНИЕ
Разработчик не может и не должен завязываться на время вызова статического конструктора. Если следовать «букве закона», то вполне возможна ситуация, когда в приведенном выше примере (без явного конструктора типа), переменная Singleton.S не будет проинициализирована при создании экземпляра класса Singleton и при вызове статического метода, который не использует поле S, но будет проинициализирована при вызове статической функции, использующей поле S. И хотя именно такое поведение исходно заложено в определение флага beforeFieldInit, в спецификации языка C# специально говорится о том, что точное время вызова определяется реализацией. Так, например, при запуске приведенного выше исходного фрагмента (без явного статического конструктора) под .Net Framework 4, мы получим более ожидаемое поведение: поле S проинициализировано не будет! Более подробно об этом можно почитать в дополнительных ссылках, приведенных в конце статьи.

Статические конструкторы и взаимоблокировка

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

Воспроизвести подобную ситуацию в идеальных условиях весьма просто: для этого в статическом конструкторе достаточно создать новый поток и попытаться дождаться его выполнения:

class Program
{
    static Program()
    {
        var thread = new Thread(o => { });
        thread.Start();
        thread.Join();
    }

    static void Main()
    {
        // Этот метод никогда не начнет выполняться,
        // поскольку дедлок произойдет в статическом
        // конструкторе класса Program
    }
}

Причем, если обратиться к спецификации CLI, то в ней сказано, что дедлок возможен при явном или не явном вызове блокирующей операции внутри статического конструктора. На практике это означает, что попытка блокировки потока внутри статического конструктора, например, из-за использования именованного мьютекса в некоторых случаях может приводить к дедлоку.

Бага в реальном приложении

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

Итак, вот симптомы реальной проблемы, с которой я столкнулся. У нас есть сервис, который прекрасно работает в консольном режиме, а также не менее прекрасно работает в виде сервиса, если собрать его в Debug-е. Однако если собрать его в релизе, то он запускается через раз: один раз запускается успешно, а во второй раз запуск падает по тайм-ауту (по умолчанию SCM прибивает процесс, если сервис не запустился за 30 секунд).

В результате отладки было найдено следующее. (1) У нас есть класс сервиса, в конструкторе которого происходит создание счетчиков производительности; (2) класс сервиса реализован в виде синглтона с помощью инициализации статического поля без явного статического конструктора, и (3) этот синглтон использовался напрямую в методе Main для запуска сервиса в консольном режиме:

// Класс сервиса
partial class Service : ServiceBase
{
    // "Кривоватая" реализаци Синглтона. Нет статического конструктора
    public static readonly Service instance = new Service();
    public static Service Instance { get { return instance; } }
 
    public Service()
    {
        InitializeComponent();
 
        // В конструкторе инициализирутся счетчики производительности
        var counters = new CounterCreationDataCollection();
           
        if (PerformanceCounterCategory.Exists(category))
            PerformanceCounterCategory.Delete(category);
 
        PerformanceCounterCategory.Create(category, description,
            PerformanceCounterCategoryType.SingleInstance, counters);
    }

    // Метод запуска сервиса
    public void Start()
    {}
 
    const string category = "Category";
    const string description = "Category description";
}
// А тем временем в классе Program
static void Main(string[] args)
{
    if (args[0] == "--console")
        Service.Instance.Start();
    else
        ServiceBase.Run(new Service());
}

Поскольку класс Server не содержит явного статического конструктора и компилятор C# добавляет флаг beforeFieldInit, то вызов конструктора класса Service происходит до вызова метода Main. При этом для создания категории счетчиков производительности используется именованный мьютекс, что в определенных условиях приводит к дедлоку приложения: во время первого запуска указанной категории еще нет в системе, поэтому метод Exists возвращает false и метод Create завершается успешно; во время следующего запуска метод Exists возвращает true, метод Delete завершается успешно, но метод Create подвисает на веки. Понятное дело, что после того, как проблема была найдена, решение заняло ровно 13 секунд: добавить статический конструктор в класс Service.

Заключение

Пример с багом в реальном приложении говорит о том, что статьи о подводных камнях языка C# и о правильном применении известных паттернов и идиом не является бредом и выдумкой теоретиков (**), многие подобные статьи основываются на шишках, набитых в реальном мире. Сегодня вы могли столкнуться с проблемами кривой реализации синглтона, завтра – с непонятным поведением изменяемых значимых типов, послезавтра вы прибиваете поток с помощью Thread.Abort и получаете рассогласованное состояние системы (***). Все эти проблемы весьма реальны и понимание принципов, заложенных в их основу может сэкономить денек другой при поиске какого-нибудь особенно злого бага.

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

-------------------------

(*) Двумя другими весьма популярными реализациями паттерна Синглтон являются: (1) блокировка с двойной проверкой (double checked locking), а также (2) с помощью типа Lazy<T>, который появился в .Net Framework 4.

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

(***) Если интересно, какие такие проблемы таятся в изменяемых значимых типах, то вполне подойдет предыдущая заметка «О вреде изменяемых значимых типов», ну а если интересно, что же такого плохого в вызове Thread.Abort, то тут есть даже две заметки: «О вреде вызова Thread.Abort», а также перевод интересной статьи Криса Селлза «Изучение ThreadAbortExcpetion с помощью Rotor».

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

  1. Хорошая статья. Недавно провел маленький опрос. Выяснилось, что что такое класс-тип представление ОЧЕНЬ смутное. А то, что у него еще и конструктор есть... Когда вызывается было уже неактуально. Кстати, Сергей, наверное, надо тогда уж быть последовательным и рассказать про syncblockindex, на что указывает GetType(тип) и почему блокировки на типах - это плохо. Мне кажется это было бы очень в тему к двум предыдущим статьям?

    ОтветитьУдалить
  2. ...а во-вторых, эта реализация должна быть «отложенной» (lazy), чтобы не создавать экземпляр (потенциально) дорого объекта раньше времени или в тех случаях, когда он вообще может не понадобиться...

    Не должна она быть отложенной, и Wiki, как ни странно, со мной согласна.
    Самое главное в синглтоне это, что бы его время жизни покрывало моменты взаимодействия с ним. Можно - не значит нужно: "... although a singleton can be implemented as a static instance, it can also be lazily constructed, requiring no memory or resources until needed..."

    Необходимость смещения конструирования до по последнего момента вызвана тем что используют синглтон, в основном, что бы достичь этого, а не того что бы реализовать сам синглтон. Хотя на самом деле время жизни объекта синглтон может быть даже прерывистым, когда состояние выгружается куда-то, а потом восстанавливается или же может быть синглтон на каждый тред (актуально для кеширования).

    ОтветитьУдалить
  3. @eugene: Спасибо за идею. Я думаю, что как-нибудь вернусь к этой теме и восполню сей пробел.

    ОтветитьУдалить
  4. @ony: говоря формально в паттерне синглтона даже о многопоточности речь не идет. Но поскольку многопоточные сценарии являются совершенно типовыми, то немногопоточная реализация считается тотальным муветоном.

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

    Все это делает "ленивость" синглтона таким же желательным свойством, как и "потокобезопасность".

    ОтветитьУдалить
  5. Приведите, пожалуйста, Вашу "правильную" реализацию Синглтона

    ОтветитьУдалить
  6. @s_tristan: моя "правильная" реализация синглтона заключается в наличии пустого статического конструктора, либо в использовании блокировки с двойной проверкой.

    ОтветитьУдалить
  7. А вот ситуация, где пустой статический конструктор не помогает:

    class Program
    {
    static void Main(string[] args)
    {
    Service service = new Service();
    }
    }

    public class Service
    {
    private static readonly Service service = new Service();

    public static Service Instance
    {
    get
    {
    return service;
    }
    }

    static Service()
    {
    }

    public Service()
    {
    Thread thread = new Thread(o => { });
    thread.Start();
    thread.Join();
    }
    }

    Выход из положения - использовать Lazy:

    class Program
    {
    static void Main(string[] args)
    {
    Service service = new Service();
    }
    }

    public class Service
    {
    private static readonly Lazy service = new Lazy(() => new Service());

    public static Service Instance
    {
    get
    {
    return service.Value;
    }
    }

    public Service()
    {
    Thread thread = new Thread(o => { });
    thread.Start();
    thread.Join();
    }
    }

    ОтветитьУдалить
  8. Борис, совершенно верно. Вызов подобного кода в статическом конструкторе гарантировано приводит к дедлоку. Просто в моем случае дедлок происходит только если код создания счетчиков производительности вызывается до функции Main. Поэтому это решает проблему (хотя риск дедлока все еще остается, поскольку ожидание освобождения именованого мьютекса, которое происходит при создании категориии счетчиков производительности все еще может приводить к дедлоку).

    ОтветитьУдалить
  9. Странно, что не упомянута действительно правильная реализация синглтона: http://www.yoda.arachsys.com/csharp/singleton.html Также об этом можно почитать в книге: http://www.amazon.com/3-0-Design-Patterns-Judith-Bishop/dp/059652773X/ref=sr_1_1?ie=UTF8&qid=1312478044&sr=8-1
    А double-checking lock совсем даже не правильная. Об этом можно почитать по первой ссылке. Или, чтобы глубоко вникнуть в многопоточность и разобрать по косточкам почему это так, рекомендую: http://www.amazon.com/Concurrent-Programming-Windows-Joe-Duffy/dp/032143482X/ref=sr_1_1?s=books&ie=UTF8&qid=1312478185&sr=1-1

    ОтветитьУдалить
  10. Олег, спасибо большое, но на самом деле указанную вами ссылку я привел:) Это и есть та статья Джона Скита, которая у меня значится под номером 4:
    Jon Skeet. Implementing the Singleton Pattern in C#

    И если вы прочитаете хоть по своей ссылке, хоть по моей, то заметите, что основным минусом double-ckecked lock-а является невозможность использования этого паттерна в других языках, например в Java (причем до версии 1.5).

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

    ОтветитьУдалить
  11. Есть еще одна интересная особенность, почему наверное не надо использовать статический конструктор. Если, кто-то, может быть после решит, что статический конструктор - отличное место для инициализации и добавит код, который будет генерить исключение - то все - ахтунг. Любая попытка обратиться к GetInstance() будет приводить к TypeInitializeException.

    ОтветитьУдалить
  12. @Женя: да, совершенно верно.

    Если говорить о базовой и строгой гарантии исключений (раз уж тема такая пошла), то можно сказать, что синглтон на основе инициализатора статического поля обеспечивает базовую гарантию (состояние согласовано), а синглтон, на основе double-checked locking-а или на основе Lazy - обеспечивает строгую гарантию исключений, т.е. если создание экземпляра упадет, то мы "откатимся" в то состояние, которое было до создания экземпляра.

    Теперь по русски: при использовании статического конструктора, если он один раз упадет, то при последующем обращении мы не будем пытаться создать экземпляр снова, а лишь будем выбрасывать закешированное исключение. В случае с double-check locking-ом - мы будем пытаться создать экземпляр снова и снова каждый раз.

    ОтветитьУдалить
  13. >> Так, например, при запуске приведенного выше исходного фрагмента (без явного статического конструктора) под .Net Framework 4, мы получим более ожидаемое поведение: поле S проинициализировано не будет!

    Проверил. Поле инициализируется. Как так?

    ОтветитьУдалить
  14. не пойму почему, но сам статический конструктор, похоже, вообще не вызывается. Ни дебаггером не поймать, ни Console.WriteLine ничего не выводит:

    class Singleton
    {
    public static string S = Echo("Field Init");

    static Singleton()
    {
    S = Echo("abc");
    }

    public static string Echo(string s)
    {
    Console.WriteLine(s);
    return s;
    }
    }

    или он вызывается ранее, в какой-то момент сразу после компиляции и до запуска приложения?

    ОтветитьУдалить
    Ответы
    1. Олег, а как выглядит приложение целиком?

      Удалить
    2. namespace ConsoleApplication5
      {
      class Program
      {
      static void Main(string[] args)
      {
      Console.WriteLine("Starting main...");
      if (args.Length == 1)
      {
      Console.WriteLine(Singleton.S);
      }
      Console.ReadLine();
      }
      }

      class Singleton
      {
      public static string S = Echo("Field Init");

      static Singleton()
      {
      S = Echo("abc");
      }

      public static string Echo(string s)
      {
      Console.WriteLine(s);
      return s;
      }
      }
      }



      .NET 4.5.2

      Удалить
    3. Ну, тут несколько моментов:
      1. На экран ничего не выводится, поскольку args.Length при запуске из VS равен 0.
      2. У вас добавлен явный статический конструктор, который приводит к тому, что статический конструктор вызывается лишь в момент первого обращения к типу, но не раньше.
      3. Достаточно убрать статический конструктор, чтобы увидеть, что статический конструктор будет вызываться даже в том случае, когда обрщение к синглтону будет происходить в недостижимом коде.

      Удалить
  15. Как мне не хватало этой статьи, когда я бодался с InvalidTypeException.

    ОтветитьУдалить