Сообщений 6    Оценка 400        Оценить  
Система Orphus

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

Автор: Тепляков Сергей Владимирович
Опубликовано: 23.04.2012
Исправлено: 10.12.2016
Версия текста: 1.1
Статический конструктор и инициализаторы полей
Статические конструкторы и взаимоблокировка
Баг в реальном приложении
Заключение

Изначально автор хотел назвать эту статью следующим образом: «О синглтонах, статических конструкторах и инициализаторах статических полей, о флаге 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; } }
}

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

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

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

Статический конструктор – это штука, предназначенная для инициализации типа, которая должна быть вызвана перед доступом к любому статическому или не статическому члену, а также перед созданием экземпляра класса. Однако если класс в языке 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"; 
}
// А тем временем в классе Programstatic 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 и получаете рассогласованное состояние системы.

ПРИМЕЧАНИЕ

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

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

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


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 6    Оценка 400        Оценить