Threadsicheres Initialisieren eines Singletons

Singletons lassen sich in vielen Variationen in C++11 threadsicher initialisieren. Dabei können vereinfachend gesprochen Zusicherung der C++-Laufzeit, Locks oder auch atomare Operationen verwendet werden. Mich interessiert vor allem in diesem Artikel die Antwort auf die Frage: Welche Performanzunterschiede bestehen zwischen den Variationen?

Meine Strategie

Als Referenzwert verwende ich ein Singleton Objekt, auf dass ich 40 Millionen Mal zugreife. Der erste Zugriff initialisiert das Objekt. Im Gegensatz dazu werden die Zugriffe aus dem Multithreading Programm von 4 Threads aus stattfinden. Hier gilt mein Interesse nur der reinen Performanz der Zugriffe. Die Programme lasse ich auf zwei physikalischen Rechner laufen. Unter Linux besitzt mein Rechner vier, unter Windows aber nur zwei Kerne. Beide Programme übersetze ich sowohl ohne als auch mit maximaler Optimierung. Bei der Übersetzung mit maximaler Optimierung muss ich einen kleinen Trick anwenden, indem ich eine volatile Variable dummy in der statischen Methode getInstance verwende. Dies hat den einfachen Grund, dass dadurch der Optimierer den Zugriff auf das Singleton Objekt nicht wegoptimieren darf.

Drei Fragestellung stehen jetzt im Vordergrund:

  1. Wie ist die relative Performanz der verschiedenen Singleton Implementierungen?
  2. Gibt es signifikate Unterschiede in der relativen Performanz zwischen Linux (gcc) und Windows (cl.exe)?
  3. Welche Unterschiede bestehen zwischen der optimierten und nicht optimierten Programmen?

Zum Abschluss werde ich die nackten Zahlen in einer Tabelle zusammenfassen. Alle Zahlenangaben sind in Sekunden.

Die Referenzwerte

Die verwendeten Compiler

Die Details zu den Compiler lassen sich am besten direkt von der Kommandozeile auslesen.

gcccl exe

Der Referenzcode

Der Zugriff auf das nicht synchronisierte Singleton Objekt. 

 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;

}

 

In der Referenzimplementierung verwende ich das sogenannte Meyers Singleton. Das elegante an dieser Implementierung ist, dass die Singleton Objekt in Zeile 11 als statische Variablen mit Blockgültigkeit genau dann initialisiert wird, wenn die statische Methode getInstance (Zeile 10 - 14) das erste Mal aufgerufen wird. In Zeile 14 ist die volatile Variable dummy auskommentiert. Diese Zeile verwende ich dann, wenn ich das Programm mit maximaler Optimierung übersetze. Dadurch verhindere ich, dass in diesem Fall der Aufruf der MySingleton::geInstance() Methode wegoptimiert wird.

Nun die nackten Zahlen unter Linux und Windows.

Ohne Optimierung

singletonSingleThreadedsingletonSingleThreaded win

Maximale Optimierung

 singletonSingleThreaded optsingletonSingleThreaded win opt

Zusicherungen der C++-Laufzeit

Die Details zur threadsicheren Initialisierung von Variablen habe ich in dem Artikel Sicheres Initialisieren der Daten vorgestellt.

Meyers Singleton

Das Schöne am Meyers Singleton im C++11-Standard ist es, dass deren Umsetzung automatisch threadsicher ist, denn diese wichtige Eingschaft sichert der Standard für statische Variablen mit Blockgültigkeit zu. Das Meyers Singleton ist eine statische Variable mit Blockgültigkeit. Damit muss ich das Programm nur noch für vier Threads umschreiben.

 

 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;

}

 

Die Singleton Objekte verwende ich in der Funktion getTime in Zeile 24 - 32. Die Funktion wird von den vier Promisen in den Zeilen 36 - 39 ausgeführt. Die assozierten Future sammeln alle Ergebnisse in der Zeile 41 ein. Jetzt fehlt nur noch die Ausführungszeiten des Programms in Sekunden.

