Última atualização:2026-10-12
O padrão Singleton em C++¶
0. Introdução¶
Certamente ninguém escapa do tema design patterns em entrevistas; esta seção aborda o design pattern mais usado em entrevistas (o singleton): a partir da classificação, da segurança de threads, do ângulo sem o padrão C++11 e do ângulo com o padrão C++11, quais são as soluções de singleton thread-safe. Acredito que, depois de ler este artigo com atenção, você não precisará mais se preocupar em entrevistas futuras.
1. O singleton que todos conhecem¶
Nos livros em geral, ou no conhecimento comum de todos, o padrão singleton é assim:
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 é uma implementação muito simples: declara-se o construtor como private ou protected para impedir a instanciação por funções externas; internamente há um ponteiro de classe estático que guarda a única instância; a construção da instância é feita por um método public que retorna a única instância da classe.
Naturalmente, esse código só serve para contexto de thread única; com múltiplas threads, ele não é seguro. Considere duas threads chamando o método instance simultaneamente pela primeira vez e detectando ao mesmo tempo que p é nullptr: as duas construiriam ao mesmo tempo uma instância para p, violando o princípio do singleton.
2. Lazy e eager¶
O singleton divide-se em dois métodos de implementação:
- lazy (preguiçoso)
- instancia apenas quando a instância da classe é usada pela primeira vez; o caso acima é a implementação lazy.
- eager (ansioso)
- instancia já na definição da classe singleton.
Aqui está também a implementação eager:
class singleton {
private:
singleton() {}
static singleton *p;
public:
static singleton *instance();
};
singleton *singleton::p = new singleton();
singleton* singleton::instance() {
return p;
}
Naturalmente, esta é thread-safe; o que costumamos descrever como não thread-safe é o modo lazy; adiante veremos a otimização thread-safe do código do modo lazy.
3. Lock em múltiplas threads¶
Em C++, há uma classe cujo princípio de implementação do lock usa RAII, dispensando o gerenciamento manual do unlock: o lock_guard; aqui usamos ela para travar.
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;
}
Nessa forma de escrever, não ocorre a situação acima em que as duas threads executam o trecho do p=nullptr: quando a thread A está executando p = new Singleton() e a thread B chama instance(), B certamente ficará bloqueada no ponto do lock, esperando que A termine de executar e libere o lock. Assim, é thread-safe.
Porém, essa forma de escrever tem desempenho muito baixo, porque cada chamada de instance() faz lock e unlock, e essa etapa só é necessária no primeiro new Singleton(); uma vez que p foi criado, por mais threads que acessem simultaneamente, basta usar if (p == nullptr) para verificar (apenas uma leitura, sem necessidade de lock) — não há problema de segurança de threads; com o lock, surge pelo contrário um problema de desempenho.
Daí surge o DCL.
4. Padrão double-checked locking¶
Na forma de escrever acima, o lock é feito e liberado em qualquer circunstância, mas para operações de leitura não existe insegurança de threads; portanto, basta travar na criação da primeira instância, e não depois. Vejamos primeiro a implementação do DCLP:
singleton* singleton::instance() {
if(p == nullptr) { // 第一次检查
Lock lock;
if(p == nullptr){ // 第二次检查
p = new singleton;
}
}
return p;
}
Com base no exposto, podemos escrever double-checked locking + recolhimento automático
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;
}
A chave do DCLP é que a maioria das chamadas a instance verá p não nulo e, portanto, nem tenta inicializá-lo. Assim, o DCLP verifica se p é nulo antes de tentar obter o lock. Só quando a verificação é bem-sucedida (ou seja, p ainda não foi inicializado) é que o lock é obtido, e então verifica-se de novo se p continua nulo (daí o nome double-checked locking). A segunda verificação é necessária porque, como acabamos de ver, é bem possível que outra thread, por acaso, inicialize p depois da primeira verificação e antes de conseguir o lock.
Aparentemente, o código acima é maravilhoso; mas só bem depois se descobriu essa falha, cuja razão é: execução fora de ordem das leituras e escritas de memória (problema do compilador).
Considere de novo a linha que inicializa p:
Esta instrução faz três coisas acontecerem:
- alocar memória suficiente para armazenar o objeto
singleton; - construir um objeto
singletonna memória alocada; - fazer
papontar para essa memória alocada.
Pode-se pensar que esses três passos são executados em ordem, mas na verdade só se pode garantir que o passo 1 é executado primeiro; os passos 2 e 3, não necessariamente. É aí que está o problema.
- A thread A chama instance, executa o primeiro teste de p, obtém o lock, executa segundo 1,3, e então é suspensa. Neste momento, p é não nulo, mas a memória apontada por p ainda não tem um objeto Singleton construído.
- A thread B chama instance, constata que p é não nulo e o retorna ao chamador de instance. O chamador desreferencia o ponteiro para obter o singleton — oh, um objeto que ainda não foi construído. Eis o bug.
O DCLP só funciona bem quando os passos um e dois são executados antes do passo três, mas não há meio de expressar essa restrição em C ou C++. É como um punhal cravado no coração do DCLP: precisamos definir restrições sobre a ordem relativa das instruções, mas a nossa linguagem não oferece forma de expressá-las.
5. Instrução memory barrier¶
No C++11, esse problema do DCLP foi resolvido.
Como o novo C++11 estipulou um novo modelo de memória, garantindo que não haja troca de threads durante a execução dos 3 passos acima — o processo de inicialização é, digamos, uma operação "atômica" —, o DCL volta a poder ser usado corretamente; mas, no C++11, há uma forma de escrever singleton multithread ainda mais concisa, que deixaremos para apresentar mais adiante.
Antes do C++11, a solução era a instrução barrier. Para que a execução seja correta, é preciso inserir uma memory barrier entre os passos 2 e 3, forçando a CPU a executar na ordem dos passos 1, 2 e 3.
Primeira implementação:
Baseada em operator new + placement new, escrevendo o código na ordem de execução 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 implementação:
Baseada na incorporação direta da instrução de assembly ASM mfence; a macro barrier do unix também é implementada com essa instrução.
#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, chama-se uma instrução fornecida pela cpu, cujo papel é impedir que a cpu troque instruções anteriores a ela para depois dela; essa instrução também costuma ser chamada de barrier. No código acima, o asm indica que se trata de uma instrução de assembly; volatile é opcional: se usado, declara ao compilador que não é permitido otimizar essa instrução de assembly. lwsync é a instrução barrier fornecida pelo POWERPC.
6. Variável local estática¶
Scott Meyer propôs, no 《Effective C++》, uma forma concisa de escrever o singleton
- Em thread única, correto.
- Em múltiplas threads no C++11 e posteriores (como o C++14), correto.
- Em múltiplas threads antes do C++11, não necessariamente correto.
A razão é que os padrões anteriores ao C++11 não estipulavam o modelo de memória de variáveis static locais; assim, ele não era thread-safe. Mas no C++11 ele é thread-safe, porque o novo padrão C++ estabelece que, quando uma thread está inicializando uma variável, as demais threads devem aguardar a conclusão dessa inicialização antes de acessá-la.
A ordem de memória usada acima:
- memory_order_relaxed: ordem de memória relaxada, usada apenas para garantir que as operações sobre o objeto atômico sejam atômicas
- memory_order_acquire: operação de aquisição; ao ler determinado objeto atômico, nenhuma operação de leitura ou escrita posterior da thread atual pode ser reordenada para antes desta operação, e todas as escritas de memória feitas por outras threads antes da liberação do mesmo objeto atômico tornam-se visíveis à thread atual
- memory_order_release: operação de liberação; ao escrever determinado objeto atômico, nenhuma operação de leitura ou escrita anterior da thread atual pode ser reordenada para depois desta operação, e todas as escritas de memória da thread atual tornam-se visíveis às threads que adquirirem o mesmo objeto atômico
7. Atomic¶
Em versões anteriores ao C++11, além de implementar o Singleton thread-safe por meio de locks, também é possível usar as atomic operations embutidas nos vários compiladores.
Quando java e c# descobriram o problema de reordenação, acrescentaram a palavra-chave volatile: ao declarar a variável p, é preciso acrescentar o qualificador volatile; vendo isso, o compilador sabe que não se pode fazer reorder aqui (é preciso primeiro alocar a memória, depois executar o construtor e só então, com tudo completo, fazer a atribuição).
Já o padrão c++ nunca corrigiu isso; por isso o VC++ também acrescentou essa palavra-chave na versão 2005, mas isso não é portável (só dá suporte às plataformas da Microsoft).
Já na versão c++ 11, para eliminar essas falhas pela raiz, foi introduzido um modelo de memória adequado ao multithreading. Finalmente há um mecanismo que nos ajuda a implementar uma solução 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;
}
Vale notar que o código acima usa dois termos bastante importantes, aquisição e liberação:
- aquisição é uma operação de leitura da memória; nenhuma operação de leitura ou escrita posterior da thread atual pode ser reordenada para antes desta operação.
- liberação é uma operação de escrita da memória; nenhuma operação de leitura ou escrita anterior da thread atual pode ser reordenada para depois desta operação.
acquire e release costumam aparecer em pares, com o objetivo de garantir que, se o release sobre o mesmo objeto atômico ocorrer antes do acquire, as modificações de memória anteriores ao release possam ser todas vistas pelas leituras de memória posteriores ao acquire.
8. pthread_once¶
Se estiver na plataforma unix, além de usar atomic operations, sem usar C++11 também é possível implementar o Singleton com pthread_once.
O protótipo é:
Implementação:
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. Conclusão¶
Este artigo explicou várias formas de singleton, incluindo o singleton thread-safe e várias implementações thread-safe sem C++11: memory barrier, variável local estática, pthread_once, a implementação com atomic do C++11 etc.
Por fim, vale notar que, quanto ao destrutor das classes singleton acima, consulte a implementação double-checked locking + recolhimento automático: é preciso declarar na classe uma variável local estática — uma variável local estática pode ser entendida como uma variável global: no fim do programa, o destrutor dessa variável local estática é chamado automaticamente; eis por que se declara e define na classe uma classe aninhada, em vez de escrever diretamente o destrutor do singleton.
10. Materiais de referência¶
https://thorns.cn/2019/03/09/DCLP.html