Введение

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

Итак, представим, что у нас есть некоторые данные Info, которые можно получить из базы данных. Эти данные используются в разных частях программы и не изменяются во время её выполнения. Похоже, это идеальный кандидат для кэширования с помощью Singleton.

Реализация

Набираем Синглтон Майерса.

Не используйте в современном С++ реализацию, приведенную в "Design Patterns" GoF, она имеет много проблем, в частности, data race в многопоточных программах.

class GlobalInfo final
{
public:
   static const GlobalInfo& getInstance() 
   {
      static GlobalInfo instance;
      return instance;
   }

   [[nodiscard]]
   const Info& info() const noexcept
   {
      return info_;
   }
private:
   GlobalInfo() :
      info_(getInfo()) { }
  
   GlobalInfo(const GlobalInfo&) = delete;
   GlobalInfo(GlobalInfo&&) noexcept = delete;
   GlobalInfo& operator=(const GlobalInfo&) = delete;
   GlobalInfo& operator=(GlobalInfo&&) noexcept = delete;
   
   Info info_;
};

И используем.

int proccessInfo()
{
   const auto& info = GlobalInfo::getInstance().info();
   // ...
}

Вроде всё хорошо, и это даже работает, но... тут мы решаем покрыть proccessInfo юнит-тестами.

Упс, функция proccessInfo неявно зависит от GlobalInfo, который невозможно инициализировать в тестовой среде.

Существуют разные способы обойти данную проблему, например, принимать info в качестве аргумента. Но давайте не будем менять сигнатуру proccessInfo, а просто добавим еще одну абстракцию.

Relationships
Relationships

Скажем, что в GlobalInfo находятся не просто глобальные данные, а глобальные данные по умолчанию. А доступ к текущем глобальным данным будем предоставлять через функции getGlobalInfo и setGlobalInfo. Теперь proccessInfo ничего не знает о GlobalInfo и зависит только от интерфейса getGlobalInfo, что уменьшает связность.

Далее я буду использовать глобальные переменные и свободные функции, что не меняет сути, просто мне так больше нравится ?.

Перепишем код.

// global_info.h
// ...

[[nodiscard]]
const Info& getDefaultInfo();

[[nodiscard]]
const Info& getGlobalInfo();

void setGlobalInfo(const Info* new_info) noexcept;
// global_info.cpp
// ...
const Info& getDefaultInfo()
{
   static Info instance = getInfo();
   return instance;
}

constinit std::atomic<const Info*> global_info;

const Info& getGlobalInfo()
{	
   const auto* instance = global_info.load(std::memory_order_relaxed);

   if(!instance) [[unlikely]]
   {
      const auto* default_info = std::addressof(getDefaultInfo());
      global_info.compare_exchange_weak(instance, default_info, 
                                        std::memory_order_relaxed);
      return *default_info;
   }

   return *instance;
}

void setGlobalInfo(const Info* new_info) noexcept
{
   global_info.store(new_info, std::memory_order_relaxed);
}

И используем его.

int proccessInfo() 
{
   const auto& info = getGlobalInfo(); 
   // ... 
}

Теперь функция proccessInfo может быть покрыта юнит-тестами. Для этого достаточно в тестовой среде установить Mock-объект с помощью setGlobalInfo.

Хорошо, но что если кто-то решит установить в setGlobalInfo указатель на объект, который не доживет до конца использования результата getGlobalInfo?

Существует два решения.

Можно просто написать контракт для данной функции в документации. Например, объект, на который указывает new_info при установке значения через setGlobalInfo, должен пережить все вызовы getGlobalInfo, иначе поведение не определено.

Но давайте подумаем, а что на самом деле требуется от new_info? Мы хотим, чтобы это были данные из синглтона. А синглтон это скорее всего функция или объект со статической функцией, который возвращает ссылку на Info. Давайте так и запишем.

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

// global_info.h
// ...

using InfoHandler = const Info& (*)();

[[nodiscard]]
const Info& getDefaultInfo();

[[nodiscard]]
const Info& getGlobalInfo();

void setGlobalInfo(InfoHandler info_handler) noexcept;
// global_info.cpp
// ...
const Info& getDefaultInfo()
{
   static Info instance = getInfo();
   return instance;
}

constinit std::atomic<const Info& (*)()> global_info;

const Info& getGlobalInfo()
{	
   auto instance = global_info.load(std::memory_order_relaxed);

   if(!instance) [[unlikely]]
   {
      global_info.compare_exchange_weak(instance, getDefaultInfo, 
                                        std::memory_order_relaxed);
      return getDefaultInfo();
   }

   return instance();
}

void setGlobalInfo(InfoHandler info_handler) noexcept
{
   global_info.store(info_handler, std::memory_order_relaxed);
}

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

А что насчет производительности?