Ohne Optimierung

 singletonMeyerssingletonMeyers win

Maximale Optimierung

singletonMeyers optsingletonMeyers win opt

 Weiter geht's mit der der Funktion std::call_once in Kombination mit dem Flag std::once_flag.

Die Funktion std::call_once und das std::once_flag

Mit der Funktion std::call_once lässt sich eine aufrufbare Einheit registrieren, die mit Hilfe des std::once_flag genau einmal ausgeführt wird. In der nächsten Variation des Singleton Patterns stellt die Funktion std::call_once sicher, dass das Singleton Objekt genau einmal initialisiert wird.

 

 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;

}

 

Zum Abschluss die Ausgabe des Programms.

Ohne Optimierung

singletoCallOncesingletoCallOnce win

Maximale Optimierung

singletoCallOnce optsingletoCallOnce win opt

Die naheliegende Option ist es, den Zugriff auf das Singleton Objekt mit einem Lock zu schützen.

Lock

Der in ein Lock verpackte Mutex stellt sicher, dass die Initialisierung des Singleton Objekts threadsicher ist.

 

 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;
}

 

Wie schlägt sich die klassische threadsichere Implementierung des Singleton Patterns?

Ohne Optimierung

 singletonLocksingletonLock win

Maximale Optimierung

singletonLock optsingletonLock win opt

Mit atomaren Variablen solltes es deutlich schneller werden.

Atomare Operationen

Mit atomaren Variablen und deren Operationen ist die threadsichere Initialisierung des Singleton Objektes deutlich anspruchsvoller. Jetzt gelten explizit die Regeln des C++-Speichermodells. Meine Implentierungen basieren auf dem Double-Checked Locking Pattern

Sequenzielle Konsistenz

Der Verweis auf das Singleton Objekte ist nun ein atomare Variable. Da ich die das C++-Speichermodell nicht explizit spezifiziert habe, gilt der Default: Sequenzielle Konsistenz.

 

 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;

}

Nun bin ich gespannt.

Ohne Optimierung

 singletonSequentialConsistencysingletonSequentialConsistency win

Maximale Optimierung

singletonSequentialConsistency optsingletonSequentialConsistency win opt

 

Eine weitere Optimierungsmöglichkeit für atomare Operationen will ich noch anwenden.

Acquire-Release Semantik

Das Lesen auf dem Singleton Objekt (Zeile 14) ist eine acquire-Operation, das Schreiben auf dem Singleton-Objekt ist eine release-Operation (Zeile 20). Da beide Operationen auf der gleichen atomaren Variablen vollzogen werden, ist in diesem Fall die strenge Sequenzielle Konsistenz nicht notwendig. Der C++-Standard sichert zu, dass eine acquire-Operation mit einer davor ausgeführen release-Operation synchronisiert, wenn sie auf der gleichen atomaren Variablen ausgeführt werden. Diese Bedingung gilt in unserem konkreten Fall. Daher lockere ich die Bedingungen an das C++-Speichermodell in Zeile 14 und 20 und verwende die Acquire-Release Semantik.

 

 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;

}

Die Acquire-Release Semantik besitzt eine ähnliche Performanz wie die Sequenzielle Konsistenz. Das verwundert mich nicht, denn auf x86-Architekturen sind beide Speichermodelle sehr ähnlich. Auf einer ARMv7- oder PowerPC-Architektur sind die Unterschiede deutlich drastischer. Die Details dazu gibt es auf Jeff Preshings Blog Preshing on Programming.

Ohne Optimierung

 singletonAcquireReleasesingletonAcquireRelease win

Maximale Optimierung

singletonAcquireRelease optsingletonAcquireRelease win opt

Habe ich eine wichtige Variante der threadsicheren Initialisierung eines Singleton Objektes vergessen? Wenn ja, schicke mir deine threadsichere Singleton Implementierung zu und ich werde sie in die Aufstellung aufnehmen.

