Saltar a contenido

Última actualización:2026-10-13

El patrón singleton en C++

0. Introducción

Seguro que nadie escapa en las entrevistas al tema de los patrones de diseño. Esta sección expone el patrón de diseño más utilizado en entrevistas (el patrón singleton), desde su clasificación y la seguridad de hilos, tanto desde la perspectiva previa a C++11 como desde la perspectiva basada en C++11, y qué soluciones de singleton thread-safe existen. Lee este artículo con atención y no tendrás que preocuparte en futuras entrevistas.

1. El singleton ampliamente conocido

En los libros habituales, o según el conocimiento común, el patrón singleton es el siguiente:

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;
}

Esta es una implementación muy sencilla: el constructor se declara private o protected para evitar que funciones externas instancien la clase; dentro, un puntero estático de la clase guarda la única instancia; la instanciación se realiza mediante un método public que devuelve la única instancia de la clase.

Por supuesto, este código solo sirve para un solo hilo; con múltiples hilos no es seguro. Considera dos hilos que llaman a instance por primera vez a la vez y ambos comprueban que p es nullptr: ambos hilos construirían una instancia para p simultáneamente, lo que violaría el principio del singleton.

2. Inicialización perezosa y ansiosa

El singleton se implementa de dos maneras:

  • Inicialización perezosa (lazy)
  • La instanciación ocurre la primera vez que se usa la instancia de la clase; lo anterior es la implementación perezosa.
  • Inicialización ansiosa (eager)
  • La instanciación ocurre en el momento en que se define la clase singleton.

Aquí también se muestra la implementación ansiosa:

class singleton {
private:
    singleton() {}
    static singleton *p;
public:
    static singleton *instance();
};

singleton *singleton::p = new singleton();
singleton* singleton::instance() {
    return p;
}

Por supuesto esta es thread-safe. La que solemos describir como no thread-safe es la variante perezosa; a continuación se explican las optimizaciones de código para hacerla thread-safe.

3. Bloqueo para múltiples hilos

En C++ hay una clase para bloquear cuyo principio de implementación emplea RAII, de modo que no hay que gestionar el unlock manualmente: lock_guard; aquí la usamos para bloquear.

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;
}

Con esta escritura no puede ocurrir la situación anterior en la que ambos hilos entran en la rama de p == nullptr. Mientras el hilo A ejecuta p = new Singleton(), si el hilo B llama a instance(), se bloqueará sin duda en la toma del lock y esperará a que el hilo A termine y libere el lock. Por lo tanto es thread-safe.

Pero esta escritura tiene un rendimiento muy bajo, porque cada llamada a instance() toma y libera el lock, y ese paso solo es necesario para el primer new Singleton(). Una vez que p ha sido creado, no importa cuántos hilos accedan a la vez, comprobar con if (p == nullptr) es suficiente (es solo una operación de lectura, no requiere lock); no hay problema de seguridad de hilos, y añadir el lock solo genera un problema de rendimiento.

De ahí surge el DCL.

4. Patrón de doble comprobación con lock (DCLP)

La escritura anterior toma el lock y lo libiere pase lo que pase, mientras que para las operaciones de lectura no existe problema de seguridad de hilos; por tanto solo hace falta bloquear cuando se crea la instancia por primera vez, y nunca después. Veamos primero la implementación del DCLP:

singleton* singleton::instance() {
    if(p == nullptr) {  // 第一次检查
        Lock lock;
        if(p == nullptr){ // 第二次检查
            p = new singleton;
        }
    }
    return p;
}

Con base en lo anterior, podemos escribir doble comprobación con lock + recolección automática

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;
}

La clave del DCLP está en que la mayoría de las llamadas a instance verán p como no nulo, de modo que ni siquiera intentan inicializarlo. Por eso el DCLP comprueba si p es nulo antes de intentar tomar el lock. Solo cuando la comprobación tiene éxito (es decir, p aún no se ha inicializado) toma el lock, y después comprueba de nuevo si p sigue siendo nulo (de ahí el nombre de doble comprobación con lock). La segunda comprobación es necesaria porque, como acabamos de ver, es muy posible que otro hilo inicialice p casualmente después de la primera comprobación y antes de adquirir el lock con éxito.

El código anterior parece precioso, pero después de bastante tiempo se descubrió el fallo. La razón: ejecución desordenada de lecturas y escrituras de memoria (un problema del compilador).

Consideremos de nuevo la línea que inicializa p:

p = new singleton;

Esta sentencia provoca tres cosas:

  1. Asignar memoria capaz de almacenar el objeto singleton;
  2. Construir un objeto singleton en la memoria asignada;
  3. Hacer que p apunte a esa memoria asignada.

Puede parecer que estos tres pasos se ejecutan en orden, pero en realidad solo se puede garantizar que el paso 1 se ejecuta primero; los pasos 2 y 3, no necesariamente. Ahí está el problema.

  • El hilo A llama a instance, ejecuta la primera comprobación de p, toma el lock, ejecuta los pasos 1 y 3, y luego se suspende. En ese momento p no es nulo, pero todavía no se ha construido ningún objeto Singleton en la memoria a la que apunta p.
  • El hilo B llama a instance, determina que p no es nulo y lo devuelve al llamador de instance. El llamador desreferencia el puntero para obtener el singleton… vaya, un objeto que aún no ha sido construido. Ahí aparece el bug.

El DCLP funciona bien solo si los pasos uno y dos se ejecutan antes del paso tres, pero no hay forma de expresar esa restricción en C ni en C++. Es como una daga clavada en el corazón del DCLP: necesitamos definir restricciones sobre el orden relativo de las instrucciones, pero nuestro lenguaje no ofrece manera de expresar dichas restricciones.

