C++11: Thread-safe Lazy Initialization
Lazy Initialization bezeichnet die Maßnahme, die Initialisierung von Objekten bis zur ersten Verwendung zu verzögern. Diese Maßnahme kann die Responsiveness von Anwendungen deutlich verbessern, indem zeitaufwendige Operationen erst dann durchgeführt werden, wenn ihre Ergebnisse tatsächlich benötigt werden.
Ein kritischer Aspekt der Lazy Initialization ist die Threadsicherheit. In der Regel müssen dabei zwei Punkte sichergestellt werden: Erstens darf die Initialisierung nur einmal erfolgen. Und zweitens dürfen nur vollständig konstruierte Objekte verwendet werden.
In manchen Situationen sind beide Punkte einfach zu realisieren. Das folgende Beispiel zeigt die verzögerte Initialisierung eines global zugreifbaren Objekts. Die Variable instance
wird beim ersten Aufruf der Funktion get_gadget
initialisiert, und erst am Ende des Programms wieder zerstört. Diese Möglichkeit der Lazy Initialization existiert schon lange, doch erst seit C++11 und der Einführung von Nebenläufigkeit ist auch deren Threadsicherheit garantiert.
auto get_gadget() -> gadget&
{
static gadget instance{/*...*/};
return instance;
}
Die Implementierung ist zwar einfach, doch leider nur von geringem Nutzen. Im Wesentlichen handelt es sich um eine verkappte, globale Variable, die aufgrund der globalen Zuriffspfade häufig zu schlecht wartbarem Code führt.
Viel nützlicher als auf globaler Ebene ist die Lazy Initialization in Bezug zu einem anderen Objekt. Das folgende Listing zeigt eine Klasse widget
, in der ein gadget
bei Bedarf erzeugt wird. Allerdings ist der Code nicht thread-safe – beziehungsweise genauer, das Verhalten ist undefiniert, wenn die Funktion auf der gleichen Instanz gleichzeitig aus unterschiedlichen Threads aufgerufen werden kann.
class widget
{
std::unique_ptr<gadget> _gadget;
public:
auto get_gadget() -> gadget&
{
if (!_gadget) { // ERROR: not thread-safe
_gadget.reset(new gadget{/*...*/});
}
return *_gadget;
}
};
Man kann die problematischen Data Races einfach eliminieren, indem man die Klasse um eine Member-Variable vom Typ std::mutex
ergänzt, und die gleichzeitige Ausführung des Funktionsrumpfs mit einem std::lock_guard
unterbindet. Das ist gut, doch es geht besser.
Spezifisch für die Lazy Initialization ist, dass im Regelfall die Initialisierung bereits erfolgt ist. Die Ausführung lässt sich also optimieren, indem man zuerst auf diesen Fall prüft, und die Initialisierung bei Erfolg überspringt. Für die Prüfung genügt eine Load-Operation mit Acquire-Semantik, was wesentlich effizienter als ein std::mutex
ist. Da die Implementierung einerseits nicht trivial, andererseits jedoch häufig nützlich ist, unterstützen fast alle Bibliotheken diese Anforderung. Im POSIX gehört dazu die Funktion pthread_once
, in C++11 gibt es den Datentyp std::once_flag
und das Funktionstemplate std::call_once
.
class widget
{
std::once_flag _gadget_init;
std::unique_ptr<gadget> _gadget;
public:
auto get_gadget() -> gadget&
{
std::call_once(_gadget_init, [this]() { _gadget.reset(new gadget{/*...*/}); });
return *_gadget;
}
};
Die Funktion erfüllt genau den Anwedungsfall der Lazy Initialization. Beim ersten Aufruf für das übergebene std::once_flag
wird die Lambda-Funktion ausgeführt, und alle anderen Threads blockieren an dieser Stelle, bis die Initialisierung abgeschlossen ist. Scheitert die Initialisierung mit einer Ausnahme, so bricht der entsprechende Aufruf mit dieser Ausnahme ab, und der Nächste darf sich an der Initialisierung versuchen.
Die Implementierung in GCC-4.8 ist zwar noch nicht optimal. Dennoch empfehle ich bereits jetzt die Verwendung. Denn die Optimierungen werden kommen, und es ist allemal besser, als ein fehlerhaftes Double-checked Locking selbst zu implementieren.