Ir para o conteúdo

Ú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:

p = new singleton;

Esta instrução faz três coisas acontecerem:

  1. alocar memória suficiente para armazenar o objeto singleton;
  2. construir um objeto singleton na memória alocada;
  3. fazer p apontar 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

singleton &singleton::instance() {
    static singleton p;
    return p;
}
  • 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 é:

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

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

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

https://segmentfault.com/a/1190000015950693

Comentários