Inizializzazione thread-safe di un singleton

Inizializzazione thread-safe di un singleton

Ci sono molti problemi con il modello singleton. Ne sono totalmente consapevole. Ma il pattern singleton è un caso d'uso ideale per una variabile, che deve solo essere inizializzata in modo thread-safe. Da quel momento in poi puoi usarlo senza sincronizzazione. Quindi, in questo post, discuto di diversi modi per inizializzare un singleton in un ambiente multithreading. Ottieni i numeri delle prestazioni e puoi ragionare sui tuoi casi d'uso per l'inizializzazione thread-safe di una variabile.

Esistono molti modi diversi per inizializzare un singleton in C++ 11 in modo thread-safe. Da un colpo d'occhio, puoi avere garanzie dal runtime C++, dai blocchi o dagli atomi. Sono assolutamente curioso delle implicazioni sulle prestazioni.

La mia strategia

Uso come punto di riferimento per la mia misurazione delle prestazioni un oggetto singleton a cui accedo in sequenza 40 milioni di volte. Il primo accesso inizializzerà l'oggetto. Al contrario, l'accesso dal programma multithreading sarà effettuato da 4 thread. Qui mi interessa solo la performance. Il programma verrà eseguito su due PC reali. Il mio PC Linux ne ha quattro, il mio PC Windows ha due core. Compilo il programma con il massimo e senza ottimizzazione. Per la traduzione del programma con la massima ottimizzazione, devo utilizzare una variabile volatile nel metodo statico getInstance. In caso contrario, il compilatore ottimizzerà il mio accesso al singleton e il mio programma diventerà troppo veloce.

Ho tre domande in mente:

  1. Come sono le prestazioni relative delle diverse implementazioni singleton?
  2. C'è una differenza significativa tra Linux (gcc) e Windwos (cl.exe)?
  3. Qual ​​è la differenza tra le versioni ottimizzate e non ottimizzate?

Infine, raccolgo tutti i numeri in una tabella. I numeri sono in secondi.

I valori di riferimento

I due compilatori

La riga di comando fornisce i dettagli del compilatore. Ecco gcc e cl.exe.

Il codice di riferimento

In un primo momento, il caso single-thread. Ovviamente senza sincronizzazione.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// singletonSingleThreaded.cpp

#include <chrono>
#include <iostream>

constexpr auto tenMill= 10000000;

class MySingleton{
public:
 static MySingleton& getInstance(){
 static MySingleton instance;
 // volatile int dummy{};
 return instance;
 }
private:
 MySingleton()= default;
 ~MySingleton()= default;
 MySingleton(const MySingleton&)= delete;
 MySingleton& operator=(const MySingleton&)= delete;
 
};

int main(){
 
 constexpr auto fourtyMill= 4* tenMill;
 
 auto begin= std::chrono::system_clock::now();
 
 for ( size_t i= 0; i <= fourtyMill; ++i){
 MySingleton::getInstance();
 }
 
 auto end= std::chrono::system_clock::now() - begin;
 
 std::cout << std::chrono::duration<double>(end).count() << std::endl;

}

Uso nell'implementazione di riferimento il cosiddetto Mayers Singleton. L'eleganza di questa implementazione è che l'istanza dell'oggetto singleton nella riga 11 è una variabile statica con un ambito di blocco. Pertanto, l'istanza verrà inizializzata esattamente, quando il metodo statico getInstance (riga 10 - 14) verrà eseguito la prima volta. Nella riga 14 la variabile volatile dummy è commentata. Quando traduco il programma con la massima ottimizzazione che deve cambiare. Quindi la chiamata MySingleton::getInstance() non verrà ottimizzata.

Ora i numeri grezzi su Linux e Windows.

Senza ottimizzazione

Massima ottimizzazione

Garanzie del runtime C++

Ho già presentato i dettagli sull'inizializzazione thread-safe delle variabili nell'inizializzazione thread-safe dei dati post.

Meyers Singleton

La bellezza di Meyers Singleton in C++11 è che è automaticamente thread-safe. Ciò è garantito dallo standard:variabili statiche con ambito di blocco. Il Meyers Singleton è una variabile statica con scope a blocchi, quindi abbiamo finito. Resta ancora da riscrivere il programma per quattro thread.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// singletonMeyers.cpp

#include <chrono>
#include <iostream>
#include <future>

constexpr auto tenMill= 10000000;

class MySingleton{
public:
 static MySingleton& getInstance(){
 static MySingleton instance;
 // volatile int dummy{};
 return instance;
 }
private:
 MySingleton()= default;
 ~MySingleton()= default;
 MySingleton(const MySingleton&)= delete;
 MySingleton& operator=(const MySingleton&)= delete;

};

std::chrono::duration<double> getTime(){

 auto begin= std::chrono::system_clock::now();
 for ( size_t i= 0; i <= tenMill; ++i){
 MySingleton::getInstance();
 }
 return std::chrono::system_clock::now() - begin;
 
};

