Такие разные одиночки

Данная запись является вольным переводом статьи Джона Скита «Implementing the Singleton Pattern in C#» (англ.), которая чрезвычайно понравилась мне как полнотой материала, так и манерой его подачи (с объяснением причин, особенностей и последствий).

Введение

Паттерн одиночка — один из наиболее известных паттернов проектирования. По сути, одиночка — класс, позволяющий создать лишь один свой экземпляр и обычно предоставляющий простой способ доступа к этому экземпляру. Чаще всего одиночки при создании экземпляра не позволяют указывать никаких параметров, так как в этом случае удовлетворение второго обращения с отличающимися параметрами было бы проблематично (в случае, если конкретный экземпляр должен быть доступен вызовам с одинаковыми параметрами, лучше использовать фабрику). Эта статья относится только к ситуации, когда параметры не нужны. Обычным требованием к одиночкам является ленивая инициализация, то есть создание экземпляра только тогда, когда в нём впервые возникает необходимость.
Реализовать одиночку в C# можно несколькими различными способами. Я опишу их здесь в порядке возрастания изящества, начиная с наиболее часто встречающегося потоконебезопасного способа и заканчивая самой ленивой, простой, потокобезопасной и производительной реализацией.
Все эти реализации обладают некими общими свойствами, а именно:

  • Один закрытый конструктор класса, не имеющий параметров. Это предотвратит создание экземпляров одиночки другими классами (что было бы нарушением паттерна). Кстати, это предотвратит ещё и наследование, и это правильно: если у одиночки будут два производных класса, каждый из них сможет создать экземпляр, что также является нарушением паттерна. Если вам нужен одинокий экземпляр некоего базового класса, чей конкретный тип выяснится только во время исполнения (IoC хочется, например — прим. переводчика) — используйте фабрику;
  • Класс запечатан. Строго говоря, в связи с предыдущим пунктом это не обязательно, но может помочь каким-нибудь сакральным оптимизациям JIT-компилятора;
  • Для хранения ссылки на единственный экземпляр (при её наличии) используется статическое закрытое поле;
  • Для получения ссылки на экземпляр (а также для неявного создания оного при необходимости) используется статический открытый член;

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

Делай раз: однопоточная реализация

// Потенциально опасный код! Не использовать!
public sealed class Singleton
{
    private static Singleton instance=null;

    private Singleton()
    {
    }

    public static Singleton Instance
    {
        get
        {
            if (instance == null)
            {
                instance = new Singleton();
            }
            return instance;
        }
    }
}

Эта реализация, как уже говорилось, не является потокобезопасной: несколько различных потоков могут одновременно убедиться, что instance == null, и создать каждый свой экземпляр, что нарушит паттерн. Отметим, что экземпляр уже может быть создан в каком-либо потоке перед вычислением выражения, но среда исполнения не гарантирует, что этот экземпляр будет виден в других потоках до достижения соответствующих барьеров памяти.

Делай два: простая потокобезопасная реализация

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

    Singleton()
    {
    }

    public static Singleton Instance
    {
        get
        {
            lock (padlock)
            {
                if (instance == null)
                {
                    instance = new Singleton();
                }
                return instance;
            }
        }
    }
}

Эта реализация является потокобезопасной: поток блокирует общий объект, и перед созданием экземпляра проверяет, не создан ли он уже. Это решает проблему с барьером памяти (так как блокировка гарантирует, что все операции чтения будут происходить после неё, а разблокировка — что все операции записи будут завершены до неё), а также гарантирует, что экземпляр будет создан только в одном потоке (так как находиться на этом участке кода одновременно может только один поток — когда в него сумеет войти второй поток, первый уже точно создаст экземпляр класса, и сравнение даст false). К сожалению, здесь страдает производительность, так как блокировка происходит при каждом обращении к экземпляру класса.
Заметим также, что здесь потоки блокируются на значении закрытой статической переменной, а не на typeof(Singleton), как иногда делают. Блокирование на объектах, к которым имеется доступ извне, чревато проблемами с производительностью и даже взаимными блокировками.

Делай три: потокобезопасная реализация с двойной проверкой

// Потенциально опасный код! Не использовать!
public sealed class Singleton
{
    private static Singleton instance = null;
    private static readonly object padlock = new object();

    Singleton()
    {
    }