Сразу хочу отметить, что в данной статье нет попытки сделать самый быстрый синглтон, в противном случае все бы было в хедерах.
Цель — сделать синглтон, который является оптимальным и по производительности, и по практичности.

Я просто взял Google Benchmark и вызывал info/getGlobalInfo в нескольких потоках и вот что получилось.

Performance
Performance

Simple singleton — Синглтон Майерса.
Value singleton — синглтон с getGlobalInfo, хранящий указатель на данные.
Function singleton — синглтон с getGlobalInfo, хранящий указатель на функцию.

Некоторые варианты с продлением жизни объекта, установленного с помощью setGlobalInfo, такие как std::atomic<std::shared_ptr>, std::shared_ptr + std::mutex, были отброшены, так как давали замедление в 30-500 раз.

Учитывая, что время работы вызова ~1 ns и велика погрешность, нельзя утверждать, что синглтон с getGlobalInfo, хранящий указатель на данные, быстрее, чем классический синглтон. Но из графика видно, что они имеют примерно одинаковую производительность.

Кроме того, можно сделать некоторые оценки на основе кода. Рассмотрим получение уже инициализированных по умолчанию данных.

  • При получении данных из классического синглтона имеем: 1 проверка на то, является ли статическая переменная инициализированной; 1 чтение по адресу.

  • При получении данных из синглтона с дополнительной функцией, хранящей указатель на данные, имеем: 1 проверка на то, является ли атомарная переменная инициализированной; 1 чтение по адресу.

  • При получении данных из синглтона с дополнительной функцией, хранящей указатель на функцию, имеем: 1 проверки на то, является ли атомарная переменная инициализированной; 1 проверка на то, является ли статическая переменная инициализированной; 1 дополнительный вызов функции по указатели; 1 чтение по адресу.

Таким образом, удалось сохранить тестируемость кода без потери производительности на абстракцию.

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


  1. sergio_nsk
    29.04.2024 21:28
    +2

    находяться

    Как можно было это написать? Ведь это даже не предлагается мобильной клавиатурой.

    мне так больше нравиться

    Плохо, но хотя бы можно свалить вину на авто-завершение слова.

    Не используйте в современном С++ реализацию, приведенную в "Design Patterns" GoF, она имеет много проблем, в частности, data race в многопоточных программах.

    Поподробней можно, где там data race.


    1. a75 Автор
      29.04.2024 21:28

      Плохо, но хотя бы можно свалить вину на авто-завершение слова.

      Спасибо, исправил.

      Поподробней можно, где там data race.

      В книге "Design Patterns" приводиться следующий пример.

      class Singleton {
      public:
         static Singleton* Instance();
      protected:
         Singleton();
      private:
         static Singleton* _instance;
      };
      Singleton* Singleton::Instance () {
         if (_instance == 0) {
            _instance = new Singleton;
         }
         return _instance;
      }

      Как известно, одновременное чтение и запись одной и той же неатомарной переменной из разных потоков — data race (оно же UB). Что и происходит, если поток 1 записывает _instance (строка 3), а поток 2 читает (строка 2).

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

      Мне больше нравится такое написание (код ниже), которое, как ни странно, встречается нередко.

      Singleton* Singleton::Instance() 
      {
         if (!instance) 
         {
            std::lock_guard<std::mutex> lock(mutex);
            instance = new Singleton;
         }
         return instance;
      }

      Что автор хочет этим сказать? Что это можно использовать в многопоточной программе или зачем тогда lock_guard?

      По факту, здесь тот же data race.


      1. sergio_nsk
        29.04.2024 21:28

        Это вообще другой, ничего похожего с синглтоном в статье.

        И опять приводиться. Что сложного в проверке "что делает"?


        1. a75 Автор
          29.04.2024 21:28

          Это синглтон.

          И в статье как раз сказано, что синглтона из GoF не будет, так как ...


      1. andreygn
        29.04.2024 21:28

        У вас тоже гонка. После захвата мьютекса нужно опять проверить на существование объекта (его мог уже другой поток создать). Нужен классический дабл чек.

        Вообще синглтон зло. Причем не только потому что нарушает S в SOLID. Лучше что-то вроде дипенденси инвершион контейнера, фабрики бинов (аля спринг) или ещё чего-нибудь подобного.


        1. a75 Автор
          29.04.2024 21:28

          У вас тоже гонка.

          Где? В статье не используется mutex вообще. Если речь про комментарий, то там так и написано, что это гонка.

          Singleton* Singleton::Instance() 
          {
             if(!instance) 
             {
                std::lock_guard<std::mutex> lock(mutex);
                if(!instance)
                   instance = new Singleton;
             }
             return instance;
          }

          С точки зрения гонки этот код ничем не лучше того, что был в предыдущем комментарии.


  1. a75 Автор
    29.04.2024 21:28

    -