int main(){
 
 auto fut1= std::async(std::launch::async,getTime);
 auto fut2= std::async(std::launch::async,getTime);
 auto fut3= std::async(std::launch::async,getTime);
 auto fut4= std::async(std::launch::async,getTime);
 
 auto total= fut1.get() + fut2.get() + fut3.get() + fut4.get();
 
 std::cout << total.count() << std::endl;

}

Uso l'oggetto singleton nella funzione getTime (riga 24 - 32). La funzione viene eseguita dalle quattro promesse nella riga 36 - 39. I risultati dei futures associati sono riassunti nella riga 41. Questo è tutto. Manca solo il tempo di esecuzione.

Senza ottimizzazione

Massima ottimizzazione

Il passaggio successivo è la funzione std::call_once in combinazione con il flag std::once_flag.

La funzione std::call_once e il flag std::once_flag

È possibile utilizzare la funzione std::call_once per registrare un callable che verrà eseguito esattamente una volta. Il flag std::call_once nell'implementazione seguente garantisce che il singleton sarà inizializzato thread-safe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// singletonCallOnce.cpp

#include <chrono>
#include <iostream>
#include <future>
#include <mutex>
#include <thread>

constexpr auto tenMill= 10000000;

class MySingleton{
public:
 static MySingleton& getInstance(){
 std::call_once(initInstanceFlag, &MySingleton::initSingleton);
 // volatile int dummy{};
 return *instance;
 }
private:
 MySingleton()= default;
 ~MySingleton()= default;
 MySingleton(const MySingleton&)= delete;
 MySingleton& operator=(const MySingleton&)= delete;

 static MySingleton* instance;
 static std::once_flag initInstanceFlag;

 static void initSingleton(){
 instance= new MySingleton;
 }
};

MySingleton* MySingleton::instance= nullptr;
std::once_flag MySingleton::initInstanceFlag;

std::chrono::duration<double> getTime(){

 auto begin= std::chrono::system_clock::now();
 for ( size_t i= 0; i <= tenMill; ++i){
 MySingleton::getInstance();
 }
 return std::chrono::system_clock::now() - begin;
 
};

int main(){

 auto fut1= std::async(std::launch::async,getTime);
 auto fut2= std::async(std::launch::async,getTime);
 auto fut3= std::async(std::launch::async,getTime);
 auto fut4= std::async(std::launch::async,getTime);
 
 auto total= fut1.get() + fut2.get() + fut3.get() + fut4.get();
 
 std::cout << total.count() << std::endl;

}

Ecco i numeri.

Senza ottimizzazione

Massima ottimizzazione

Naturalmente, il modo più ovvio è proteggere il singleton con un lucchetto.

Blocca

Il mutex racchiuso in un blocco garantisce che il singleton sarà inizializzato thread-safe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// singletonLock.cpp

#include <chrono>
#include <iostream>
#include <future>
#include <mutex>

constexpr auto tenMill= 10000000;

std::mutex myMutex;

class MySingleton{
public:
 static MySingleton& getInstance(){
 std::lock_guard<std::mutex> myLock(myMutex);
 if ( !instance ){
 instance= new MySingleton();
 }
 // volatile int dummy{};
 return *instance;
 }
private:
 MySingleton()= default;
 ~MySingleton()= default;
 MySingleton(const MySingleton&)= delete;
 MySingleton& operator=(const MySingleton&)= delete;

 static MySingleton* instance;
};


MySingleton* MySingleton::instance= nullptr;

std::chrono::duration<double> getTime(){

 auto begin= std::chrono::system_clock::now();
 for ( size_t i= 0; i <= tenMill; ++i){
 MySingleton::getInstance();
 }
 return std::chrono::system_clock::now() - begin;
 
};

int main(){
 
 auto fut1= std::async(std::launch::async,getTime);
 auto fut2= std::async(std::launch::async,getTime);
 auto fut3= std::async(std::launch::async,getTime);
 auto fut4= std::async(std::launch::async,getTime);
 
 auto total= fut1.get() + fut2.get() + fut3.get() + fut4.get();
 
 std::cout << total.count() << std::endl;
}

Quanto è veloce la classica implementazione thread-safe del pattern singleton?

Senza ottimizzazione

Massima ottimizzazione

Non così in fretta. L'atomica dovrebbe fare la differenza.

Variabili atomiche

Con le variabili atomiche, il mio lavoro diventa estremamente impegnativo. Ora devo usare il modello di memoria C++. Baso la mia implementazione sul noto schema di blocco ricontrollato.

Coerenza sequenziale

L'handle del singleton è atomico. Poiché non ho specificato il modello di memoria C++, si applica l'impostazione predefinita:coerenza sequenziale.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// singletonAcquireRelease.cpp

#include <atomic>
#include <iostream>
#include <future>
#include <mutex>
#include <thread>

constexpr auto tenMill= 10000000;

class MySingleton{
public:
 static MySingleton* getInstance(){
 MySingleton* sin= instance.load();
 if ( !sin ){
 std::lock_guard<std::mutex> myLock(myMutex);
 sin= instance.load();
 if( !sin ){
 sin= new MySingleton();
 instance.store(sin);
 }
 } 
 // volatile int dummy{};
 return sin;
 }
private:
 MySingleton()= default;
 ~MySingleton()= default;
 MySingleton(const MySingleton&)= delete;
 MySingleton& operator=(const MySingleton&)= delete;

