Técnica para evitar comportamento indefinido ao acessar um singleton

O artigo discute as causas e métodos para evitar comportamento indefinido ao acessar um singleton no c ++ moderno. Exemplos de código de thread único são fornecidos. Nada específico do compilador, tudo de acordo com o padrão.

1. Introdução


Para começar, recomendo que você leia outros artigos sobre singleton no Habré:

Três idades do padrão Singleton
Singleton e instâncias comuns
3 maneiras de quebrar o princípio da responsabilidade única
Singleton - padrão ou antipadrão?
Usando o Padrão Singleton

E, finalmente, um artigo que abordou o mesmo tópico, mas entrou (mesmo que apenas porque as desvantagens e limitações não foram consideradas):
objetos inicializados (ou seja, objetos
Vida útil de um único objeto e

Seguinte:

  • este não é um artigo sobre propriedades arquitetônicas de singleton;
  • este não é um artigo “como fazer um singleton branco e fofo com um singleton terrível e terrível”;
  • essa não é uma campanha única;
  • não é uma cruzada contra singleton;
  • este não é um artigo final feliz.

Este artigo é sobre um aspecto muito importante, mas ainda técnico, do uso de singleton no C ++ moderno. A atenção principal do artigo é dedicada ao momento de destruição do singleton, como na maioria das fontes, a questão da destruição é pouco divulgada. Geralmente, a ênfase está no momento em que o singleton foi criado, e na destruição, na melhor das hipóteses, diz algo como "destruído na ordem inversa".

Pedimos que você siga o escopo do artigo nos comentários, especialmente para não organizar o padrão singleton versus o holivar antipadrão singleton.

Então vamos lá.

O que o padrão diz


As citações são do rascunho final em C ++ 14 N3936, como os rascunhos disponíveis do C ++ 17 não são marcados como "finais".
Dou a seção mais importante em sua totalidade. Lugares importantes são destacados por mim.

3.6.3 Rescisão [basic.start.term]

1. Destrutores (12.4) para objetos inicializados (ou seja, objetos cuja vida útil (3.8) começou) com duração de armazenamento estático são chamados como resultado do retorno do main e como resultado da chamada std :: exit (18.5). Destrutores para objetos inicializados com duração de armazenamento de encadeamento em um determinado encadeamento são chamados como resultado do retorno da função inicial desse encadeamento e como resultado desse encadeamento que chama std :: exit. As conclusões dos destruidores para todos os objetos inicializados com duração de armazenamento de encadeamento dentro desse encadeamento são sequenciadas antes do início dos destruidores de qualquer objeto com duração de armazenamento estático. Se a conclusão do construtor ou a inicialização dinâmica de um objeto com duração de armazenamento do encadeamento for sequenciada antes da de outro, a conclusão do destruidor do segundo será sequenciada antes do início do destruidor do primeiro. Se a conclusão do construtor ou a inicialização dinâmica de um objeto com duração de armazenamento estático for sequenciada antes da de outro, a conclusão do destruidor do segundo será sequenciada antes do início do destruidor do primeiro. [Nota: Esta definição permite destruição simultânea. –Final nota] Se um objeto for inicializado estaticamente, ele será destruído na mesma ordem como se o objeto fosse inicializado dinamicamente. Para um objeto do tipo matriz ou classe, todos os subobjetos desse objeto são destruídos antes que qualquer objeto de escopo de bloco com duração de armazenamento estático inicializado durante a construção dos subobjetos seja destruído. Se a destruição de um objeto com duração de armazenamento estático ou de thread sair por meio de uma exceção, std :: terminate será chamado (15.5.1).

2. Se uma função contiver um objeto de escopo de bloco com duração de armazenamento estático ou de thread que foi destruído e a função for chamada durante a destruição de um objeto com duração de armazenamento estático ou de thread, o programa terá um comportamento indefinido se o fluxo de controle passar através da definição do objeto de luneta destruída anteriormente. Da mesma forma, o comportamento é indefinido se o objeto de escopo do bloco for usado indiretamente (ou seja, através de um ponteiro) após sua destruição.

3. Se a conclusão da inicialização de um objeto com duração de armazenamento estático for sequenciada antes de uma chamada para std :: atexit (consulte "cstdlib", 18.5), a chamada para a função passada para std :: atexit será sequenciada antes da chamada para o destruidor do objeto. Se uma chamada para std :: atexit for sequenciada antes da conclusão da inicialização de um objeto com duração de armazenamento estático, a chamada para o destruidor do objeto será sequenciada antes da chamada para a função passada para std :: atexit. Se uma chamada para std :: atexit for sequenciada antes de outra chamada para std :: atexit, a chamada para a função passada para a segunda chamada std :: atexit será sequenciada antes da chamada para a função passada para a primeira chamada std :: atexit .

4. Se houver um uso de um objeto ou função de biblioteca padrão não permitido nos manipuladores de sinal (18.10) que não ocorra antes (1.10) da destruição de objetos com duração de armazenamento estático e execução das funções registradas std :: atexit (18.5 ), o programa tem um comportamento indefinido. [Nota: Se houver um uso de um objeto com duração de armazenamento estático que não ocorra antes da destruição do objeto, o programa terá um comportamento indefinido. Terminar cada encadeamento antes de uma chamada para std :: exit ou a saída do main é suficiente, mas não é necessário, para atender a esses requisitos. Esses requisitos permitem que os gerenciadores de encadeamentos sejam objetos de duração de armazenamento estático. - Nota final]

5. Chamar a função std :: abort () declarada em “cstdlib” finaliza o programa sem executar nenhum destruidor e sem chamar as funções passadas para std :: atexit () ou std :: at_quick_exit ().
Interpretação:

  • a destruição de objetos com duração de armazenamento de encadeamento é realizada na ordem inversa de sua criação;
  • estritamente depois disso, objetos com duração de armazenamento estático são destruídos e chamadas são feitas para funções registradas com std :: atexit na ordem inversa de criação desses objetos e registro de tais funções;
  • Uma tentativa de acessar um objeto destruído com duração de armazenamento de encadeamento ou duração de armazenamento estático contém comportamento indefinido. A reinicialização de tais objetos não é fornecida.

Nota: as variáveis ​​globais no padrão são referidas como "variável não local com duração de armazenamento estático". Como resultado, verifica-se que todas as variáveis ​​globais, todas as singletones (estáticas locais) e todas as chamadas para std :: atexit caem em uma única fila LIFO à medida que são criadas / registradas.

As informações úteis para o artigo também estão contidas na seção 3.6.2 Inicialização de variáveis ​​não locais [basic.start.init] . Trago apenas o mais importante:
A inicialização dinâmica de uma variável não local com duração de armazenamento estático é ordenada ou desordenada. [...] Variáveis ​​com inicialização ordenada definida dentro de uma única unidade de tradução devem ser inicializadas na ordem de suas definições na unidade de tradução.
Interpretação (levando em consideração o texto completo da seção): variáveis ​​globais em uma unidade de tradução são inicializadas na ordem da declaração.

O que estará no código


Todos os exemplos de código fornecidos no artigo são publicados no github .

O código consiste em três camadas, como se fosse escrito por pessoas diferentes:

  • singleton;
  • utilitário (classe usando singleton);
  • usuário (variáveis ​​globais e principal).

Singleton e o utilitário são como uma biblioteca de terceiros, e o usuário é o usuário.
A camada de utilitário foi projetada para isolar a camada de usuário da camada de singleton. Nos exemplos, o usuário tem a oportunidade de acessar o singleton, mas agiremos como se isso fosse impossível.

O usuário primeiro faz tudo certo e, em seguida, com um movimento do pulso, tudo quebra. Primeiro, tentamos corrigi-lo na camada de utilidade e, se não der certo, na camada de singleton.

No código, caminharemos constantemente ao longo da borda - agora no lado claro, depois no escuro. Para facilitar a mudança para o lado obscuro, foi escolhido o caso mais difícil - acessar um singleton a partir do destruidor de utilitários.

Por que o caso de chamar do destruidor é o mais difícil? Como o destruidor de utilidades pode ser chamado no processo de minimização do aplicativo, quando a pergunta "o singleton foi destruído ou ainda não" se torna relevante.

O caso é algum tipo de sintético. Na prática, não são necessárias chamadas para um singleton do destruidor. Mesmo quando necessário. Por exemplo, para registrar a destruição de objetos.

Três classes de singleton são usadas:

  • SingletonClassic - sem ponteiros inteligentes. De fato, não é diretamente clássico, mas definitivamente o mais clássico dentre os três considerados;
  • SingletonShared - com std :: shared_ptr;
  • SingletonWeak - com std :: weak_ptr.

Todos os singletones são modelos. O parâmetro do modelo é usado para herdar dele. Na maioria dos exemplos, eles são parametrizados pela classe Payload, que fornece uma função pública para adicionar dados ao std :: set.

O destruidor de utilidades na maioria dos exemplos tenta preencher uma centena de valores lá. A saída de diagnóstico para o console também é usada a partir do construtor singleton, do destruidor singleton e da instância ().

Por que é tão difícil? Para facilitar a compreensão de que estamos do lado escuro. O apelo ao singleton destruído é um comportamento indefinido, mas não pode ser manifestado de forma alguma externamente. O preenchimento de valores no std :: set destruído também certamente não garante manifestações externas, mas não há uma maneira mais confiável (de fato, no GCC no Linux em exemplos incorretos com o singleton clássico, o std :: set destruído é preenchido com êxito e no MSVS sob Windows - trava). Com comportamento indefinido, a saída para o console pode não ocorrer. Portanto, nos exemplos corretos, esperamos a ausência de acesso a instance () após o destruidor, bem como a ausência de um travamento e a ausência de travamento, e nos incorretos, a presença de um apelo ou travamento ou travamento ou de uma só vez em qualquer combinação ou qualquer outra coisa.

Singleton clássico


Payload.h
#pragma once #include <set> class Payload { public: Payload() = default; ~Payload() = default; Payload(const Payload &) = delete; Payload(Payload &&) = delete; Payload& operator=(const Payload &) = delete; Payload& operator=(Payload &&) = delete; void add(int value) { m_data.emplace(value); } private: std::set<int> m_data; }; 


SingletonClassic.h
 #pragma once #include <iostream> template<typename T> class SingletonClassic : public T { public: ~SingletonClassic() { std::cout << "~SingletonClassic()" << std::endl; } SingletonClassic(const SingletonClassic &) = delete; SingletonClassic(SingletonClassic &&) = delete; SingletonClassic& operator=(const SingletonClassic &) = delete; SingletonClassic& operator=(SingletonClassic &&) = delete; static SingletonClassic& instance() { std::cout << "instance()" << std::endl; static SingletonClassic inst; return inst; } private: SingletonClassic() { std::cout << "SingletonClassic()" << std::endl; } }; 


Exemplo 1 de SingletonClassic


Classic_Example1_correct.cpp
 #include "SingletonClassic.h" #include "Payload.h" #include <memory> class ClassicSingleThreadedUtility { public: ClassicSingleThreadedUtility() { // To ensure that singleton will be constucted before utility SingletonClassic<Payload>::instance(); } ~ClassicSingleThreadedUtility() { auto &instance = SingletonClassic<Payload>::instance(); for ( int i = 0; i < 100; ++i ) instance.add(i); } }; // 1. Create an empty unique_ptr // 2. Create singleton (because of modified ClassicSingleThreadedUtility c-tor) // 3. Create utility std::unique_ptr<ClassicSingleThreadedUtility> emptyUnique; auto utilityUnique = std::make_unique<ClassicSingleThreadedUtility>(); // This guarantee destruction in order: // - utilityUnique; // - singleton; // - emptyUnique. // This order is correct int main() { return 0; } 


Saída do console
instance ()
SingletonClassic ()
instance ()
~ SingletonClassic ()

O utilitário chama o singleton no construtor para garantir que o singleton seja criado antes da criação do utilitário.

O usuário cria dois std :: unique_ptr: um vazio, o segundo contendo o utilitário.

A ordem da criação:

- vazio std :: unique_ptr.
- singleton;
- utilidade.

E, consequentemente, a ordem de destruição:

- utilidade;
- singleton;
- vazio std :: unique_ptr.

A chamada do destruidor de utilitário para o singleton está correta.

SingletonClassic example 2


Tudo é o mesmo, mas o usuário pegou e arruinou tudo com uma linha.

Classic_Example2_incorrect.cpp
 #include "SingletonClassic.h" #include "Payload.h" #include <memory> class ClassicSingleThreadedUtility { public: ClassicSingleThreadedUtility() { // To ensure that singleton will be constucted before utility SingletonClassic<Payload>::instance(); } ~ClassicSingleThreadedUtility() { auto &instance = SingletonClassic<Payload>::instance(); for ( int i = 0; i < 100; ++i ) instance.add(i); } }; // 1. Create an empty unique_ptr // 2. Create singleton (because of modified ClassicSingleThreadedUtility c-tor) // 3. Create utility std::unique_ptr<ClassicSingleThreadedUtility> emptyUnique; auto utilityUnique = std::make_unique<ClassicSingleThreadedUtility>(); // This guarantee destruction in order: // - utilityUnique; // - singleton; // - emptyUnique. // This order seems to be correct ... int main() { // ... but user swaps unique_ptrs emptyUnique.swap(utilityUnique); // Guaranteed destruction order is still the same: // - utilityUnique; // - singleton; // - emptyUnique, // but now utilityUnique is empty, and emptyUnique is filled, // so destruction order is incorrect return 0; } 


Saída do console
instance ()
SingletonClassic ()
~ SingletonClassic ()
instance ()

A ordem da criação e destruição é preservada. Parece que tudo está parado. Mas não. Invocando emptyUnique.swap (utilityUnique), o usuário confirmou um comportamento indefinido.

Por que o usuário fez coisas tão estúpidas? Porque ele não sabe nada sobre a estrutura interna da biblioteca, o que lhe forneceu um singleton e uma utilidade.

E se você conhece a estrutura interna da biblioteca? ... de qualquer maneira, no código real, é muito fácil se envolver. E você tem que sair por uma penosa penalidade, porque entender o que exatamente aconteceu não será fácil.

Por que não exigir que a biblioteca seja usada corretamente? Bem, existem todos os tipos de docas para escrever, exemplos ... E por que não criar uma biblioteca que não é tão fácil de estragar?

SingletonClassic example 3


Durante a preparação do artigo por vários dias, acreditei que era impossível eliminar o comportamento indefinido do exemplo anterior na camada de utilidade, e a solução estava disponível apenas na camada de singleton. Mas, com o tempo, surgiu uma solução.

Antes de abrir os spoilers com o código e a explicação, sugiro que o leitor tente encontrar uma saída da situação por conta própria (apenas na camada de utilidade!). Não excluo que existem melhores soluções.

Classic_Example3_correct.cpp
 #include "SingletonClassic.h" #include "Payload.h" #include <memory> #include <iostream> class ClassicSingleThreadedUtility { public: ClassicSingleThreadedUtility() { thread_local auto flag_strong = std::make_shared<char>(0); m_flag_weak = flag_strong; SingletonClassic<Payload>::instance(); } ~ClassicSingleThreadedUtility() { if ( !m_flag_weak.expired() ) { auto &instance = SingletonClassic<Payload>::instance(); for ( int i = 0; i < 100; ++i ) instance.add(i); } } private: std::weak_ptr<char> m_flag_weak; }; // 1. Create an empty unique_ptr // 2. Create singleton (because of modified ClassicSingleThreadedUtility c-tor) // 3. Create utility std::unique_ptr<ClassicSingleThreadedUtility> emptyUnique; auto utilityUnique = std::make_unique<ClassicSingleThreadedUtility>(); // This guarantee destruction in order: // - utilityUnique; // - singleton; // - emptyUnique. // This order seems to be correct ... int main() { // ... but user swaps unique_ptrs emptyUnique.swap(utilityUnique); { // To demonstrate normal processing before application ends auto utility = ClassicSingleThreadedUtility(); } // Guaranteed destruction order is still the same: // - utilityUnique; // - singleton; // - emptyUnique, // but now utilityUnique is empty, and emptyUnique is filled, // so destruction order is incorrect ... // ... but utility uses a variable with thread storage duration to detect thread termination. return 0; } 


Saída do console
instance ()
SingletonClassic ()
instance ()
instance ()
~ SingletonClassic ()

Explicação
O problema ocorre apenas ao minimizar o aplicativo. O comportamento indefinido pode ser eliminado ensinando o utilitário a reconhecer quando o aplicativo é minimizado. Para fazer isso, usamos uma variável flag_strong do tipo std :: shared_ptr, que possui um qualificador de duração de armazenamento de encadeamento (consulte trechos do padrão no artigo acima) - é como uma estática, mas só é destruída quando o encadeamento atual termina antes que qualquer estática seja destruída , inclusive antes da destruição singleton. A variável flag_strong é uma para todo o fluxo, e cada instância do utilitário armazena sua cópia fraca.

Em um sentido restrito, a solução pode ser chamada de hack, porque é indireto e não óbvio. Além disso, ele alerta muito cedo e, às vezes (em um aplicativo multithread), geralmente alerta falso. Mas, em um sentido amplo, isso não é um hack, mas uma solução completamente definida pelas propriedades padrão - desvantagens e vantagens.

Singletonshared


Vamos seguir para um singleton modificado com base em std :: shared_ptr.

SingletonShared.h
 #pragma once #include <memory> #include <iostream> template<typename T> class SingletonShared : public T { public: ~SingletonShared() { std::cout << "~SingletonShared()" << std::endl; } SingletonShared(const SingletonShared &) = delete; SingletonShared(SingletonShared &&) = delete; SingletonShared& operator=(const SingletonShared &) = delete; SingletonShared& operator=(SingletonShared &&) = delete; static std::shared_ptr<SingletonShared> instance() { std::cout << "instance()" << std::endl; // "new" and no std::make_shared because of private c-tor static auto inst = std::shared_ptr<SingletonShared>(new SingletonShared); return inst; } private: SingletonShared() { std::cout << "SingletonShared()" << std::endl; } }; 


Ai-ai-ai, o novo operador não deve ser usado no código moderno, mas std :: make_shared é necessário! E isso é evitado pelo construtor particular do singleton.

Ha! Eu também tenho um problema! Declare std :: make_shared um amigo singleton! ... e obtenha uma variação do antipadrão PublicMorozov: usando o mesmo std :: make_shared, será possível criar instâncias adicionais do singleton que não são fornecidas pela arquitetura.

Exemplos 1 e 2 compartilhados


Corresponda totalmente aos exemplos nº 1 e 2 da versão clássica. Alterações significativas foram feitas apenas na camada de singleton, o utilitário permaneceu essencialmente o mesmo. Assim como nos exemplos com o singleton clássico, o exemplo-1 está correto e o exemplo-2 mostra um comportamento indefinido.

Shared_Example1_correct.cpp
 #include "SingletonShared.h" #include <Payload.h> #include <memory> class SharedSingleThreadedUtility { public: SharedSingleThreadedUtility() { // To ensure that singleton will be constucted before utility SingletonShared<Payload>::instance(); } ~SharedSingleThreadedUtility() { if ( auto instance = SingletonShared<Payload>::instance() ) for ( int i = 0; i < 100; ++i ) instance->add(i); } }; // 1. Create an empty unique_ptr // 2. Create singleton (because of modified SharedSingleThreadedUtility c-tor) // 3. Create utility std::unique_ptr<SharedSingleThreadedUtility> emptyUnique; auto utilityUnique = std::make_unique<SharedSingleThreadedUtility>(); // This guarantee destruction in order: // - utilityUnique; // - singleton; // - emptyUnique. // This order is correct int main() { return 0; } 


Saída do console
instance ()
SingletonShared ()
instance ()
~ SingletonShared ()

Shared_Example2_incorrect.cpp
 #include "SingletonShared.h" #include "Payload.h" #include <memory> class SharedSingleThreadedUtility { public: SharedSingleThreadedUtility() { // To ensure that singleton will be constucted before utility SingletonShared<Payload>::instance(); } ~SharedSingleThreadedUtility() { // Sometimes this check may result as "false" even for destroyed singleton // preventing from visual effects of undefined behaviour ... //if ( auto instance = SingletonShared::instance() ) // for ( int i = 0; i < 100; ++i ) // instance->add(i); // ... so this code will demonstrate UB in colour auto instance = SingletonShared<Payload>::instance(); for ( int i = 0; i < 100; ++i ) instance->add(i); } }; // 1. Create an empty unique_ptr // 2. Create singleton (because of modified SharedSingleThreadedUtility c-tor) // 3. Create utility std::unique_ptr<SharedSingleThreadedUtility> emptyUnique; auto utilityUnique = std::make_unique<SharedSingleThreadedUtility>(); // This guarantee destruction in order: // - utilityUnique; // - singleton; // - emptyUnique. // This order seems to be correct ... int main() { // ... but user swaps unique_ptrs emptyUnique.swap(utilityUnique); // Guaranteed destruction order is the same: // - utilityUnique; // - singleton; // - emptyUnique, // but now utilityUnique is empty, and emptyUnique is filled, // so destruction order is incorrect return 0; } 


Saída do console
instance ()
SingletonShared ()
~ SingletonShared ()
instance ()

Exemplo 3 de SingletonShared


E agora tentaremos resolver esse problema melhor do que no exemplo número 3 dos clássicos.
A solução é óbvia: você só precisa estender a vida útil do singleton armazenando uma cópia de std :: shared_ptr retornada pelo singleton no utilitário. E essa solução, completa com o SingletonShared, foi amplamente replicada em fontes abertas.

Shared_Example3_correct.cpp
 #include "SingletonShared.h" #include "Payload.h" #include <memory> class SharedSingleThreadedUtility { public: SharedSingleThreadedUtility() // To ensure that singleton will be constucted before utility : m_singleton(SingletonShared<Payload>::instance()) { } ~SharedSingleThreadedUtility() { // Sometimes this check may result as "false" even for destroyed singleton // preventing from visual effects of undefined behaviour ... //if ( m_singleton ) // for ( int i = 0; i < 100; ++i ) // m_singleton->add(i); // ... so this code will allow to demonstrate UB in colour for ( int i = 0; i < 100; ++i ) m_singleton->add(i); } private: // A copy of smart pointer, not a reference std::shared_ptr<SingletonShared<Payload>> m_singleton; }; // 1. Create an empty unique_ptr // 2. Create singleton (because of SharedSingleThreadedUtility c-tor) // 3. Create utility std::unique_ptr<SharedSingleThreadedUtility> emptyUnique; auto utilityUnique = std::make_unique<SharedSingleThreadedUtility>(); int main() { // This guarantee destruction in order: // - utilityUnique; // - singleton; // - emptyUnique. // This order is correct ... // ... but user swaps unique_ptrs emptyUnique.swap(utilityUnique); // Guaranteed destruction order is the same: // - utilityUnique; // - singleton; // - emptyUnique, // but now utilityUnique is empty, and emptyUnique is filled, // so destruction order is incorrect... // ... but utility have made a copy of shared_ptr when it was available, // so it's correct again. return 0; } 


Saída do console
instance ()
SingletonShared ()
~ SingletonShared ()

E agora, atenção, a pergunta é: você realmente queria prolongar a vida de um singleton?
Ou você queria se livrar do comportamento indefinido e escolher a extensão da vida como uma maneira de ficar na superfície?

A incorreta teoria na forma de substituição de metas por meios leva ao risco de impasse (ou referência cíclica - chame como você deseja).

Sim nuuuuuu, é assim que você deve se esforçar tanto !? Você terá que pensar em tanto tempo e certamente não o fará por acidente!

CallbackPayload.h
 #pragma once #include <functional> class CallbackPayload { public: CallbackPayload() = default; ~CallbackPayload() = default; CallbackPayload(const CallbackPayload &) = delete; CallbackPayload(CallbackPayload &&) = delete; CallbackPayload& operator=(const CallbackPayload &) = delete; CallbackPayload& operator=(CallbackPayload &&) = delete; void setCallback(std::function<void()> &&fn) { m_callbackFn = std::move(fn); } private: std::function<void()> m_callbackFn; }; 


SomethingWithVeryImportantDestructor.h
 #pragma once #include <iostream> class SomethingWithVeryImportantDestructor { public: SomethingWithVeryImportantDestructor() { std::cout << "SomethingWithVeryImportantDestructor()" << std::endl; } ~SomethingWithVeryImportantDestructor() { std::cout << "~SomethingWithVeryImportantDestructor()" << std::endl; } SomethingWithVeryImportantDestructor(const SomethingWithVeryImportantDestructor &) = delete; SomethingWithVeryImportantDestructor(SomethingWithVeryImportantDestructor &&) = delete; SomethingWithVeryImportantDestructor& operator=(const SomethingWithVeryImportantDestructor &) = delete; SomethingWithVeryImportantDestructor& operator=(SomethingWithVeryImportantDestructor &&) = delete; }; 


Shared_Example4_incorrect.cpp
 #include "SingletonShared.h" #include "CallbackPayload.h" #include "SomethingWithVeryImportantDestructor.h" class SharedSingleThreadedUtility { public: SharedSingleThreadedUtility() // To ensure that singleton will be constucted before utility : m_singleton(SingletonShared<CallbackPayload>::instance()) { std::cout << "SharedSingleThreadedUtility()" << std::endl; } ~SharedSingleThreadedUtility() { std::cout << "~SharedSingleThreadedUtility()" << std::endl; } void setCallback(std::function<void()> &&fn) { if ( m_singleton ) m_singleton->setCallback(std::move(fn)); } private: // A copy of smart pointer, not a reference std::shared_ptr<SingletonShared<CallbackPayload>> m_singleton; }; int main() { auto utility = std::make_shared<SharedSingleThreadedUtility>(); auto something = std::make_shared<SomethingWithVeryImportantDestructor>(); // lambda with "utility" and "something" captured utility->setCallback( [utility, something](){} ); return 0; } 


Saída do console
instance ()
SingletonShared ()
SharedSingleThreadedUtility ()
SomethingWithVeryImportantDestructor ()

Um singleton foi criado.

Um utilitário foi criado.

Algo S-Very-Important-Destructor foi criado (eu adicionei isso por intimidação, porque na Internet existem posts como "bem, o destruidor singleton não será chamado, então, o que é isso, deve existir o tempo todo) programas ”).

Mas nenhum destruidor foi chamado para nenhum desses objetos!

Por causa do que? Devido à substituição de gols por meios.

Singletonweak


SingletonWeak.h
 #pragma once #include <memory> #include <iostream> template<typename T> class SingletonWeak : public T { public: ~SingletonWeak() { std::cout << "~SingletonWeak()" << std::endl; } SingletonWeak(const SingletonWeak &) = delete; SingletonWeak(SingletonWeak &&) = delete; SingletonWeak& operator=(const SingletonWeak &) = delete; SingletonWeak& operator=(SingletonWeak &&) = delete; static std::weak_ptr<SingletonWeak> instance() { std::cout << "instance()" << std::endl; // "new" and no std::make_shared because of private c-tor static auto inst = std::shared_ptr<SingletonWeak>(new SingletonWeak); return inst; } private: SingletonWeak() { std::cout << "SingletonWeak()" << std::endl; } }; 


Essa modificação do singleton em fontes abertas, se dada, certamente não é frequente. Eu conheci algumas variantes estranhas viradas do avesso com um std :: weak_ptr, que parece ser usado, o que, aparentemente, oferece ao utilitário nada mais do que prolongar a vida de um singleton:


A opção que proponho, quando aplicada corretamente nas camadas de singleton e utilitário:

  • protege contra ações na camada do usuário descrita nos exemplos acima, incluindo a prevenção de conflitos;
  • determina o momento em que o aplicativo é dobrado com mais precisão do que o aplicativo thread_local no Classic_Example3_correct, ou seja, permite que você se aproxime da borda;
  • Não sofro do problema teórico de substituir objetivos por meios (não sei se algo tangível que não um impasse pode aparecer nesse problema teórico).

No entanto, há uma desvantagem: prolongar a vida útil de um singleton ainda pode permitir que ele se aproxime ainda mais da borda.

Exemplo 1 de SingletonWeak


Semelhante ao Shared_Example3_correct.cpp.

Weak_Example1_correct.cpp
 #include "SingletonWeak.h" #include "Payload.h" #include <memory> class WeakSingleThreadedUtility { public: WeakSingleThreadedUtility() // To ensure that singleton will be constucted before utility : m_weak(SingletonWeak<Payload>::instance()) { } ~WeakSingleThreadedUtility() { // Sometimes this check may result as "false" even in case of incorrect usage, // and there's no way to guarantee a demonstration of undefined behaviour in colour if ( auto strong = m_weak.lock() ) for ( int i = 0; i < 100; ++i ) strong->add(i); } private: // A weak copy of smart pointer, not a reference std::weak_ptr<SingletonWeak<Payload>> m_weak; }; // 1. Create an empty unique_ptr // 2. Create singleton (because of WeakSingleThreadedUtility c-tor) // 3. Create utility std::unique_ptr<WeakSingleThreadedUtility> emptyUnique; auto utilityUnique = std::make_unique<WeakSingleThreadedUtility>(); int main() { // This guarantee destruction in order: // - utilityUnique; // - singleton; // - emptyUnique. // This order is correct ... // ... but user swaps unique_ptrs emptyUnique.swap(utilityUnique); // Guaranteed destruction order is the same: // - utilityUnique; // - singleton; // - emptyUnique, // but now utilityUnique is empty, and emptyUnique is filled, // so destruction order is incorrect... // ... but utility have made a weak copy of shared_ptr when it was available, // so it's correct again. return 0; } 


Saída do console
instance ()
SingletonWeak ()
~ SingletonWeak ()

Por que precisamos do SingletonWeak, porque ninguém incomoda o utilitário de usar o SingletonShared como SingletonWeak? Sim, ninguém se incomoda. E mesmo ninguém incomoda o utilitário de usar SingletonWeak como SingletonShared. Mas usá-los para a finalidade pretendida é um pouco mais fácil do que usá-los para outros fins.

Exemplo 2 de SingletonWeak


Semelhante ao Shared_Example4_incorrect, mas apenas o deadlock não ocorre neste caso.

Weak_Example2_correct.cpp
 #include "SingletonWeak.h" #include "CallbackPayload.h" #include "SomethingWithVeryImportantDestructor.h" class WeakSingleThreadedUtility { public: WeakSingleThreadedUtility() // To ensure that singleton will be constucted before utility : m_weak(SingletonWeak<CallbackPayload>::instance()) { std::cout << "WeakSingleThreadedUtility()" << std::endl; } ~WeakSingleThreadedUtility() { std::cout << "~WeakSingleThreadedUtility()" << std::endl; } void setCallback(std::function<void()> &&fn) { if ( auto strong = m_weak.lock() ) strong->setCallback(std::move(fn)); } private: // A weak copy of smart pointer, not a reference std::weak_ptr<SingletonWeak<CallbackPayload>> m_weak; }; int main() { auto utility = std::make_shared<WeakSingleThreadedUtility>(); auto something = std::make_shared<SomethingWithVeryImportantDestructor>(); // lambda with "utility" and "something" captured utility->setCallback( [utility, something](){} ); return 0; } 


Saída do console
instance ()
SingletonWeak ()
WeakSingleThreadedUtility ()
SomethingWithVeryImportantDestructor ()
~ SingletonWeak ()
~ SomethingWithVeryImportantDestructor ()
~ WeakSingleThreadedUtility ()

Em vez de uma conclusão


E o que, tal modificação de um singleton eliminará o comportamento indefinido? Prometi que não haveria um final feliz. Os exemplos a seguir mostram que ações hábeis de sabotagem na camada do usuário podem destruir até a biblioteca correta com um singleton (mas devemos admitir que isso dificilmente pode ser feito por acidente).

Shared_Example5_incorrect.cpp
 #include "SingletonShared.h" #include "Payload.h" #include <memory> #include <cstdlib> class SharedSingleThreadedUtility { public: SharedSingleThreadedUtility() // To ensure that singleton will be constucted before utility : m_singleton(SingletonShared<Payload>::instance()) { } ~SharedSingleThreadedUtility() { // Sometimes this check may result as "false" even for destroyed singleton // preventing from visual effects of undefined behaviour ... //if ( m_singleton ) // for ( int i = 0; i < 100; ++i ) // m_singleton->add(i); // ... so this code will allow to demonstrate UB in colour for ( int i = 0; i < 100; ++i ) m_singleton->add(i); } private: // A copy of smart pointer, not a reference std::shared_ptr<SingletonShared<Payload>> m_singleton; }; void cracker() { SharedSingleThreadedUtility(); } // 1. Register cracker() using std::atexit // 2. Create singleton // 3. Create utility auto reg = [](){ std::atexit(&cracker); return 0; }(); auto utility = SharedSingleThreadedUtility(); // This guarantee destruction in order: // - utility; // - singleton. // This order is correct. // Additionally, there's a copy of shared_ptr in the class instance... // ... but there was std::atexit registered before singleton, // so cracker() will be invoked after destruction of utility and singleton. // There's second try to create a singleton - and it's incorrect. int main() { return 0; } 


Saída do console
instance ()
SingletonShared ()
~ SingletonShared ()
instance ()

Weak_Example3_incorrect.cpp
 #include "SingletonWeak.h" #include "Payload.h" #include <memory> #include <cstdlib> class WeakSingleThreadedUtility { public: WeakSingleThreadedUtility() // To ensure that singleton will be constucted before utility : m_weak(SingletonWeak<Payload>::instance()) { } ~WeakSingleThreadedUtility() { // Sometimes this check may result as "false" even in case of incorrect usage, // and there's no way to guarantee a demonstration of undefined behaviour in colour if ( auto strong = m_weak.lock() ) for ( int i = 0; i < 100; ++i ) strong->add(i); } private: // A weak copy of smart pointer, not a reference std::weak_ptr<SingletonWeak<Payload>> m_weak; }; void cracker() { WeakSingleThreadedUtility(); } // 1. Register cracker() using std::atexit // 2. Create singleton // 3. Create utility auto reg = [](){ std::atexit(&cracker); return 0; }(); auto utility = WeakSingleThreadedUtility(); // This guarantee destruction in order: // - utility; // - singleton. // This order is correct. // Additionally, there's a copy of shared_ptr in the class instance... // ... but there was std::atexit registered before singleton, // so cracker() will be invoked after destruction of utility and singleton. // There's second try to create a singleton - and it's incorrect. int main() { return 0; } 


Saída do console
instance ()
SingletonWeak ()
~ SingletonWeak ()
instance ()

Source: https://habr.com/ru/post/pt455848/


All Articles