    public static Singleton Instance
    {
        get
        {
            if (instance == null)
            {
                lock (padlock)
                {
                    if (instance == null)
                    {
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    }
}

Эта реализация — попытка избежать блокировок при каждом обращении, сохранив потокобезопасность. К сожалению, у этого способа имеется 4 недостатка:

  • Он не работает в Java. Несколько странный довод, но следует иметь это в виду на случай, если вам потребуется одиночка в Java (C# программисты могут одновременно быть и Java-программистами). Модель памяти Java не гарантирует, что конструктор полностью отработает перед присвоением ссылки на новый объект соответствующему полю; она была переработана для версии 1.5, но после этого блокировка с двойной проверкой всё ещё не работает без использования изменчивого поля (как, впрочем, и в C#).
  • Без барьеров памяти он также не работает в спецификации ECMA CLI (англ.). Может быть, это и безопасно в модели памяти .NET 2.0 (которая сильнее ECMA), но я не стал бы закладываться на подобные нюансы реализации, особенно когда дело касается безопасности. Эту штуку можно заставить работать при помощи изменчивого поля, либо врукопашную с барьерами памяти, хотя тут даже специалисты не могут определиться, какие именно барьеры тут нужны. Я стараюсь избегать ситуаций, относительно которых эксперты не могут договориться, что правильно, а что — нет.
  • Здесь легко ошибиться. Реализация паттерна этим способом должна в точности повторять написанное — любое существенное изменение с большой вероятностью отразится на производительности либо корректности.
  • Он всё ещё не так хорош, как следующие реализации (а ещё он громоздкий — прим. переводчика)

(Я бы добавил ещё и пятый пункт: корректные реализации с барьерами памяти или изменчивым полем класса недостаточно очевидны для понимания младшими сотрудниками — прим. переводчика)

Делай четыре: реализация не слишком ленивая, зато потокобезопасная без блокировок

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

    // Явный статический конструктор, предназначенный для указания
    // компилятору не помечать тип как beforefieldinit
    static Singleton()
    {
    }

    private Singleton()
    {
    }

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

Легко заметить, что эта реализация невероятно проста. Однако, почему она потокобезопасна и настолько ленива? Ну, в C# в пределах одного домена приложения статические конструкторы выполняются однократно при создании экземпляра класса, либо при обращении к его статическим членам. Учитывая, что необходимая для этого проверка, основанная на создаваемом типе, должна выполниться, что бы ни случилось, такая реализация быстрее, чем предыдущие с дополнительной проверкой. Но не обошлось и без пары ложек дёгтя:

  • Этот способ не так ленив, как предыдущие. В частности, если у класса помимо Instance будут и другие статические члены, то обращение к ним вызовет создание экземпляра. В следующей реализации это будет исправлено;
  • В случае, когда один статический конструктор вызывает другой, а тот, в свою очередь, вызывает первый — могут быть осложнения (Ну ещё бы! — прим. переводчика). Более подробные сведения об устройстве инициализаторов типов можно найти в спецификации .NET (в настоящее время пункт 10.5.3 второго тома (англ., pdf)) — вряд ли они вас укусят, но в любом случае знать о последствиях статических конструкторов, вызывающих друг друга, будет полезно;
  • Ленивость инициализаторов типов гарантируется средой исполнения только тогда, когда тип не помечен специальным служебным атрибутом beforefieldinit. К сожалению, компилятор (как минимум, в версии 1.1) (впрочем, в 4.0 тоже — прим. переводчика) помечает этим атрибутом все классы, не имеющие явно заданных статических конструкторов. На эту тему у меня есть отдельная статья (англ.); обратите внимание, эта особенность влияет на производительность, что описано внизу страницы;

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

Делай пять: полностью ленивая инициализация

public sealed class Singleton
{
    private Singleton()
    {
    }

    public static Singleton Instance { get { return Nested.instance; } }
        
    private class Nested
    {
        // Explicit static constructor to tell C# compiler
        // not to mark type as beforefieldinit
        static Nested()
        {
        }

        internal static readonly Singleton instance = new Singleton();
    }
}

Здесь создание экземпляра инициируется первым обращением к статическому члену вложенного типа, что, очевидно, происходит только в геттере свойства Instance. Благодаря этому реализация становится полностью ленивой, и сохраняет высокую производительность предыдущей реализации. Заметим, что, хоть у вложенных типов и есть доступ к закрытым членам содержащего класса, обратное неверно, поэтому поле instance объявлено внутренним; впрочем, это не вызывает никаких проблем, так как сам класс является закрытым. Однако, из-за реализации полностью ленивой инициализации код этого способа несколько сложнее.

Делай шесть: использование класса Lazy<T>

В .NET 4.0 (или выше) для упрощения реализации ленивости можно использовать класс System.Lazy<T>. Всё, что вам нужно — передать в конструктор Lazy делегат, вызывающий конструктор одиночки, что проще всего сделать лямбда-выражением.

public sealed class Singleton
{
    private static readonly Lazy lazy =
        new Lazy(() => new Singleton());
    
    public static Singleton Instance { get { return lazy.Value; } }

    private Singleton()
    {
    }
}

Эта реализация проста и обладает хорошей производительностью; также она позволяет в случае необходимости узнать, создан ли экземпляр класса (при помощи свойства IsValueCreated).

Ленивость против производительности

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

Исключения

Иногда вам нужно выполнить в конструкторе одиночки какой-нибудь код, который может выбросить исключение, не являющееся, впрочем, смертельным для приложения в целом; приложение может даже попытаться решить приведшую к исключению проблему и ещё раз попробовать создать одиночку. В этом случае использование инициализатора типа для создания одиночки весьма проблематично. Разные среды исполнения ведут себя в этой ситуации по-разному, но я не знаю ни одной, которая бы делала то, что хотелось бы (а именно — вызвала бы инициализатор типа повторно); более того, даже если бы таковая существовала, в других средах такой код был бы неработоспособен. Для решения этой проблемы я бы предложил использовать вторую реализацию.

Спасибо Андрею Терещенко за выявление проблемы.

Кое-что о производительности

Основная причина наличия этого абзаца — люди, которые стараются быть умными, придумывая разные штуки типа алгоритма блокировки с двойной проверкой. Мнение, что блокировка является дорогостоящей операцией, столь же распространено, сколь ошибочно. Я наскоро набросал небольшой тест производительности, миллиард раз обращающийся к одиночке в цикле, и протестировал разные варианты реализации. Не особо научно, так как в реальной жизни вам бы хотелось учесть, сколько времени тратится на вызов метода, получающего одиночку, и т.п. Тем не менее, он демонстрирует один важный момент. Самое медленное решение из тех, что я запустил на своём ноутбуке (с коэффициэнтом 5) — блокировка с одной проверкой (номер 2 в этой статье). Насколько его замедление важно? Наверное, не очень, если учесть, что менее, чем за 40 секунд, к одиночке удалось обратиться миллиард раз (примечание: я писал эту статью довольно давно, и сейчас ожидал бы куда большей производительности). Это означает, что если вы обращаетесь к одиночке «всего лишь» четыреста тысяч раз в секунду, выигрыш в производительности составит около 1%, так что это — экономия на спичках. Далее — если вы действительно обращаетесь к одиночке настолько часто — вы, скорее всего, делаете это в цикле. И если вас настолько заботит производительность, почему бы не вынести обращение к одиночке за пределы цикла, сохранив ссылку на полученный экземпляр в локальной переменной? Бинго! Теперь даже самая тормозная реализация ведёт себя вполне адекватно.
Интересно бы было взглянуть на реальное приложение, в котором разница в производительности между алгоритмом с одиночной проверкой и алгоритмом с двойной проверкой существенно сказалась бы на производительности приложения в целом.

Заключение (слегка изменено 7 января 2007; обновлено 12 января 2011)

Существует несколько способов реализовать одиночку в C#. Читателю были детально описаны способы инкапсуляции аспекта синхронизации, которые, признаю, могут быть полезны в весьма редких и очень специфичных ситуациях (например, там, где хочется добиться очень высокой производительности, или возможности определять, создан ли уже экземпляр одиночки или ещё нет, или полностью ленивой инициализации, не завязанной на вызов статических методов). Лично я не могу себе представить более или менее распространённую ситуацию, достойную расширения этой статьи, но если вы в такой окажетесь — напишите мне.
Лично я предпочитаю четвёртое решение. Немногочисленные ситуации, в которых я бы от него отказался — это необходимость вызывать статические методы до инициализации одиночки, либо заранее узнавать, проинициализирован ли он. Не помню, когда я последний раз попадал в такую ситуацию (и попадал ли вообще). В этом случае я бы предложил использовать второе решение, так как оно простое, очевидное и работоспособное.
Пятое решение элегентно, но оно причудливее, чем второе или четвёртое, и, как сказано ранее, его преимущества, похоже, редко будут полезными. Шестое решение — простейший способ добиться ленивости в .NET 4; у него есть то преимущество, что оно, очевидно, полностью лениво. Сейчас я в силу привычки всё ещё склонен использовать решение 4, но при работе с неопытными разработчиками я, вероятно, перешёл бы на шестое, чтобы начать с простого и универсального шаблона.

(Я не стал бы использовать первое решение, так как оно сломано, а также третье, так как у него нет преимуществ перед пятым.)

Добавить комментарий

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

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Google photo

Для комментария используется ваша учётная запись Google. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s