В статье рассмотрены причины возникновения и способы избежания неопределённого поведения при обращении к синглтону в современном c++. Приведены примеры однопоточного кода. Ничего compiler-specific, всё в соответствии со стандартом.

Введение


Для начала рекомендую ознакомиться с другими статьями о синглтонах на Хабре:

Три возраста паттерна Singleton
Синглтоны и общие экземпляры
3 cпособа нарушить Single Responsibility Principle
Singleton — паттерн или антипаттерн?
Использование паттерна синглтон

И, наконец, статья, затронувшая эту же тему, но вскольз (хотя бы потому, что не рассмотрены недостатки и ограничения):
tialized objects (that is, objects
Синглтон и время жизни объекта

Далее:

  • это не статья об архитектурных свойствах синглтона;
  • это не статья «как из страшного и ужасного синглтона сделать белый и пушистый синглтон»;
  • это не агитация за применения синглтона;
  • это не крестовый поход против синглтона;
  • это не статья с хэппи-эндом.

Это статья об одном очень важном, но всё же техническом аспекте применения синглтона в современном С++. Основное внимание в статье уделяется моменту уничтожения синглтона, т.к. в большинстве источников вопрос уничтожения раскрыт слабо. Обычно упор делается на моменте создания синглтона, а про уничтожение в лучшем случае сказано что-то типа «уничтожается в обратном порядке».

Попрошу в комментариях придерживаться рамок статьи, особенно не устраивать холивар «синглтон-паттерн vs синглтон-антипаттерн».

Итак, поехали.

Что говорит стандарт


Цитаты — из C++14 final draft N3936, т.к. доступные черновики по C++17 не отмечены как «final».
Самый важный раздел привожу целиком. Важные места выделены мной.

3.6.3 Termination [basic.start.term]

1. Destructors (12.4) for initialized objects (that is, objects whose lifetime (3.8) has begun) with static storage duration are called as a result of returning from main and as a result of calling std::exit (18.5). Destructors for initialized objects with thread storage duration within a given thread are called as a result of returning from the initial function of that thread and as a result of that thread calling std::exit. The completions of the destructors for all initialized objects with thread storage duration within that thread are sequenced before the initiation of the destructors of any object with static storage duration. If the completion of the constructor or dynamic initialization of an object with thread storage duration is sequenced before that of another, the completion of the destructor of the second is sequenced before the initiation of the destructor of the first. If the completion of the constructor or dynamic initialization of an object with static storage duration is sequenced before that of another, the completion of the destructor of the second is sequenced before the initiation of the destructor of the first. [ Note: This definition permits concurrent destruction. —end note ] If an object is initialized statically, the object is destroyed in the same order as if the object was dynamically initialized. For an object of array or class type, all subobjects of that object are destroyed before any block-scope object with static storage duration initialized during the construction of the subobjects is destroyed. If the destruction of an object with static or thread storage duration exits via an exception, std::terminate is called (15.5.1).

2. If a function contains a block-scope object of static or thread storage duration that has been destroyed and the function is called during the destruction of an object with static or thread storage duration, the program has undefined behavior if the flow of control passes through the definition of the previously destroyed blockscope object. Likewise, the behavior is undefined if the block-scope object is used indirectly (i.e., through a pointer) after its destruction.

3. If the completion of the initialization of an object with static storage duration is sequenced before a call to std::atexit (see «cstdlib», 18.5), the call to the function passed to std::atexit is sequenced before the call to the destructor for the object. If a call to std::atexit is sequenced before the completion of the initialization of an object with static storage duration, the call to the destructor for the object is sequenced before the call to the function passed to std::atexit. If a call to std::atexit is sequenced before another call to std::atexit, the call to the function passed to the second std::atexit call is sequenced before the call to the function passed to the first std::atexit call.

4. If there is a use of a standard library object or function not permitted within signal handlers (18.10) that does not happen before (1.10) completion of destruction of objects with static storage duration and execution of std::atexit registered functions (18.5), the program has undefined behavior. [ Note: If there is a use of an object with static storage duration that does not happen before the object’s destruction, the program has undefined behavior. Terminating every thread before a call to std::exit or the exit from main is sufficient, but not necessary, to satisfy these requirements. These requirements permit thread managers as static-storage-duration objects. —end note ]

5. Calling the function std::abort() declared in «cstdlib» terminates the program without executing any destructors and without calling the functions passed to std::atexit() or std::at_quick_exit().
Трактовка:

  • уничтожение объектов со thread storage duration производится в порядке, обратном их созданию;
  • строго после этого уничтожаются объекты со static storage duration и производятся вызовы функций, зарегистрированных с помощью std::atexit в порядке, обратном созданию таких объектов и регистрации таких функций;
  • попытка обращения к уничтоженному объекту со thread storage duration или static storage duration содержит неопределённое поведение. Повторная инициализация таких объектов не предусмотрена.

Примечание: глобальные переменные в стандарте именуются как «non-local variable with static storage duration». В итоге получается, что все глобальные переменные, все синглтоны (локальные статики) и все вызовы std::atexit попадают в единую очередь LIFO по мере их создания/регистрации.

Полезная для статьи информация также содержится в разделе 3.6.2 Initialization of non-local variables [basic.start.init]. Привожу только самое важное:
Dynamic initialization of a non-local variable with static storage duration is either ordered or unordered. [...] Variables with ordered initialization defined within a single translation unit shall be initialized in the order of their definitions in the translation unit.
Трактовка (с учётом полного текста раздела): глобальные переменные в пределах одной единицы трансляции инициализируются в порядке объявления.

Что будет в коде


Все примеры кода, приведённые в статье, опубликованы на гитхабе.

Код состоит из трёх слоёв, как бы написанных разными людьми:

  • синглтон;
  • утилита (класс, использующий синглтон);
  • пользователь (глобальные переменные и main).

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

Пользователь сначала делает всё правильно, а потом лёгким движением руки всё ломает. Починить сначала пытаемся в слое утилиты, а если не получается — то в слое синглтона.

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

Почему случай обращения из деструктора самый сложный? Потому что деструктор утилиты может быть вызван в процессе сворачивания приложения, когда становится актуальным вопрос «уничтожен уже синглтон или ещё нет».

Случай какой-то синтетический. На практике обращения к синглтону из деструктора не нужны. Еще как нужны. Например, для логгирования уничтожения объектов.

Используются три класса синглтонов:

  • SingletonClassic — без умных указателей. На самом деле он не прямо совсем классический, но точно самый классический среди трёх рассмотренных;
  • SingletonShared — с std::shared_ptr;
  • SingletonWeak — с std::weak_ptr.

Все синглтоны являются шаблонами. Параметр шаблона используют, чтобы от него унаследоваться. В большинстве примеров параметризуются классом Payload, предоставляющим одну public-функцию по добавлению данных в std::set.

Деструктор утилиты в большинстве примеров пытается набить туда сотню значений. Также используется диагностический вывод в консоль из конструктора синглтона, деструктора синглтона, и instance().

Зачем так сложно? Чтобы проще было понять, что мы — на тёмной стороне. Обращение к уничтоженному синглтону является неопределённым поведением, но вполне может никак не проявиться внешне. Набивка значений в уничтоженный std::set тоже конечно не гарантирует внешних проявлений, но более надёжного способа как бы и нет (по факту в GCC под линуксом в некорректных примерах с классическим синглтоном в уничтоженный std::set всё успешно набивается, а в MSVS под виндой — зависает). При undefined behaviour вывод в консоль может и не случиться. Так что в корректных примерах ждём отсутствие обращения к instance() после деструктора, а также отсутствие крэша и отсутствие зависания, а в некорректных — либо наличие такого обращения, либо крэш, либо зависание, либо всё сразу в любых комбинациях, либо всё что угодно.

Классический синглтон


Payload.h
#pragma once

#include <set>


class Payload
{
public:
  Payload() = default;
  ~Payload() = default;

  Payload(const Payload &) = delete;
  Payload(Payload &&) = delete;

  Payload& operator=(const Payload &) = delete;
  Payload& operator=(Payload &&) = delete;

  void add(int value)
  {
    m_data.emplace(value);
  }

private:
  std::set<int> m_data;
};


SingletonClassic.h
#pragma once

#include <iostream>


template<typename T>
class SingletonClassic : public T
{
public:
  ~SingletonClassic()
  {
    std::cout << "~SingletonClassic()" << std::endl;
  }

  SingletonClassic(const SingletonClassic &) = delete;
  SingletonClassic(SingletonClassic &&) = delete;

  SingletonClassic& operator=(const SingletonClassic &) = delete;
  SingletonClassic& operator=(SingletonClassic &&) = delete;

  static SingletonClassic& instance()
  {
    std::cout << "instance()" << std::endl;
    static SingletonClassic inst;
    return inst;
  }

private:
  SingletonClassic()
  {
    std::cout << "SingletonClassic()" << std::endl;
  }
};


SingletonClassic, пример 1


Classic_Example1_correct.cpp
#include "SingletonClassic.h"
#include "Payload.h"

#include <memory>

class ClassicSingleThreadedUtility
{
public:
  ClassicSingleThreadedUtility()
  {
    // To ensure that singleton will be constucted before utility
    SingletonClassic<Payload>::instance();
  }

  ~ClassicSingleThreadedUtility()
  {
    auto &instance = SingletonClassic<Payload>::instance();
    for ( int i = 0; i < 100; ++i )
      instance.add(i);
  }
};


// 1. Create an empty unique_ptr
// 2. Create singleton (because of modified ClassicSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<ClassicSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<ClassicSingleThreadedUtility>();

// This guarantee destruction in order:
// - utilityUnique;
// - singleton;
// - emptyUnique.
// This order is correct


int main()
{
  return 0;
}


Вывод в консоль
instance()
SingletonClassic()
instance()
~SingletonClassic()

Утилита обращается в конструкторе к синглтону, чтобы гарантировать создание синглтона до создания утилиты.

Пользователь создаёт два std::unique_ptr: один пустой, второй — содержащий утилиту.

Порядок создания:

— пустой std::unique_ptr.
— синглтон;
— утилита.

И соответственно порядок уничтожения:

— утилита;
— синглтон;
— пустой std::unique_ptr.

Обращение из деструктора утилиты к синглтону корректно.

SingletonClassic, пример 2


Всё то же самое, но пользователь взял и одной строчкой всё испортил.

Classic_Example2_incorrect.cpp
#include "SingletonClassic.h"
#include "Payload.h"

#include <memory>


class ClassicSingleThreadedUtility
{
public:
  ClassicSingleThreadedUtility()
  {
    // To ensure that singleton will be constucted before utility
    SingletonClassic<Payload>::instance();
  }

  ~ClassicSingleThreadedUtility()
  {
    auto &instance = SingletonClassic<Payload>::instance();
    for ( int i = 0; i < 100; ++i )
      instance.add(i);
  }
};


// 1. Create an empty unique_ptr
// 2. Create singleton (because of modified ClassicSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<ClassicSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<ClassicSingleThreadedUtility>();

// This guarantee destruction in order:
// - utilityUnique;
// - singleton;
// - emptyUnique.
// This order seems to be correct ...


int main()
{
  // ... but user swaps unique_ptrs
  emptyUnique.swap(utilityUnique);

  // Guaranteed destruction order is still the same:
  // - utilityUnique;
  // - singleton;
  // - emptyUnique,
  // but now utilityUnique is empty, and emptyUnique is filled,
  // so destruction order is incorrect

  return 0;
}


Вывод в консоль
instance()
SingletonClassic()
~SingletonClassic()
instance()

Порядок создания и уничтожения сохранился. Казалось бы, всё по-прежнему. Но нет. Вызовом emptyUnique.swap(utilityUnique) пользователь учинил неопределённое поведение.

Зачем пользователь сделал такую глупость? Потому что он ничего не знает о внутреннем устройстве библиотеки, предоставившей ему синглтон и утилиту.

А если знать внутреннее устройство библиотеки?… то всё равно в реальном коде очень просто вляпаться. А выпутываться придётся путём мучительного дебага, т.к. понять, что же именно произошло, будет не просто.

А почему бы не потребовать использовать библиотеку правильно? Ну там доки всякие понаписывать, примеры… А почему бы не сделать такую библиотеку, которую не так просто будет испортить?

SingletonClassic, пример 3


В ходе подготовки статьи в течение нескольких дней я считал, что в слое утилиты невозможно устранить неопределённое поведение из прошлого примера, и решение доступно только в слое синглтона. Но со временем решение всё же придумалось.

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

Classic_Example3_correct.cpp
#include "SingletonClassic.h"
#include "Payload.h"

#include <memory>
#include <iostream>


class ClassicSingleThreadedUtility
{
public:
  ClassicSingleThreadedUtility()
  {
    thread_local auto flag_strong = std::make_shared<char>(0);
    m_flag_weak = flag_strong;

    SingletonClassic<Payload>::instance();
  }

  ~ClassicSingleThreadedUtility()
  {
    if ( !m_flag_weak.expired() )
    {
      auto &instance = SingletonClassic<Payload>::instance();
      for ( int i = 0; i < 100; ++i )
        instance.add(i);
    }
  }

private:
  std::weak_ptr<char> m_flag_weak;
};


// 1. Create an empty unique_ptr
// 2. Create singleton (because of modified ClassicSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<ClassicSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<ClassicSingleThreadedUtility>();

// This guarantee destruction in order:
// - utilityUnique;
// - singleton;
// - emptyUnique.
// This order seems to be correct ...


int main()
{
  // ... but user swaps unique_ptrs
  emptyUnique.swap(utilityUnique);

  {
    // To demonstrate normal processing before application ends
    auto utility = ClassicSingleThreadedUtility();
  }

  // Guaranteed destruction order is still the same:
  // - utilityUnique;
  // - singleton;
  // - emptyUnique,
  // but now utilityUnique is empty, and emptyUnique is filled,
  // so destruction order is incorrect ...
  // ... but utility uses a variable with thread storage duration to detect thread termination.

  return 0;
}


Вывод в консоль
instance()
SingletonClassic()
instance()
instance()
~SingletonClassic()

Пояснение
Проблема возникает только при сворачивании приложения. От неопределённого поведения можно избавиться, научив утилиту распознавать момент сворачивания приложения. Для этого использована переменная flag_strong типа std::shared_ptr, имеющая квалификатор thread storage duration (см. выдержки из стандарта выше в статье) — это как статик, но только уничтожается при завершении текущего потока до уничтожения любого из статиков, в том числе — до уничтожения синглтона. Переменная flag_strong — одна на весь поток, а каждый экземпляр утилиты хранит у себя её weak-копию.

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

SingletonShared


Переходим к рассмотрению модифицированного синглтона — основанного на std::shared_ptr.

SingletonShared.h
#pragma once

#include <memory>
#include <iostream>


template<typename T>
class SingletonShared : public T
{
public:
  ~SingletonShared()
  {
    std::cout << "~SingletonShared()" << std::endl;
  }

  SingletonShared(const SingletonShared &) = delete;
  SingletonShared(SingletonShared &&) = delete;

  SingletonShared& operator=(const SingletonShared &) = delete;
  SingletonShared& operator=(SingletonShared &&) = delete;

  static std::shared_ptr<SingletonShared> instance()
  {
    std::cout << "instance()" << std::endl;
    // "new" and no std::make_shared because of private c-tor
    static auto inst = std::shared_ptr<SingletonShared>(new SingletonShared);
    return inst;
  }

private:
  SingletonShared()
  {
    std::cout << "SingletonShared()" << std::endl;
  }
};


Ай-ай-ай, оператор new в современном коде использовать не следует, вместо него нужен std::make_shared! А этому мешает приватный конструктор синглтона.

Ха! Тоже мне проблема! Надо объявить std::make_shared фрэндом синглтона!… и получить разновидность антипаттерна PublicMorozov: с помощью того же самого std::make_shared можно будет насоздавать не предусмотренные архитектурой дополнительные экземпляры синглтона.

SingletonShared, примеры 1 и 2


Полностью соответствуют примерам №№1 и 2 для классического варианта. Значимые изменения внесены только в слой синглтона, утилита по сути осталась такой же. Так же, как в примерах с классическим синглтоном, пример-1 корректен, а пример-2 демонстрирует неопределённое поведение.

Shared_Example1_correct.cpp
#include "SingletonShared.h"
#include <Payload.h>

#include <memory>


class SharedSingleThreadedUtility
{
public:
  SharedSingleThreadedUtility()
  {
    // To ensure that singleton will be constucted before utility
    SingletonShared<Payload>::instance();
  }

  ~SharedSingleThreadedUtility()
  {
    if ( auto instance = SingletonShared<Payload>::instance() )
      for ( int i = 0; i < 100; ++i )
        instance->add(i);
  }
};


// 1. Create an empty unique_ptr
// 2. Create singleton (because of modified SharedSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<SharedSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<SharedSingleThreadedUtility>();

// This guarantee destruction in order:
// - utilityUnique;
// - singleton;
// - emptyUnique.
// This order is correct


int main()
{
  return 0;
}


Вывод в консоль
instance()
SingletonShared()
instance()
~SingletonShared()

Shared_Example2_incorrect.cpp
#include "SingletonShared.h"
#include "Payload.h"

#include <memory>


class SharedSingleThreadedUtility
{
public:
  SharedSingleThreadedUtility()
  {
    // To ensure that singleton will be constucted before utility
    SingletonShared<Payload>::instance();
  }

  ~SharedSingleThreadedUtility()
  {
    // Sometimes this check may result as "false" even for destroyed singleton
    // preventing from visual effects of undefined behaviour ...
    //if ( auto instance = SingletonShared::instance() )
    //  for ( int i = 0; i < 100; ++i )
    //    instance->add(i);

    // ... so this code will demonstrate UB in colour
    auto instance = SingletonShared<Payload>::instance();
    for ( int i = 0; i < 100; ++i )
      instance->add(i);
  }
};


// 1. Create an empty unique_ptr
// 2. Create singleton (because of modified SharedSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<SharedSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<SharedSingleThreadedUtility>();

// This guarantee destruction in order:
// - utilityUnique;
// - singleton;
// - emptyUnique.
// This order seems to be correct ...


int main()
{
  // ... but user swaps unique_ptrs
  emptyUnique.swap(utilityUnique);

  // Guaranteed destruction order is the same:
  // - utilityUnique;
  // - singleton;
  // - emptyUnique,
  // but now utilityUnique is empty, and emptyUnique is filled,
  // so destruction order is incorrect

  return 0;
}


Вывод в консоль
instance()
SingletonShared()
~SingletonShared()
instance()

SingletonShared, пример 3


А сейчас попытаемся починить эту проблему получше, чем в примере №3 из классики.
Решение очевидно: надо всего лишь продлить жизнь синглтона, прихранив в утилите копию std::shared_ptr, возвращённого синглтоном. И это решение в комплекте с SingletonShared широко растиражировано в открытых источниках.

Shared_Example3_correct.cpp
#include "SingletonShared.h"
#include "Payload.h"

#include <memory>


class SharedSingleThreadedUtility
{
public:
  SharedSingleThreadedUtility()
      // To ensure that singleton will be constucted before utility
      : m_singleton(SingletonShared<Payload>::instance())
  {
  }

  ~SharedSingleThreadedUtility()
  {
    // Sometimes this check may result as "false" even for destroyed singleton
    // preventing from visual effects of undefined behaviour ...
    //if ( m_singleton )
    //  for ( int i = 0; i < 100; ++i )
    //    m_singleton->add(i);

    // ... so this code will allow to demonstrate UB in colour
    for ( int i = 0; i < 100; ++i )
      m_singleton->add(i);
  }

private:
  // A copy of smart pointer, not a reference
  std::shared_ptr<SingletonShared<Payload>> m_singleton;
};


// 1. Create an empty unique_ptr
// 2. Create singleton (because of SharedSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<SharedSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<SharedSingleThreadedUtility>();


int main()
{
  // This guarantee destruction in order:
  // - utilityUnique;
  // - singleton;
  // - emptyUnique.
  // This order is correct ...
  // ... but user swaps unique_ptrs
  emptyUnique.swap(utilityUnique);

  // Guaranteed destruction order is the same:
  // - utilityUnique;
  // - singleton;
  // - emptyUnique,
  // but now utilityUnique is empty, and emptyUnique is filled,
  // so destruction order is incorrect...

  // ... but utility have made a copy of shared_ptr when it was available,
  // so it's correct again.

  return 0;
}


Вывод в консоль
instance()
SingletonShared()
~SingletonShared()

А теперь, внимание, вопрос: а Вы в самом деле хотели продлевать жизнь синглтона?
Или хотели избавиться от неопределённого поведения, а продление жизни выбрали как лежащий на поверхности способ?

Теоретическая некорректности в виде подмены целей средствами ведёт к риску возникновения deadlock (или cyclic reference — называйте, как хотите).

Да нуууууу, это как так надо постараться!? Даже специально такое долго придётся придумывать, а уж случайно точно не сделаешь!

CallbackPayload.h
#pragma once

#include <functional>


class CallbackPayload
{
public:
  CallbackPayload() = default;
  ~CallbackPayload() = default;

  CallbackPayload(const CallbackPayload &) = delete;
  CallbackPayload(CallbackPayload &&) = delete;

  CallbackPayload& operator=(const CallbackPayload &) = delete;
  CallbackPayload& operator=(CallbackPayload &&) = delete;

  void setCallback(std::function<void()> &&fn)
  {
    m_callbackFn = std::move(fn);
  }

private:
  std::function<void()> m_callbackFn;
};


SomethingWithVeryImportantDestructor.h
#pragma once

#include <iostream>


class SomethingWithVeryImportantDestructor
{
public:
  SomethingWithVeryImportantDestructor()
  {
    std::cout << "SomethingWithVeryImportantDestructor()" << std::endl;
  }
  ~SomethingWithVeryImportantDestructor()
  {
    std::cout << "~SomethingWithVeryImportantDestructor()" << std::endl;
  }

  SomethingWithVeryImportantDestructor(const SomethingWithVeryImportantDestructor &) = delete;
  SomethingWithVeryImportantDestructor(SomethingWithVeryImportantDestructor &&) = delete;

  SomethingWithVeryImportantDestructor& operator=(const SomethingWithVeryImportantDestructor &) = delete;
  SomethingWithVeryImportantDestructor& operator=(SomethingWithVeryImportantDestructor &&) = delete;
};


Shared_Example4_incorrect.cpp
#include "SingletonShared.h"
#include "CallbackPayload.h"
#include "SomethingWithVeryImportantDestructor.h"


class SharedSingleThreadedUtility
{
public:
  SharedSingleThreadedUtility()
      // To ensure that singleton will be constucted before utility
      : m_singleton(SingletonShared<CallbackPayload>::instance())
  {
    std::cout << "SharedSingleThreadedUtility()" << std::endl;
  }

  ~SharedSingleThreadedUtility()
  {
    std::cout << "~SharedSingleThreadedUtility()" << std::endl;
  }

  void setCallback(std::function<void()> &&fn)
  {
    if ( m_singleton )
      m_singleton->setCallback(std::move(fn));
  }

private:
  // A copy of smart pointer, not a reference
  std::shared_ptr<SingletonShared<CallbackPayload>> m_singleton;
};


int main()
{
  auto utility = std::make_shared<SharedSingleThreadedUtility>();
  auto something = std::make_shared<SomethingWithVeryImportantDestructor>();

  // lambda with "utility" and "something" captured
  utility->setCallback( [utility, something](){} );

  return 0;
}


Вывод в консоль
instance()
SingletonShared()
SharedSingleThreadedUtility()
SomethingWithVeryImportantDestructor()

Был создан синглтон.

Была создана утилита.

Было создано Нечто-С-Очень-Важным-Деструктором (это я для устрашения добавил, т.к. в интернетах встречаются посты типа «ну не будет вызван деструктор синглтона, ну и что из этого, он же всё равно должен существовать всё время работы программы»).

Но ни для одного из этих объектов не был вызван деструктор!

Из-за чего? Из-за подмены целей средствами.

SingletonWeak


SingletonWeak.h
#pragma once

#include <memory>
#include <iostream>


template<typename T>
class SingletonWeak : public T
{
public:
  ~SingletonWeak()
  {
    std::cout << "~SingletonWeak()" << std::endl;
  }

  SingletonWeak(const SingletonWeak &) = delete;
  SingletonWeak(SingletonWeak &&) = delete;

  SingletonWeak& operator=(const SingletonWeak &) = delete;
  SingletonWeak& operator=(SingletonWeak &&) = delete;

  static std::weak_ptr<SingletonWeak> instance()
  {
    std::cout << "instance()" << std::endl;
    // "new" and no std::make_shared because of private c-tor
    static auto inst = std::shared_ptr<SingletonWeak>(new SingletonWeak);
    return inst;
  }

private:
  SingletonWeak()
  {
    std::cout << "SingletonWeak()" << std::endl;
  }
};


Такая модификация синглтона в открытых источниках если и приводится, то точно не часто. Я встречал какие-то странные вывернутые наизнанку варианты с непонятно как применённым std::weak_ptr, которые, похоже, не предлагают утилите ничего другого, кроме как продлевать синглтону жизнь:


Предлагаемый же мной вариант при правильном применении в слоях синглтона и утилиты:

  • защищает от действий в пользовательском слое, рассмотренных в вышеприведённых примерах, в том числе предотвращает deadlock;
  • определяет момент свёртывания приложения точнее, чем применение thread_local в Classic_Example3_correct, т.е. позволяет ближе подойти к краю;
  • не страдает теоретической проблемой подмены целей средствами (я не знаю, может ли из этой теоретической проблемы появиться ещё что-нибудь осязаемое, кроме deadlock).

Однако есть и недостаток: продление жизни синглтону всё же может позволить ещё ближе подойти к краю.

SingletonWeak, пример 1


Аналогичен Shared_Example3_correct.cpp.

Weak_Example1_correct.cpp
#include "SingletonWeak.h"
#include "Payload.h"

#include <memory>


class WeakSingleThreadedUtility
{
public:
  WeakSingleThreadedUtility()
      // To ensure that singleton will be constucted before utility
      : m_weak(SingletonWeak<Payload>::instance())
  {
  }

  ~WeakSingleThreadedUtility()
  {
    // Sometimes this check may result as "false" even in case of incorrect usage,
    // and there's no way to guarantee a demonstration of undefined behaviour in colour
    if ( auto strong = m_weak.lock() )
      for ( int i = 0; i < 100; ++i )
        strong->add(i);
  }

private:
  // A weak copy of smart pointer, not a reference
  std::weak_ptr<SingletonWeak<Payload>> m_weak;
};


// 1. Create an empty unique_ptr
// 2. Create singleton (because of WeakSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<WeakSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<WeakSingleThreadedUtility>();


int main()
{
  // This guarantee destruction in order:
  // - utilityUnique;
  // - singleton;
  // - emptyUnique.
  // This order is correct ...
  // ... but user swaps unique_ptrs
  emptyUnique.swap(utilityUnique);

  // Guaranteed destruction order is the same:
  // - utilityUnique;
  // - singleton;
  // - emptyUnique,
  // but now utilityUnique is empty, and emptyUnique is filled,
  // so destruction order is incorrect...

  // ... but utility have made a weak copy of shared_ptr when it was available,
  // so it's correct again.

  return 0;
}


Вывод в консоль
instance()
SingletonWeak()
~SingletonWeak()

Зачем нужен SingletonWeak, ведь никто не мешает утилите использовать SingletonShared как SingletonWeak? Да, никто не мешает. И даже никто не мешает утилите использовать SingletonWeak как SingletonShared. Но использовать их по назначению чуть проще, чем использовать не по назначению.

SingletonWeak, пример 2


Аналогичен Shared_Example4_incorrect, но только deadlock в данном случае не возникает.

Weak_Example2_correct.cpp
#include "SingletonWeak.h"
#include "CallbackPayload.h"
#include "SomethingWithVeryImportantDestructor.h"


class WeakSingleThreadedUtility
{
public:
  WeakSingleThreadedUtility()
      // To ensure that singleton will be constucted before utility
      : m_weak(SingletonWeak<CallbackPayload>::instance())
  {
    std::cout << "WeakSingleThreadedUtility()" << std::endl;
  }

  ~WeakSingleThreadedUtility()
  {
    std::cout << "~WeakSingleThreadedUtility()" << std::endl;
  }

  void setCallback(std::function<void()> &&fn)
  {
    if ( auto strong = m_weak.lock() )
      strong->setCallback(std::move(fn));
  }

private:
  // A weak copy of smart pointer, not a reference
  std::weak_ptr<SingletonWeak<CallbackPayload>> m_weak;
};



int main()
{
  auto utility = std::make_shared<WeakSingleThreadedUtility>();
  auto something = std::make_shared<SomethingWithVeryImportantDestructor>();

  // lambda with "utility" and "something" captured
  utility->setCallback( [utility, something](){} );

  return 0;
}


Вывод в консоль
instance()
SingletonWeak()
WeakSingleThreadedUtility()
SomethingWithVeryImportantDestructor()
~SingletonWeak()
~SomethingWithVeryImportantDestructor()
~WeakSingleThreadedUtility()

Вместо заключения


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

Shared_Example5_incorrect.cpp
#include "SingletonShared.h"
#include "Payload.h"

#include <memory>
#include <cstdlib>


class SharedSingleThreadedUtility
{
public:
  SharedSingleThreadedUtility()
      // To ensure that singleton will be constucted before utility
      : m_singleton(SingletonShared<Payload>::instance())
  {
  }

  ~SharedSingleThreadedUtility()
  {
    // Sometimes this check may result as "false" even for destroyed singleton
    // preventing from visual effects of undefined behaviour ...
    //if ( m_singleton )
    //  for ( int i = 0; i < 100; ++i )
    //    m_singleton->add(i);

    // ... so this code will allow to demonstrate UB in colour
    for ( int i = 0; i < 100; ++i )
      m_singleton->add(i);
  }

private:
  // A copy of smart pointer, not a reference
  std::shared_ptr<SingletonShared<Payload>> m_singleton;
};


void cracker()
{
  SharedSingleThreadedUtility();
}


// 1. Register cracker() using std::atexit
// 2. Create singleton
// 3. Create utility
auto reg = [](){ std::atexit(&cracker); return 0; }();
auto utility = SharedSingleThreadedUtility();

// This guarantee destruction in order:
// - utility;
// - singleton.
// This order is correct.
// Additionally, there's a copy of shared_ptr in the class instance...
// ... but there was std::atexit registered before singleton,
// so cracker() will be invoked after destruction of utility and singleton.
// There's second try to create a singleton - and it's incorrect.


int main()
{
  return 0;
}


Вывод в консоль
instance()
SingletonShared()
~SingletonShared()
instance()

Weak_Example3_incorrect.cpp
#include "SingletonWeak.h"
#include "Payload.h"

#include <memory>
#include <cstdlib>


class WeakSingleThreadedUtility
{
public:
  WeakSingleThreadedUtility()
      // To ensure that singleton will be constucted before utility
      : m_weak(SingletonWeak<Payload>::instance())
  {
  }

  ~WeakSingleThreadedUtility()
  {
    // Sometimes this check may result as "false" even in case of incorrect usage,
    // and there's no way to guarantee a demonstration of undefined behaviour in colour
    if ( auto strong = m_weak.lock() )
      for ( int i = 0; i < 100; ++i )
        strong->add(i);
  }

private:
  // A weak copy of smart pointer, not a reference
  std::weak_ptr<SingletonWeak<Payload>> m_weak;
};


void cracker()
{
  WeakSingleThreadedUtility();
}


// 1. Register cracker() using std::atexit
// 2. Create singleton
// 3. Create utility
auto reg = [](){ std::atexit(&cracker); return 0; }();
auto utility = WeakSingleThreadedUtility();

// This guarantee destruction in order:
// - utility;
// - singleton.
// This order is correct.
// Additionally, there's a copy of shared_ptr in the class instance...
// ... but there was std::atexit registered before singleton,
// so cracker() will be invoked after destruction of utility and singleton.
// There's second try to create a singleton - and it's incorrect.


int main()
{
  return 0;
}


Вывод в консоль
instance()
SingletonWeak()
~SingletonWeak()
instance()

Комментарии (15)


  1. berez
    17.06.2019 16:01

    Заголовок слегка вводит в заблуждение: я сначала подумал, что имеется в виду классическая бяка про одновременное создание синглтона из двух тредов, а у вас — про преждевременное удаление синглтона.
    Кстати, а если в паре с синглтоном использовать Schwarz Counter? Синглон удалится после того, как отработают деструкторы статических объектов для всех единиц трансляции, в которых используется синглтон — причем сам синглтон можно сделать даже на сыром указателе. Разве это не оно самое?


    1. Dubovik_a Автор
      17.06.2019 16:24

      С одновременным созданием из разных потоков проблемы как бы нет совсем — стандарт гарантирует отсутствие гонки. Если интересно — дам ссылки на пункты стандарта. Хотя это и выходит за рамки статьи.
      Просто с созданием — тоже нет проблемы, т.к. сам по себе паттерн «Синглтон» именно и направлен на то, чтобы статик инициализировался при первом обращении.
      А вот с уничтожением — просто беда. Говорю из личного опыта: примерно половина случаев применения синглтона требовала дебага, т.к. при сворачивании приложения происходил крэш. В статье показано, что, если о возможных проблемах не задумываться, то оно примерно так и будет, т.к. зависит от казалось бы незначительных факторов.

      «The 'nifty counter' or 'Schwarz counter' idiom is an example of a reference counting idiom». Идиома подсчёта ссылок уже реализована в STL. Причём в дополнение к std::shared_ptr есть еще и std::weak_ptr, который позволяет делать очень замечательные вещи. В статье полезность std::weak_ptr я раскрыл аж в двух примерах — последний пример из классики, и корректный пример из SingletonWeak. Кроме того, std::shared_ptr гарантирует потокобезопасность блока подсчёта ссылок (хотя, опять же, это выходит за рамки статьи). Итого, упомянутая Вами идиома:
      — требует ручной реализации того, что уже есть в STL;
      — чтобы сделать её потокобезопасной, придётся попотеть;
      — предполагает только продление жизни, недостатки которого я раскрыл достаточно подробно.


      1. Dubovik_a Автор
        17.06.2019 16:48

        Чуть подробнее вчитался в код идиомы Шварца.


        1. Dubovik_a Автор
          17.06.2019 17:24

          Там используется другая идея — разделение компилятором инициализации статиков на две фазы: статическую и динамическую. Преимуществ относительно применения STL не просматривается.


          1. berez
            18.06.2019 00:41

            А разве при использовании weak pointer вызывающий код не должен каждый раз вызывать lock() и проверять результат? С удалением синглтона по счетчику пойнтер можно использовать сразу. Правда, там под капотом другого оверхеда дофига. :)


            1. Dubovik_a Автор
              18.06.2019 08:31

              Не понял вопроса. Уточните.


              1. berez
                20.06.2019 09:35

                Использование синглтона, возвращающего сырой указатель или shared_ptr:

                Singleton::instance()->use();

                При этом валидность указателя обеспечивается за счет счетчика Шварца.

                Использование синглтона, возвращающего weak_ptr:
                if (auto ptr = Singleton::instance().lock())
                     ptr->use();
                else
                    // протух

                Это ж в каждом месте, где используется синглтон, придется городить if.


                1. Dubovik_a Автор
                  20.06.2019 10:13

                  Если Вы это приводите, как аргумент в пользу счётчика Шварца — то какой-то он очень уж слабенький. Вообще к любому указателю, даже сырому, лучше не обращаться без проверки.
                  Счётчик Шварца является старомодной идиомой на сырых указателях без потокобезопасности, использующей для той же задачи несколько объектов со static storage duration. Возможно, его тоже можно улучшить с применением современного STL — напишите свою статью об этом, там и обсудим более подробно. В нём намного больше магии, чем в синглтонах Мейерса и их модификациях, рассмотренных в статье. Например (код отсюда):

                  static struct StreamInitializer {
                    StreamInitializer ();
                    ~StreamInitializer ();
                  } streamInitializer; // static initializer for every translation unit
                  

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


                  1. berez
                    20.06.2019 12:01

                    Если углубляться в область «какие ошибки можно сделать» — мы погрязнем в придуманных ситуациях. Так-то любой синглтон можно реализовать криво или удалить объект по указателю напрямую. :)

                    Собственно, я не настаиваю на том, что счетчик — это панацея (и примерно представляю, когда он не сработает). Просто вспомнился как вариант.


                    1. Dubovik_a Автор
                      20.06.2019 13:38

                      Вариант, да.
                      Но в плане реальной применимости:
                      — реализовать идиому Шварца по памяти через месяц после прочтения — вряд ли реально, а приведённые мной модификации — вполне;
                      — как поддерживать код, для правильного функционирования которого требуется столько весьма точно выполненных ритуалов?
                      — требуемый для корректировки синглтона Мейерса механизм (умные указатели) УЖЕ давно стандартизован, и отказываться от него в пользу самодельного велосипеда — ну я прямо даже не знаю…
                      И самое главное — ради чего? Где преимущества?


  1. Dubovik_a Автор
    17.06.2019 16:24

    Не туда ответил.


  1. F376
    17.06.2019 19:29

    Статья и предлагаемые решения замечательна, но существует все же более удачное решение. Исходное решение заключается в опоре на детали, на стандарты, в опоре на договорное поведение (которое на некоторых платформах в некоторых случаях все же нарушается) и прочей зависимости от «хитроумных», скрытых и договорных деталей.
    Так делать не надо.
    Поощряет к этому по моему мнению еще то, что последние стандарты C++ — с недостаточно ясными целями пытаются взвалить на себя (обернуть) OS, hardware и платформенно-специфичные механизмы, которые обычно уже не один десяток лет решены чисто и строго.

    Некоторые индустриальные стандарты (MISRA) не поощряют конструирование систем на подобных принципах.

    Многие индустриальные решения (RTOS-системы) в явном виде содержат фреймворки содержащие внутри себя init-active-deinit фазы жизни модулей/объектов итд.

    У множественных синглтонов существует генеральный недостаток:
    отсутствие единого централизованного управления (скрытый менеджмент, отсутствие управляемости, разнесенный, «размазанный» код).

    Вместо подобных конструктивов жизнь в итоге научила меня проектировать все свои проекты/системы с учетом трёх фаз «жизни»:
    1. Инициализация.
    2. Рабочее состояние.
    3. Деинициализация.

    Внутри проектов могут существовать синглтоны, но по другим причинам — синглтон я рассаматриваю как вырожденую «фабрику», всегда отдающую один и тот же объект для того чтобы сделать код независимым от конкретных типов. Однако, все эти синглтоны (сам тип) инициализируются и деинициализируются строго в специально предназначенное для этого время. Инициализация/деинициализация недоступна обычному (его можно называть «user-level») коду. Дополнительным преимуществом является то что «user-level» слой кода всегда гарантированно получит instance, проверять ему не требуется, иного быть не может.

    Возникает вопрос, но как быть если требуется динамически создавать тяжеловесные объекты по ходу жизни? Эти задачи решаются не синглтоном (не разбросанными по проекту и проектам синглтонами), а фабрикой. Фабрики построены на тех же принципах времени жизни что и тип синглтона, инициализируются строго до запуска слоя «user-level» кода и деинициализируются после него. Но кроме этого фабрики (или одна фабрика) осуществляют внутренний трекинг создаваемых объектов, что позволяет им гарантированно освободить их на фазе деинициализации.

    Желаю всем управляемого кода.


    1. Dubovik_a Автор
      17.06.2019 19:43

      Ну я же просил — не выходить за рамки статьи. Она НЕ об архитекуре вообще. Она НЕ о недостатках синглтона как архитектурного решения.Она НЕ о возможных альтернативах. Может быть немного она «о возможных последствиях нескольких синглтонов в одной программе, и что с этим по-быстрому сделать, чтобы не перекраивать архитектуру».
      Опять же, поднятые в статье вопросы, если Вы не заметили (допускаю, что статью толком не прочитали), как раз приближают к:

      проектировать все свои проекты/системы с учетом трёх фаз «жизни»
      и
      Инициализация/деинициализация недоступна обычному (его можно называть «user-level») коду

      Ещё более неуместно в данном контексте выглядит критика стандарта, если честно.


  1. masterspline
    20.06.2019 06:22

    Уже довольно давно, когда вижу термин синглтон, думаю про синглтон Майерса. Останутся ли с ним описанные проблемы?


    1. Dubovik_a Автор
      20.06.2019 07:57

      Да, весь материал статьи основан именно на синглтоне Мейерса и его модификациях.