Alle Zahlen im Überblick

Da ich alle Programme nur einmalig ausgeführt habe und die ausführbaren Dateien für Windows auf vier Kerne optimiert habe, obwohl ich nur zwei besitze, will ich die Zahlen nicht überbewerten. Sie geben aber ein deutliches Zeichen. Das Meyers Singleton ist sehr einfach zu verstehen und besitzt die beste Performanz. Insbesondere die auf Locks basierende Version ist deutlich langsamer. Diese Aussagen gelten unabhängig davon, ob ich die Programme mit dem GCC- oder dem cl.exe-Compiler übersetzt habe. 

Schön zeigen die vielen Zahlen aber auch, das sich das Optimieren lohnt. Diese Aussage gilt nur mit leichter Einschränkung für die Lock Implementierung des Singleton Patterns, das intern std::lock_guard verwendet.

 

comparisonSingleton

Wie geht's weiter?

Nachdem ich doch mit dem Artikel zu Singletons sehr stark polarisiert haben, will auf meine Meinung zum Singleton im nächsten Artikel deutlich eingehen. Mal schauen, in welche Richtung sich die Diskussion noch weiter bewegt.

 

 

 

 

 

 

 

 

title page smalltitle page small Go to Leanpub/cpplibrary "What every professional C++ programmer should know about the C++ standard library".   Hole dir dein E-Book. Unterstütze meinen Blog.

 

Kommentare   

0 #1 Thomas 2016-02-21 01:21
Die klassische Implementierung mit lock_guard ist kein Sigleton, denn sie erzeugt bei jedem Aufruf von getInstance() eine neue, also insgesamt 40000000 Instanzen. Daher "hinkt" der Vergleich des Zeitverhaltens mit den anderen Varianten.
Andererseits ist die zweite Abfrage von sin in der atomaren und der Acquire-Release-Variante wirkungslos, weil sin eine lokale Stack-Variable ist, die sich während des Wartens auf den Mutex nicht ändern kann. Daher werden auch in diesen beiden Beispielen mindestens einige Instanzen erzeugt.
Zitieren
0 #2 Rainer Grimm 2016-02-21 17:52
zitiere Thomas:

Andererseits ist die zweite Abfrage von sin in der atomaren und der Acquire-Release-Variante wirkungslos, weil sin eine lokale Stack-Variable ist, die sich während des Wartens auf den Mutex nicht ändern kann. Daher werden auch in diesen beiden Beispielen mindestens einige Instanzen erzeugt.

Vielen Dank, wie auch schon Thomas Gahr festgestellt hat, habe ich das neue laden sin= instance.load() unmittelbar vor dem zweiten Check vergessen. Das werde ich in beiden Beispielen korrigieren.
Zitieren
0 #3 Rainer Grimm 2016-02-21 18:14
zitiere Thomas:
Die klassische Implementierung mit lock_guard ist kein Sigleton, denn sie erzeugt bei jedem Aufruf von getInstance() eine neue, also insgesamt 40000000 Instanzen. Daher "hinkt" der Vergleich des Zeitverhaltens mit den anderen Varianten.

Stimmt, meine Implementierung ist threadsicher aber kein Singleton. Das werde ich anpassen.
Zitieren
0 #4 Kamagra heisega38u 2016-11-14 07:43
I was able to find good advice from your blog posts.
Zitieren
0 #5 https://goo.gl/ 2016-12-21 14:15
Hi there, I found your web site by way of Google even as searching for a related subject, your site came up, it seems to be great.
I have bookmarked it in my google bookmarks.
Hi there, just become aware of your weblog through Google, and located that it's truly informative.
I am going to watch out for brussels. I will appreciate in the event you continue
this in future. Numerous folks will probably be benefited out of your writing.
Cheers!
Zitieren

Kommentar schreiben


Modernes C++

Abonniere den Newsletter

Inklusive zwei Artikel meines Buchs
Introduction und Multithreading

Beiträge-Archiv

Sourcecode

Neuste Kommentare