Перейти к содержанию

Последнее обновление: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:

p = new singleton;

Эта инструкция приводит к трём событиям:

  1. выделяется память, способная хранить объект singleton;
  2. в выделенной памяти конструируется объект singleton;
  3. 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

singleton &singleton::instance() {
    static singleton p;
    return p;
}
  • В однопоточном исполнении — корректно.
  • В многопоточном исполнении в 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.

Прототип таков:

int pthread_once(pthread_once_t once_control, void (init_routine) (void));

Реализация:

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

https://www.cnblogs.com/liyuan989/p/4264889.html

https://segmentfault.com/a/1190000015950693

Комментарии