Pull to refresh

Техника избежания неопределённого поведения при обращении к синглтону

Reading time 21 min
Views 12K
В статье рассмотрены причины возникновения и способы избежания неопределённого поведения при обращении к синглтону в современном 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()
Tags:
Hubs:
+21
Comments 17
Comments Comments 17

Articles