Последнее обновление:2026-10-13
Паттерн «одиночка» в C++¶
0. Введение¶
Наверняка никому не избежать на собеседованиях темы паттернов проектирования. В этом разделе разбирается самый популярный на собеседованиях паттерн — «одиночка» (singleton): его разновидности, потокобезопасность и решения потокобезопасного синглтона как без использования стандарта C++11, так и на его основе. Внимательно прочитайте эту статью — и на будущих собеседованиях вам беспокоиться будет не о чем.
1. Широко известный синглтон¶
В обычных книгах, или как принято считать, паттерн «одиночка» выглядит так:
class singleton {
private:
singleton() {}
static singleton *p;
public:
static singleton *instance();
};
singleton *singleton::p = nullptr;
singleton* singleton::instance() {
if (p == nullptr)
p = new singleton();
return p;
}
Это очень простая реализация: конструктор объявляется private или protected, чтобы внешние функции не могли создать экземпляр; внутри статический указатель класса хранит единственный экземпляр; создание экземпляра выполняется public-методом, который и возвращает единственный экземпляр класса.
Разумеется, этот код годится только для однопоточного использования; при многопоточности он небезопасен. Представьте, что два потока впервые одновременно вызывают метод instance и оба обнаруживают, что p равно nullptr: тогда оба потока одновременно создадут экземпляр и запишут его в p, что нарушает принцип синглтона.
2. «Ленивая» и «жадная» инициализация¶
Синглтон реализуется двумя способами:
- Ленивая инициализация
- Экземпляр создаётся при первом использовании; приведённый выше вариант — ленивая реализация.
- Жадная инициализация
- Экземпляр создаётся в момент определения класса-синглтона.
Приведём и жадную реализацию:
class singleton {
private:
singleton() {}
static singleton *p;
public:
static singleton *instance();
};
singleton *singleton::p = new singleton();
singleton* singleton::instance() {
return p;
}
Разумеется, она потокобезопасна. Обычно же как небезопасную для потоков описывают ленивую разновидность; ниже мы разберём оптимизации кода ленивого синглтона для потокобезопасности.
3. Блокировка для многопоточности¶
В C++ есть класс для блокировки, принцип реализации которого основан на RAII и который не требует ручного управления unlock — это lock_guard; воспользуемся им для блокировки.
class singleton {
private:
singleton() {}
static singleton *p;
static mutex lock_;
public:
static singleton *instance();
};
singleton *singleton::p = nullptr;
singleton* singleton::instance() {
lock_guard<mutex> guard(lock_);
if (p == nullptr)
p = new singleton();
return p;
}
При такой записи не может случиться описанная выше ситуация, когда оба потока выполняются внутри ветки p == nullptr. Пока поток A выполняет p = new Singleton(), если поток B вызовет instance(), он гарантированно заблокируется на захвате мьютекса и будет ждать, пока поток A не завершит работу и не освободит его. Следовательно, это потокобезопасно.
Но такая запись крайне неэффективна, поскольку при каждом вызове instance() выполняется захват и освобождение мьютекса, а нужен этот шаг только для первого new Singleton(). Как только p создан, сколько бы потоков ни обращалось одновременно, достаточно проверки if (p == nullptr) (это лишь операция чтения, блокировка не нужна); проблемы потокобезопасности нет, а после добавления блокировки возникает как раз проблема производительности.
Отсюда появляется DCL.
4. Паттерн двойной проверки блокировки (DCLP)¶
Вариант выше при любых обстоятельствах захватывает и освобождает мьютекс, хотя для операций чтения проблемы потокобезопасности нет; значит, блокировка нужна только при первом создании экземпляра, а дальше — никогда. Сначала посмотрим на реализацию DCLP:
singleton* singleton::instance() {
if(p == nullptr) { // 第一次检查
Lock lock;
if(p == nullptr){ // 第二次检查
p = new singleton;
}
}
return p;
}
На основе сказанного можно написать двойную проверку с блокировкой + автоматическое освобождение
class singleton {
private:
singleton() {}
static singleton *p;
static mutex lock_;
public:
static singleton *instance();
// 实现一个内嵌垃圾回收类
class CGarbo
{
public:
~CGarbo()
{
if(singleton::p)
delete singleton::p;
}
};
static CGarbo Garbo; // 定义一个静态成员变量,程序结束时,系统会自动调用它的析构函数从而释放单例对象
};
singleton *singleton::p = nullptr;
singleton::CGarbo Garbo;
std::mutex singleton::lock_;
singleton* singleton::instance() {
if (p == nullptr) {
lock_guard<mutex> guard(lock_);
if (p == nullptr)
p = new singleton();
}
return p;
}
Суть DCLP в том, что большинство вызовов instance увидят p ненулевым и даже не попытаются его инициализировать. Поэтому DCLP перед попыткой захвата мьютекса проверяет, нулевой ли p. Только если проверка успешна (то есть p ещё не инициализирован), он захватывает мьютекс, а затем снова проверяет, остался ли p нулевым (отсюда и название — двойная проверка с блокировкой). Вторая проверка необходима, потому что, как мы только что видели, вполне вероятно, что другой поток случайно инициализирует p после первой проверки, но до успешного захвата мьютекса.
Код выше выглядит прекрасно, однако лишь по прошествии немалого времени в нём обнаружили изъян. Причина: переупорядочивание чтений и записей памяти (проблема компилятора).
Рассмотрим ещё раз строку, инициализирующую p:
Этот оператор влечёт три события:
- Выделить память, способную хранить объект
singleton; - Сконструировать объект
singletonв выделенной памяти; - Заставить
pуказывать на эту выделенную память.
Можно подумать, что эти три шага выполняются по порядку, но на деле гарантировано лишь то, что шаг 1 выполняется первым; шаги 2 и 3 — не обязательно. В этом и кроется проблема.
- Поток A вызывает instance, выполняет первую проверку p, захватывает мьютекс, выполняет шаги 1 и 3, после чего приостанавливается. В этот момент p уже не нулевой, но в памяти, на которую указывает p, объект Singleton ещё не сконструирован.
- Поток B вызывает instance, видит p ненулевым и возвращает его вызывающей стороне. Та разыменовывает указатель, чтобы получить singleton — и опа, объект, который ещё не был сконструирован. Вот и баг.
DCLP работает корректно, только если шаги один и два выполняются перед шагом три, но выразить такое ограничение в C или C++ невозможно. Это как кинжал в сердце DCLP: нам нужно наложить ограничения на относительный порядок инструкций, но наш язык не даёт способа выразить такие ограничения.
5. Инструкция memory barrier¶
Проблема DCLP была решена в C++11.
Поскольку новый C++11 определил новую модель памяти, гарантируется, что при выполнении указанных трёх шагов переключения потоков не произойдёт — процесс инициализации фактически является «атомарной» операцией, и DCL снова можно использовать корректно. Впрочем, в C++11 существует и более лаконичный способ написать многопоточный синглтон — о нём расскажем позже.
До C++11 решением была инструкция barrier. Чтобы всё выполнялось корректно, между шагами 2 и 3 нужно поставить memory barrier, вынуждая CPU выполнять шаги строго в порядке 1, 2, 3.
Первая реализация:
На основе operator new + placement new, код пишется последовательно в порядке выполнения 1, 2, 3.
// method 1 operator new + placement new
singleton *instance() {
if (p == nullptr) {
lock_guard<mutex> guard(lock_);
if (p == nullptr) {
singleton *tmp = static_cast<singleton *>(operator new(sizeof(singleton)));
new(tmp)singleton();
p = tmp;
}
}
return p;
}
Вторая реализация:
На основе непосредственной вставки ASM-инструкции mfence; макрос barrier в unix тоже реализован через эту инструкцию.
#define barrier() __asm__ volatile ("lwsync")
singleton *singleton::instance() {
if (p == nullptr) {
lock_guard<mutex> guard(lock_);
barrier();
if (p == nullptr) {
p = new singleton();
}
}
return p;
}
Обычно вызывается одна инструкция, предоставляемая CPU; её роль — не позволять CPU переставлять инструкции, стоящие до неё, после неё; такую инструкцию обычно и называют barrier. В коде выше asm означает, что это ассемблерная инструкция; volatile необязателен — если он использован, то это объявление компилятору, что оптимизировать эту ассемблерную инструкцию не разрешено. lwsync — инструкция barrier, предоставляемая POWERPC.
6. Статические локальные переменные¶
Скотт Мейерс в «Effective C++» предложил лаконичный способ записи синглтона
- В однопоточном режиме — корректно.
- В многопоточном режиме в C++11 и более поздних версиях (например, C++14) — корректно.
- В многопоточном режиме до C++11 — не обязательно корректно.
Причина в том, что в стандартах до C++11 модель памяти для локальных static-переменных не была определена, поэтому он не был потокобезопасным. В C++11 же он потокобезопасен, потому что новый стандарт C++ требует: пока один поток инициализирует переменную, остальные потоки обязаны дождаться завершения инициализации, прежде чем получить к ней доступ.
Использованные выше порядки памяти:
- memory_order_relaxed: ослабленный порядок памяти; гарантирует лишь атомарность операций над атомарным объектом
- memory_order_acquire: операция получения; при чтении атомарного объекта никакие последующие чтения или записи текущего потока не могут быть переупорядочены до этой операции, и все записи памяти, сделанные другими потоками до освобождения того же атомарного объекта, становятся видимыми текущему потоку
- memory_order_release: операция освобождения; при записи в атомарный объект никакие предшествующие чтения или записи текущего потока не могут быть переупорядочены после этой операции, и все записи памяти текущего потока становятся видимыми другим потокам, которые получают тот же атомарный объект
7. Atomic¶
В версиях до C++11, помимо реализации потокобезопасного Singleton с помощью блокировок, можно использовать встроенные в компиляторы atomic operation.
Обнаружив проблему переупорядочивания, java и c# добавили ключевое слово volatile: при объявлении переменной p нужно указать квалификатор volatile, и, увидев его, компилятор понимает, что здесь переупорядочивать нельзя (сначала выделить память, затем выполнить конструктор и только после всего этого выполнить присваивание).
Стандарт c++ же долго не исправляли, поэтому VC++ добавил это ключевое слово в версии 2005, но оно не является кроссплатформенным (поддерживаются только платформы Microsoft).
А в c++ 11, чтобы устранить эти дыры в корне, была введена подходящая для многопоточности модель памяти. Наконец-то появился механизм, помогающий реализовать переносимое решение.
mutex singleton::lock_;
atomic<singleton *> singleton::p;
/*
* std::atomic_thread_fence(std::memory_order_acquire);
* std::atomic_thread_fence(std::memory_order_release);
* 这两句话可以保证他们之间的语句不会发生乱序执行。
*/
singleton *singleton::instance() {
singleton *tmp = p.load(memory_order_relaxed);
atomic_thread_fence(memory_order_acquire);
if (tmp == nullptr) {
lock_guard<mutex> guard(lock_);
tmp = p.load(memory_order_relaxed);
if (tmp == nullptr) {
tmp = new singleton();
atomic_thread_fence(memory_order_release);
p.store(tmp, memory_order_relaxed);
}
}
return p;
}
Примечательно, что в коде выше используются два довольно ключевых термина — получение и освобождение:
- Получение — это операция чтения памяти; никакие последующие чтения или записи текущего потока не могут быть переупорядочены до неё.
- Освобождение — это операция записи в память; никакие предшествующие чтения или записи текущего потока не могут быть переупорядочены после неё.
acquire и release обычно появляются в паре, и цель этого — гарантировать, что если освобождение (release) одного и того же атомарного объекта происходит до получения (acquire), то все изменения памяти, случившиеся до release, будут полностью видны чтениям памяти после acquire.
8. pthread_once¶
На unix-платформах, помимо atomic operation, если C++11 недоступен, Singleton можно реализовать и через pthread_once.
Прототип таков:
Реализация:
class singleton {
private:
singleton(); //私有构造函数,不允许使用者自己生成对象
singleton(const singleton &other);
//要写成静态方法的原因:类成员函数隐含传递this指针(第一个参数)
static void init() {
p = new singleton();
}
static pthread_once_t ponce_;
static singleton *p; //静态成员变量
public:
singleton *instance() {
// init函数只会执行一次
pthread_once(&ponce_, &singleton::init);
return p;
}
};
9. Итоги¶
В этой статье разобраны несколько вариантов синглтона, в частности потокобезопасные синглтоны, а также несколько потокобезопасных реализаций без C++11: memory barrier, статические локальные переменные, способ с pthread_once, реализация на atomic из C++11 и т. д.
Напоследок отметим: что касается деструктора описанных выше классов-синглтонов, см. реализацию «двойная проверка с блокировкой + автоматическое освобождение» — в классе обязательно нужно объявить статическую локальную переменную. Статическую локальную переменную можно понимать как глобальную: при завершении программы её деструктор вызывается автоматически. Вот почему внутри класса объявляется и определяется вложенный класс, а не пишется напрямую деструктор синглтона.
10. Ссылки¶
https://thorns.cn/2019/03/09/DCLP.html
Переиспользуемый шаблон потокобезопасного синглтона¶
Отвечая на запрос из issue #303: следуя идее boost::container::singleton_default, шаблонный базовый класс на CRTP может превратить любой класс в потокобезопасный синглтон одной строкой (полный код см. в singleton_default.hpp, включая тесты конкурентного доступа):
template <class T>
class SingletonDefault {
public:
static T& instance() {
static T inst; // C++11 起局部静态变量初始化线程安全
return inst;
}
SingletonDefault(const SingletonDefault&) = delete;
SingletonDefault& operator=(const SingletonDefault&) = delete;
protected:
SingletonDefault() = default;
};
// 用法:继承即可
class MyConfig : public SingletonDefault<MyConfig> {
friend class SingletonDefault<MyConfig>;
private:
MyConfig() = default; // 构造私有,交由基类创建
public:
int value = 42;
};
// MyConfig::instance().value