5. La instrucción memory barrier

El problema del DCLP quedó resuelto en C++11.

Como el nuevo C++11 establece un nuevo modelo de memoria, garantiza que no habrá cambio de hilo mientras se ejecutan los 3 pasos anteriores; el proceso de inicialización equivale a una operación «atómica», y el DCL vuelve a poder usarse correctamente. Eso sí, en C++11 existe una forma aún más concisa de escribir un singleton multihilo, que presentaremos más adelante.

Antes de C++11 la solución era la instrucción barrier. Para que se ejecute correctamente hay que poner un memory barrier justo entre los pasos 2 y 3, forzando a la CPU a ejecutarse en el orden 1, 2 y 3.

Primera implementación:

Basada en operator new + placement new, escribiendo el código siguiendo el orden de ejecución 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;
}

Segunda implementación:

Basada en incrustar directamente la instrucción ASM mfence; la macro barrier de unix también se implementa mediante esa instrucción.

#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;
}

Normalmente se llama a una instrucción proporcionada por la CPU; el papel de esta instrucción es impedir que la CPU intercambie las instrucciones anteriores a ella hacia después de ella, y por eso suele llamarse barrier. En el código anterior, asm indica que es una instrucción ensamblador; volatile es opcional y, si se usa, declara al compilador que no se permite optimizar esa instrucción ensamblador. lwsync es la instrucción barrier que proporciona POWERPC.

6. Variables estáticas locales

Scott Meyers propuso en Effective C++ una forma concisa de escribir un singleton

singleton &singleton::instance() {
    static singleton p;
    return p;
}
  • Con un solo hilo, correcto.
  • Con múltiples hilos en C++11 y versiones posteriores (como C++14), correcto.
  • Con múltiples hilos antes de C++11, no necesariamente correcto.

La razón es que los estándares anteriores a C++11 no definían un modelo de memoria para las variables static locales, así que no era thread-safe. En C++11, en cambio, sí lo es, porque el nuevo estándar C++ establece que cuando un hilo está inicializando una variable, los demás hilos deben esperar a que dicha inicialización termine para poder acceder a ella.

Los órdenes de memoria usados arriba:

  • memory_order_relaxed: orden de memoria relajado, solo garantiza que las operaciones sobre el objeto atómico sean atómicas
  • memory_order_acquire: operación de adquisición; al leer un objeto atómico, ninguna operación de lectura o escritura posterior del hilo actual puede reordenarse por delante de esta operación, y todas las escrituras de memoria de otros hilos previas a la liberación del mismo objeto atómico son visibles para el hilo actual
  • memory_order_release: operación de liberación; al escribir un objeto atómico, ninguna operación de lectura o escritura anterior del hilo actual puede reordenarse después de esta operación, y todas las escrituras de memoria del hilo actual son visibles para los otros hilos que adquieran el mismo objeto atómico

7. Atomic

En versiones anteriores a C++11, además de implementar un Singleton thread-safe mediante locks, también se pueden aprovechar las atomic operation integradas en cada compilador.

Cuando java y c# descubrieron el problema de reordenación, añadieron una palabra clave volatile: al declarar la variable p hay que cualificarla con volatile, y al verlo el compilador sabe que aquí no se puede reordenar (primero asignar la memoria, luego ejecutar el constructor, y solo cuando todo esté hecho, hacer la asignación).

El estándar de c++, en cambio, nunca lo corrigió, así que VC++ añadió esta palabra clave en su versión 2005, pero no es multiplataforma (solo admite plataformas Microsoft).

Y al llegar c++ 11, para eliminar estos agujeros de raíz, se introdujo un modelo de memoria adecuado para el multihilo. Por fin existe un mecanismo que nos ayuda a implementar una solución multiplataforma.

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;
}

Cabe señalar que el código anterior emplea dos términos bastante clave: adquisición y liberación:

  • La adquisición es una operación de lectura sobre memoria; ninguna operación de lectura o escritura posterior del hilo actual puede reordenarse por delante de ella.
  • La liberación es una operación de escritura sobre memoria; ninguna operación de lectura o escritura anterior del hilo actual puede reordenarse después de ella.

acquire y release normalmente apareen emparejados, con el objetivo de garantizar que, si la release sobre el mismo objeto atómico ocurre antes que la acquire, las modificaciones de memoria previas a la release puedan verse por completo en las lecturas de memoria posteriores a la acquire.

8. pthread_once

En plataformas unix, además de usar atomic operations, si no se usa C++11 también se puede implementar el Singleton mediante pthread_once.

El prototipo es el siguiente:

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

Implementación:

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. Resumen

Este artículo ha explicado varios patrones singleton, los patrones singleton thread-safe, y varias implementaciones thread-safe que no usan C++11: memory barrier, variables estáticas locales, el enfoque pthread_once, la implementación atomic de C++11, etc.

Por último, cabe señalar que, para el destructor de las clases singleton anteriores, consulta la implementación de doble comprobación con lock + recolección automática: hay que declarar una variable estática local dentro de la clase. Una variable estática local puede entenderse como una variable global; al terminar el programa se invoca automáticamente el destructor de esa variable estática local. Por eso se declara y define una clase anidada dentro de la clase, en lugar de escribir directamente el destructor del singleton.

10. Referencias

https://thorns.cn/2019/03/09/DCLP.html

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

https://segmentfault.com/a/1190000015950693

Una clase plantilla singleton thread-safe reutilizable

Respondiendo a la petición del issue #303: siguiendo la idea de boost::container::singleton_default, una clase base plantilla CRTP puede convertir cualquier clase en un singleton thread-safe en una sola línea (el código completo está en singleton_default.hpp, con pruebas de concurrencia incluidas):

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

Comentarios