Последнее обновление:2026-10-12
Шаблон Singleton в C++¶
0. Введение¶
Наверняка на собеседованиях вам не избежать темы шаблонов проектирования. В этом разделе рассматривается самый часто встречающийся на собеседованиях шаблон — singleton (одиночка): его разновидности, потокобезопасность, решения без опоры на стандарт C++11 и на основе C++11. Внимательно прочитав эту статью, вы сможете больше не беспокоиться об этом на собеседованиях.
1. Широко известный singleton¶
В обычных книгах и в массовом представлении шаблон singleton выглядит так:
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, что нарушает принцип singleton.
2. Ленивая и жадная инициализация¶
Singleton имеет две реализации:
- ленивая (lazy)
- экземпляр создаётся при первом использовании; описанная выше — это как раз ленивая реализация.
- жадная (eager)
- экземпляр создаётся в момент определения класса singleton.
Приведём и жадную реализацию:
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(), B неизбежно блокируется на захвате мьютекса и ждёт, пока поток 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¶
В C++11 проблема DCLP была решена.
Новый стандарт C++11 задал новую модель памяти, гарантирующую, что при выполнении описанных трёх шагов не происходит переключения потоков — этот процесс инициализации становится «атомарной» операцией, и DCL снова можно использовать корректно. Впрочем, в C++11 есть и более лаконичная многопоточная реализация singleton, о ней мы расскажем позже.
До 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; её действие — не дать процессору переставлять инструкции, стоящие до неё, на место после неё; такую инструкцию обычно и называют barrier. В приведённом коде asm означает, что это ассемблерная инструкция, volatile необязателен: если он используется, то объявляет компилятору, что оптимизировать эту ассемблерную инструкцию нельзя. lwsync — барьерная инструкция, предоставляемая POWERPC.
6. Статическая локальная переменная¶
Scott Meyer в «Effective C++» предложил лаконичную запись singleton
- В однопоточном исполнении — корректно.
- В многопоточном исполнении в C++11 и более поздних версиях (например, C++14) — корректно.
- В многопоточном исполнении до C++11 — корректность не гарантирована.
Причина в том, что стандарты до C++11 не задавали модель памяти для local static переменных, поэтому она не была потокобезопасной. А в C++11 она потокобезопасна, поскольку новый стандарт C++ требует, чтобы, когда один поток инициализирует переменную, остальные потоки могли обратиться к ней только после завершения инициализации.
Использованные выше упорядочивания памяти:
- memory_order_relaxed: ослабленный порядок, гарантирует лишь атомарность операций над атомарным объектом
- memory_order_acquire: операция получения; при чтении атомарного объекта никакие последующие чтения и записи текущего потока не могут быть переупорядочены до этой операции, и все записи в память, сделанные другими потоками до освобления того же атомарного объекта, становятся видимы текущему потоку
- memory_order_release: операция освобождения; при записи в атомарный объект никакие предшествующие чтения и записи текущего потока не могут быть переупорядочены после этой операции, и все записи в память текущего потока становятся видимы другим потокам, выполняющим получение того же атомарного объекта
7. Atomic¶
В версиях до C++11, помимо потокобезопасного Singleton через блокировки, можно воспользоваться встроенными в компиляторы операциями atomic.
Обнаружив проблемы переупорядочивания, 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):
- получение — это операция чтения памяти; никакие последующие чтения и записи текущего потока не могут быть переупорядочены до этой операции.
- освобождение — это операция записи в память; никакие предшествующие чтения и записи текущего потока не могут быть переупорядочены после этой операции.
acquire и release обычно используются парами, чтобы гарантировать: если release над одним и тем же атомарным объектом происходит до acquire, то все изменения памяти, произошедшие до release, будут видны всем чтениям памяти после acquire.
8. pthread_once¶
На unix-платформах, помимо atomic-операций, при невозможности использовать 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. Заключение¶
В этой статье разобраны несколько вариантов singleton, в том числе потокобезопасные, а также несколько потокобезопасных реализаций без C++11: memory barrier, статическая локальная переменная, способ с pthread_once, а также atomic-реализация из C++11 и другие.
Наконец, обратите внимание: для деструктора описанного выше класса singleton стоит обратиться к реализации «двойная проверенная блокировка + автоматическая сборка мусора» — необходимо объявить внутри класса статическую локальную переменную. Статическую локальную переменную можно понимать как глобальную: по завершении программы автоматически вызывается её деструктор — вот почему внутри класса объявляется и определяется вложенный класс, а не пишется напрямую деструктор singleton.
10. Ссылки¶
https://thorns.cn/2019/03/09/DCLP.html