 static std::atomic<MySingleton*> instance;
 static std::mutex myMutex;
};


std::atomic<MySingleton*> MySingleton::instance;
std::mutex MySingleton::myMutex;

std::chrono::duration<double> getTime(){

 auto begin= std::chrono::system_clock::now();
 for ( size_t i= 0; i <= tenMill; ++i){
 MySingleton::getInstance();
 }
 return std::chrono::system_clock::now() - begin;
 
};


int main(){

 auto fut1= std::async(std::launch::async,getTime);
 auto fut2= std::async(std::launch::async,getTime);
 auto fut3= std::async(std::launch::async,getTime);
 auto fut4= std::async(std::launch::async,getTime);
 
 auto total= fut1.get() + fut2.get() + fut3.get() + fut4.get();
 
 std::cout << total.count() << std::endl;

}

Ora sono curioso.

Senza ottimizzazione

Massima ottimizzazione

Ma possiamo fare di meglio. C'è un'ulteriore possibilità di ottimizzazione.

Acquire-release Semantic

La lettura del singleton (riga 14) è un'operazione di acquisizione, la scrittura un'operazione di rilascio (riga 20). Poiché entrambe le operazioni si svolgono sullo stesso atomico, non ho bisogno di coerenza sequenziale. Lo standard C++ garantisce che un'operazione di acquisizione si sincronizzi con un'operazione di rilascio sullo stesso atomico. Queste condizioni sono valide in questo caso, quindi posso indebolire il modello di memoria C++ nelle righe 14 e 20. La semantica di acquisizione-rilascio è sufficiente.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// singletonAcquireRelease.cpp

#include <atomic>
#include <iostream>
#include <future>
#include <mutex>
#include <thread>

constexpr auto tenMill= 10000000;

class MySingleton{
public:
 static MySingleton* getInstance(){
 MySingleton* sin= instance.load(std::memory_order_acquire);
 if ( !sin ){
 std::lock_guard<std::mutex> myLock(myMutex);
 sin= instance.load(std::memory_order_relaxed);
 if( !sin ){
 sin= new MySingleton();
 instance.store(sin,std::memory_order_release);
 }
 } 
 // volatile int dummy{};
 return sin;
 }
private:
 MySingleton()= default;
 ~MySingleton()= default;
 MySingleton(const MySingleton&)= delete;
 MySingleton& operator=(const MySingleton&)= delete;

 static std::atomic<MySingleton*> instance;
 static std::mutex myMutex;
};


std::atomic<MySingleton*> MySingleton::instance;
std::mutex MySingleton::myMutex;

std::chrono::duration<double> getTime(){

 auto begin= std::chrono::system_clock::now();
 for ( size_t i= 0; i <= tenMill; ++i){
 MySingleton::getInstance();
 }
 return std::chrono::system_clock::now() - begin;
 
};


int main(){

 auto fut1= std::async(std::launch::async,getTime);
 auto fut2= std::async(std::launch::async,getTime);
 auto fut3= std::async(std::launch::async,getTime);
 auto fut4= std::async(std::launch::async,getTime);
 
 auto total= fut1.get() + fut2.get() + fut3.get() + fut4.get();
 
 std::cout << total.count() << std::endl;

}

La semantica di acquisizione-rilascio ha prestazioni simili alla consistenza sequenziale. Non sorprende, perché su x86 entrambi i modelli di memoria sono molto simili. Otterremmo numeri completamente diversi su un'architettura ARMv7 o PowerPC. Puoi leggere i dettagli sul blog di Jeff Preshings Preshing on Programming.

Senza ottimizzazione

Massima ottimizzazione

.

Se dimentico una variante di importazione del pattern singleton thread-safe, fatemelo sapere e inviatemi il codice. Lo misurerò e aggiungerò i numeri al confronto.

Tutti i numeri a colpo d'occhio

Non prendere i numeri troppo sul serio. Ho eseguito ogni programma solo una volta e l'eseguibile è ottimizzato per quattro core sul mio PC Windows con due core. Ma i numeri danno una chiara indicazione. Il Meyers Singleton è il più facile da ottenere e il più veloce. In particolare, l'implementazione basata su lock è di gran lunga la più lenta. I numeri sono indipendenti dalla piattaforma utilizzata.

Ma i numeri mostrano di più. L'ottimizzazione conta. Questa affermazione non è del tutto vera per l'implementazione basata su std::lock_guard del pattern singleton.

Cosa c'è dopo?

Non sono così sicuro. Questo post è la traduzione di un post tedesco che ho scritto sei mesi fa. Il mio post tedesco riceve molte reazioni. Non sono sicuro, cosa accadrà questa volta. Lettera di pochi giorni ne sono sicuro. Il prossimo post riguarderà l'aggiunta degli elementi di un vettore. Innanzitutto, occupa un thread.