Última actualización:2026-10-12
C++ Patrón singleton¶
0.Introducción¶
Seguro que en las entrevistas no te libras del tema de los patrones de diseño. Esta sección trata el patrón más usado en entrevistas (el patrón singleton): su clasificación, la seguridad ante hilos y, desde la perspectiva previa a C++11 y desde la de C++11, qué soluciones existen para un singleton thread-safe. Si lees este artículo con atención, ya no tendrás de qué preocuparte en futuras entrevistas.
1.El singleton que todo el mundo conoce¶
En los libros corrientes, o en la versión más conocida del patrón singleton, este se presenta así:
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;
}
Es una implementación muy simple: se declara el constructor private o protected para impedir la instanciación desde funciones externas, se guarda la única instancia en un puntero estático de clase, y la creación de la instancia se confía a un método public que devuelve la única instancia de la clase.
Eso sí, este código solo sirve para un solo hilo; con varios hilos no es seguro. Imagina dos hilos que llaman a instance por primera vez a la vez y ambos comprueban que p es nullptr: los dos construirían una instancia para p simultáneamente, violando el principio del singleton.
2.Perezoso versus ansioso¶
El singleton tiene dos formas de implementarse:
- Perezoso (lazy)
- La instancia se crea la primera vez que se usa; la de arriba es la implementación perezosa.
- Ansioso (eager)
- La instancia se crea al definir la clase singleton.
Aquí está también 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;
}
Esta desde luego es thread-safe; lo que habitualmente describimos como no thread-safe es el modo perezoso, y a continuación veremos optimizaciones thread-safe del modo perezoso.
3.Bloqueo con mutex en entornos multihilo¶
En C++ hay una clase para bloquear cuyo principio de implementación usa RAII, sin gestionar unlock a mano: 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 que dos hilos entren ambos en la rama p == nullptr: cuando el hilo A está ejecutando p = new Singleton() y el hilo B llama a instance(), B se queda bloqueado en el cerrojo esperando a que A termine y lo libere. Por tanto es thread-safe.
Pero esta forma de escribir tiene un rendimiento muy bajo, porque cada llamada a instance() adquiere y libera el cerrojo, y ese paso solo es necesario la primera vez, en el new Singleton(). Una vez que p está creado, por muchos hilos que accedan a la vez, basta con la comprobación if (p == nullptr) (una mera lectura, sin necesidad de cerrojo): no hay problema de seguridad de hilos, y añadir el cerrojo solo genera un problema de rendimiento.
De ahí surge el DCL.
4.Patrón double-checked locking¶
La escritura anterior bloquea y libera pase lo que pase, pero en las operaciones de lectura no existe el problema de seguridad de hilos, así que basta con bloquear la primera vez que se crea la instancia y no hacerlo 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;
}
Sobre esa base podemos escribir double-checked locking con recogida 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 es que la mayoría de las llamadas a instance verán p distinto de null, de modo que ni siquiera intentan inicializarlo. Por eso el DCLP comprueba si p es nulo antes de intentar adquirir el cerrojo. Solo si la comprobación tiene éxito (es decir, p aún no se ha inicializado) se obtiene el cerrojo y se comprueba de nuevo si p sigue siendo nulo (de ahí el nombre de doble comprobación). La segunda comprobación es necesaria porque, como acabamos de ver, es perfectamente posible que otro hilo inicialice p por casualidad después de la primera comprobación y antes de conseguir el cerrojo.
El código anterior parece perfecto, pero bastante tiempo después se descubrió el agujero. La causa: la ejecución desordenada de lecturas y escrituras de memoria (un problema del compilador).
Consideremos de nuevo la línea que inicializa p:
Esta sentencia provoca tres cosas:
- Asignar la memoria capaz de almacenar el objeto
singleton; - Construir un objeto
singletonen la memoria asignada; - Hacer que
papunte a esa memoria asignada.
Uno podría pensar que estos tres pasos se ejecutan en orden, pero en realidad solo podemos afirmar que el paso 1 es el 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, adquiere el cerrojo, ejecuta según el orden 1,3, y a continuación se suspende. En ese momento p no es nulo, pero en la memoria a la que apunta p todavía no se ha construido ningún objeto Singleton.
- 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 solo funciona bien si los pasos uno y dos se ejecutan antes que el 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 expresarlas.
5.La instrucción memory barrier¶
En C++11, el problema del DCLP quedó resuelto.
Como el nuevo C++11 estableció un nuevo modelo de memoria que garantiza que no se produzcan cambios de hilo durante la ejecución de esos 3 pasos —el proceso de inicialización es, en la práctica, «atómico»—, 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, obligando a la CPU a ejecutar los pasos en el orden 1, 2 y 3.
Primera implementación:
Basada en operator new + placement new, escribiendo el código según 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 de ensamblador ASM mfence; la macro barrier de Unix también se implementa con 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 que provee la CPU; su función es impedir que la CPU reordene las instrucciones anteriores a ella colocándolas después de ella; a esta instrucción se le llama habitualmente barrier. En el código anterior, asm indica que es una instrucción de ensamblador; volatile es opcional: si se usa, le declara al compilador que no se permite optimizar esa instrucción de ensamblador. lwsync es la instrucción barrier que provee POWERPC.
6.Variable local estática¶
Scott Meyer propuso en «Effective C++» una forma concisa de escribir el singleton
- En un solo hilo, correcto.
- En entornos multihilo de C++11 y posteriores (como C++14), correcto.
- En entornos multihilo anteriores a C++11, no necesariamente correcto.
El motivo es que los estándares anteriores a C++11 no definían el modelo de memoria de las variables static locales, así que no era thread-safe. Pero en C++11 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 esa inicialización termine antes de poder acceder a ella.
Los órdenes de memoria usados anteriormente:
- 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 que otros hilos hayan hecho antes de la liberación del mismo objeto atómico se vuelven 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 previa del hilo actual puede reordenarse por detrás de esta operación, y todas las escrituras de memoria del hilo actual se vuelven visibles para los otros hilos que adquieran el mismo objeto atómico
7.Atomic¶
En versiones anteriores a C++11, además de implementar el Singleton thread-safe con cerrojos, se pueden aprovechar las operaciones atómicas integradas de cada compilador.
Cuando java y c# descubrieron el problema de reordenación, añadieron la palabra clave volatile: al declarar la variable p hay que añadir el modificador volatile, y al verlo el compilador sabe que ahí no se puede reordenar (primero reservar la memoria, luego ejecutar el constructor, y solo al terminar ambos, asignar).
El estándar de c++, en cambio, nunca lo corrigió, así que VC++ añadió esa palabra clave en la versión 2005, pero no es portable (solo se admite en plataformas Microsoft).
Y al llegar a c++ 11, para eliminar estos agujeros de raíz, se introdujo un modelo de memoria adecuado para 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;
}
Conviene señalar que el código anterior usa dos términos clave, adquisición y liberación:
- La adquisición es una operación de lectura de memoria; ninguna operación de lectura o escritura posterior del hilo actual puede reordenarse por delante de esta operación.
- La liberación es una operación de escritura de memoria; ninguna operación de lectura o escritura previa del hilo actual puede reordenarse por detrás de esta operación.
acquire y release suelen aparecer emparejados, con el objetivo de garantizar que, si el release sobre el mismo objeto atómico ocurre antes que el acquire, todas las modificaciones de memoria previas al release puedan verse en su totalidad en las lecturas de memoria posteriores al acquire.
8.pthread_once¶
En plataformas Unix, además de usar operaciones atómicas, si no se usa C++11 se puede implementar el Singleton con pthread_once.
Su prototipo es:
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 tipos de patrón singleton, el singleton thread-safe, y varias implementaciones thread-safe sin usar C++11: memory barrier, variable local estática, pthread_once, la implementación con atomic de C++11, etc.
Por último, nótese que, respecto al destructor de las clases singleton anteriores, conviene consultar la implementación con doble comprobación de cerrojo y recogida automática: hay que declarar una variable local estática dentro de la clase; una variable local estática puede entenderse como una variable global: al terminar el programa se invoca automáticamente su destructor. Por eso hay que declarar y definir una clase anidada en la clase, en lugar de escribir directamente el destructor del singleton.
10.Referencias¶
https://thorns.cn/2019/03/09/DCLP.html