Während ich ein online herunterladbares Video-Tutorial für die Entwicklung von 3D Graphics & Game Engine mit modernem OpenGL durchgearbeitet habe. Wir haben volatile
in einer unserer Klassen verwendet. Die Tutorial-Website finden Sie hier und das Video, das mit dem volatile
Schlüsselwort arbeitet, befindet sich in der Shader Engine
Serie Video 98. Diese Arbeiten sind nicht meine eigenen, sondern akkreditiert Marek A. Krzeminski, MASc
und dies ist ein Auszug aus der Video-Download-Seite.
Und wenn Sie seine Website abonniert haben und Zugriff auf seine Videos in diesem Video haben, verweist er auf diesen Artikel über die Verwendung Volatile
mit der multithreading
Programmierung.
volatil: Der beste Freund des Multithread-Programmierers
Von Andrei Alexandrescu, 1. Februar 2001
Das flüchtige Schlüsselwort wurde entwickelt, um Compileroptimierungen zu verhindern, die bei bestimmten asynchronen Ereignissen zu falschem Code führen können.
Ich möchte Ihre Stimmung nicht verderben, aber diese Kolumne befasst sich mit dem gefürchteten Thema der Multithread-Programmierung. Wenn - wie in der vorherigen Ausgabe von Generic angegeben - ausnahmesichere Programmierung schwierig ist, ist dies im Vergleich zur Multithread-Programmierung ein Kinderspiel.
Programme, die mehrere Threads verwenden, sind bekanntermaßen schwer zu schreiben, sich als richtig zu erweisen, zu debuggen, zu warten und im Allgemeinen zu zähmen. Falsche Multithread-Programme können jahrelang ohne Probleme ausgeführt werden, um dann unerwartet Amok auszuführen, da einige kritische Timing-Bedingungen erfüllt sind.
Es ist unnötig zu erwähnen, dass eine Programmiererin, die Multithread-Code schreibt, jede Hilfe benötigt, die sie bekommen kann. Diese Kolumne konzentriert sich auf die Rennbedingungen - eine häufige Ursache für Probleme in Multithread-Programmen - und bietet Ihnen Einblicke und Tools, wie Sie diese vermeiden können, und lässt den Compiler erstaunlicherweise hart daran arbeiten, Ihnen dabei zu helfen.
Nur ein kleines Schlüsselwort
Obwohl sowohl C- als auch C ++ - Standards in Bezug auf Threads auffällig leise sind, machen sie dem Multithreading in Form des flüchtigen Schlüsselworts ein kleines Zugeständnis.
Volatile ist genau wie sein bekannteres Gegenstück const ein Typmodifikator. Es soll in Verbindung mit Variablen verwendet werden, auf die in verschiedenen Threads zugegriffen und diese geändert werden. Grundsätzlich wird das Schreiben von Multithread-Programmen ohne Volatile unmöglich oder der Compiler verschwendet enorme Optimierungsmöglichkeiten. Eine Erklärung ist angebracht.
Betrachten Sie den folgenden Code:
class Gadget {
public:
void Wait() {
while (!flag_) {
Sleep(1000); // sleeps for 1000 milliseconds
}
}
void Wakeup() {
flag_ = true;
}
...
private:
bool flag_;
};
Der Zweck von Gadget :: Wait oben besteht darin, die Variable flag_ member jede Sekunde zu überprüfen und zurückzugeben, wenn diese Variable von einem anderen Thread auf true gesetzt wurde. Zumindest hat das sein Programmierer beabsichtigt, aber leider ist Warten falsch.
Angenommen, der Compiler stellt fest, dass Sleep (1000) ein Aufruf einer externen Bibliothek ist, die die Mitgliedsvariable flag_ möglicherweise nicht ändern kann. Dann kommt der Compiler zu dem Schluss, dass er flag_ in einem Register zwischenspeichern und dieses Register verwenden kann, anstatt auf den langsameren integrierten Speicher zuzugreifen. Dies ist eine hervorragende Optimierung für Single-Threaded-Code, aber in diesem Fall beeinträchtigt sie die Korrektheit: Nachdem Sie Wait für ein Gadget-Objekt aufgerufen haben, obwohl ein anderer Thread Wakeup aufruft, wird Wait für immer wiederholt. Dies liegt daran, dass die Änderung von flag_ nicht in dem Register wiedergegeben wird, in dem flag_ zwischengespeichert wird. Die Optimierung ist zu ... optimistisch.
Das Zwischenspeichern von Variablen in Registern ist eine sehr wertvolle Optimierung, die die meiste Zeit angewendet wird. Es wäre daher schade, sie zu verschwenden. C und C ++ bieten Ihnen die Möglichkeit, ein solches Caching explizit zu deaktivieren. Wenn Sie den flüchtigen Modifikator für eine Variable verwenden, speichert der Compiler diese Variable nicht in Registern. Jeder Zugriff trifft auf den tatsächlichen Speicherort dieser Variablen. Alles, was Sie tun müssen, um die Wait / Wakeup-Kombination von Gadget zum Laufen zu bringen, ist, flag_ entsprechend zu qualifizieren:
class Gadget {
public:
... as above ...
private:
volatile bool flag_;
};
Die meisten Erklärungen zur Begründung und Verwendung von flüchtigen Bestandteilen hören hier auf und empfehlen Ihnen, die primitiven Typen, die Sie in mehreren Threads verwenden, flüchtig zu qualifizieren. Mit volatile können Sie jedoch noch viel mehr tun, da es Teil des wunderbaren C ++ - Typsystems ist.
Verwenden von volatile mit benutzerdefinierten Typen
Sie können nicht nur primitive Typen, sondern auch benutzerdefinierte Typen flüchtig qualifizieren. In diesem Fall ändert flüchtig den Typ auf ähnliche Weise wie const. (Sie können auch const und flüchtig gleichzeitig auf denselben Typ anwenden.)
Im Gegensatz zu const unterscheidet flüchtig zwischen primitiven Typen und benutzerdefinierten Typen. Im Gegensatz zu Klassen unterstützen primitive Typen nämlich immer noch alle ihre Operationen (Addition, Multiplikation, Zuweisung usw.), wenn sie flüchtig qualifiziert sind. Beispielsweise können Sie einem flüchtigen int ein nichtflüchtiges int zuweisen, einem flüchtigen Objekt jedoch kein nichtflüchtiges Objekt.
Lassen Sie uns anhand eines Beispiels veranschaulichen, wie flüchtig bei benutzerdefinierten Typen funktioniert.
class Gadget {
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;
Wenn Sie der Meinung sind, dass flüchtig bei Objekten nicht so nützlich ist, bereiten Sie sich auf eine Überraschung vor.
volatileGadget.Foo(); // ok, volatile fun called for
// volatile object
regularGadget.Foo(); // ok, volatile fun called for
// non-volatile object
volatileGadget.Bar(); // error! Non-volatile function called for
// volatile object!
Die Umstellung von einem nicht qualifizierten Typ auf ein volatiles Gegenstück ist trivial. Genau wie bei const können Sie jedoch nicht von volatil zu nicht qualifiziert zurückkehren. Sie müssen eine Besetzung verwenden:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok
Eine flüchtig qualifizierte Klasse gewährt nur Zugriff auf eine Teilmenge ihrer Schnittstelle, eine Teilmenge, die vom Klassenimplementierer gesteuert wird. Benutzer können nur mithilfe eines const_cast vollen Zugriff auf die Benutzeroberfläche dieses Typs erhalten. Ebenso wie bei der Konstanz wird die Flüchtigkeit von der Klasse an ihre Mitglieder weitergegeben (z. B. sind volatileGadget.name_ und volatileGadget.state_ flüchtige Variablen).
flüchtige, kritische Abschnitte und Rennbedingungen
Das einfachste und am häufigsten verwendete Synchronisationsgerät in Multithread-Programmen ist der Mutex. Ein Mutex macht die Grundelemente "Erfassen" und "Freigeben" verfügbar. Sobald Sie Acquire in einem Thread aufrufen, wird jeder andere Thread, der Acquire aufruft, blockiert. Später, wenn dieser Thread Release aufruft, wird genau ein Thread freigegeben, der in einem Acquire-Aufruf blockiert ist. Mit anderen Worten, für einen bestimmten Mutex kann nur ein Thread die Prozessorzeit zwischen einem Aufruf von Acquire und einem Aufruf von Release abrufen. Der Ausführungscode zwischen einem Aufruf von Acquire und einem Aufruf von Release wird als kritischer Abschnitt bezeichnet. (Die Windows-Terminologie ist etwas verwirrend, da sie den Mutex selbst als kritischen Abschnitt bezeichnet, während "Mutex" tatsächlich ein Mutex zwischen Prozessen ist. Es wäre schön gewesen, wenn sie als Thread-Mutex und Prozess-Mutex bezeichnet worden wären.)
Mutexe werden verwendet, um Daten vor Rennbedingungen zu schützen. Per Definition tritt eine Race-Bedingung auf, wenn die Auswirkung von mehr Threads auf Daten davon abhängt, wie Threads geplant sind. Rennbedingungen werden angezeigt, wenn zwei oder mehr Threads um die Verwendung derselben Daten konkurrieren. Da sich Threads zu beliebigen Zeitpunkten gegenseitig unterbrechen können, können Daten beschädigt oder falsch interpretiert werden. Folglich müssen Änderungen und manchmal der Zugriff auf Daten sorgfältig mit kritischen Abschnitten geschützt werden. Bei der objektorientierten Programmierung bedeutet dies normalerweise, dass Sie einen Mutex in einer Klasse als Mitgliedsvariable speichern und ihn verwenden, wenn Sie auf den Status dieser Klasse zugreifen.
Erfahrene Multithread-Programmierer haben vielleicht die beiden obigen Absätze gelesen, aber ihr Zweck ist es, ein intellektuelles Training anzubieten, denn jetzt werden wir uns mit der flüchtigen Verbindung verbinden. Dazu zeichnen wir eine Parallele zwischen der Welt der C ++ - Typen und der Welt der Threading-Semantik.
- Außerhalb eines kritischen Abschnitts kann jeder Thread jederzeit einen anderen unterbrechen. Es gibt keine Steuerung, daher sind Variablen, auf die von mehreren Threads aus zugegriffen werden kann, flüchtig. Dies steht im Einklang mit der ursprünglichen Absicht von flüchtig - das Verhindern, dass der Compiler unabsichtlich Werte zwischenspeichert, die von mehreren Threads gleichzeitig verwendet werden.
- Innerhalb eines kritischen Abschnitts, der durch einen Mutex definiert ist, hat nur ein Thread Zugriff. Folglich hat der ausführende Code innerhalb eines kritischen Abschnitts eine Single-Threaded-Semantik. Die Regelgröße ist nicht mehr flüchtig - Sie können das flüchtige Qualifikationsmerkmal entfernen.
Kurz gesagt, Daten, die zwischen Threads ausgetauscht werden, sind außerhalb eines kritischen Abschnitts konzeptionell flüchtig und innerhalb eines kritischen Abschnitts nicht flüchtig.
Sie betreten einen kritischen Abschnitt, indem Sie einen Mutex sperren. Sie entfernen das flüchtige Qualifikationsmerkmal aus einem Typ, indem Sie einen const_cast anwenden. Wenn es uns gelingt, diese beiden Operationen zusammenzufügen, stellen wir eine Verbindung zwischen dem C ++ - Typsystem und der Threading-Semantik einer Anwendung her. Wir können den Compiler veranlassen, die Rennbedingungen für uns zu überprüfen.
LockingPtr
Wir brauchen ein Tool, das eine Mutex-Erfassung und einen const_cast sammelt. Lassen Sie uns eine LockingPtr-Klassenvorlage entwickeln, die Sie mit einem flüchtigen Objekt obj und einem Mutex mtx initialisieren. Während seiner Lebensdauer hält ein LockingPtr mtx erworben. Außerdem bietet LockingPtr Zugriff auf das flüchtig gestrippte Objekt. Der Zugriff wird auf intelligente Weise über operator-> und operator * angeboten. Der const_cast wird in LockingPtr ausgeführt. Die Besetzung ist semantisch gültig, da LockingPtr den erfassten Mutex für seine Lebensdauer beibehält.
Definieren wir zunächst das Grundgerüst einer Klasse Mutex, mit der LockingPtr funktioniert:
class Mutex {
public:
void Acquire();
void Release();
...
};
Um LockingPtr zu verwenden, implementieren Sie Mutex mithilfe der nativen Datenstrukturen und primitiven Funktionen Ihres Betriebssystems.
LockingPtr wird mit dem Typ der Regelgröße versehen. Wenn Sie beispielsweise ein Widget steuern möchten, verwenden Sie ein LockingPtr, das Sie mit einer Variablen vom Typ flüchtiges Widget initialisieren.
Die Definition von LockingPtr ist sehr einfach. LockingPtr implementiert einen nicht anspruchsvollen Smart Pointer. Es konzentriert sich ausschließlich auf das Sammeln eines const_cast und eines kritischen Abschnitts.
template <typename T>
class LockingPtr {
public:
// Constructors/destructors
LockingPtr(volatile T& obj, Mutex& mtx)
: pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {
mtx.Lock();
}
~LockingPtr() {
pMtx_->Unlock();
}
// Pointer behavior
T& operator*() {
return *pObj_;
}
T* operator->() {
return pObj_;
}
private:
T* pObj_;
Mutex* pMtx_;
LockingPtr(const LockingPtr&);
LockingPtr& operator=(const LockingPtr&);
};
Trotz seiner Einfachheit ist LockingPtr eine sehr nützliche Hilfe beim Schreiben von korrektem Multithread-Code. Sie sollten Objekte, die von Threads gemeinsam genutzt werden, als flüchtig definieren und niemals const_cast mit ihnen verwenden. Verwenden Sie immer automatische LockingPtr-Objekte. Lassen Sie uns dies anhand eines Beispiels veranschaulichen.
Angenommen, Sie haben zwei Threads, die ein Vektorobjekt gemeinsam nutzen:
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector<char> BufT;
volatile BufT buffer_;
Mutex mtx_; // controls access to buffer_
};
Innerhalb einer Thread-Funktion verwenden Sie einfach ein LockingPtr, um kontrollierten Zugriff auf die Variable buffer_ member zu erhalten:
void SyncBuf::Thread1() {
LockingPtr<BufT> lpBuf(buffer_, mtx_);
BufT::iterator i = lpBuf->begin();
for (; i != lpBuf->end(); ++i) {
... use *i ...
}
}
Der Code ist sehr einfach zu schreiben und zu verstehen. Wenn Sie buffer_ verwenden müssen, müssen Sie einen LockingPtr erstellen, der darauf zeigt. Sobald Sie dies getan haben, haben Sie Zugriff auf die gesamte Schnittstelle des Vektors.
Das Schöne daran ist, dass der Compiler Sie darauf hinweist, wenn Sie einen Fehler machen:
void SyncBuf::Thread2() {
// Error! Cannot access 'begin' for a volatile object
BufT::iterator i = buffer_.begin();
// Error! Cannot access 'end' for a volatile object
for ( ; i != lpBuf->end(); ++i ) {
... use *i ...
}
}
Sie können auf keine Funktion von buffer_ zugreifen, bis Sie entweder einen const_cast anwenden oder LockingPtr verwenden. Der Unterschied besteht darin, dass LockingPtr eine geordnete Möglichkeit bietet, const_cast auf flüchtige Variablen anzuwenden.
LockingPtr ist bemerkenswert ausdrucksstark. Wenn Sie nur eine Funktion aufrufen müssen, können Sie ein unbenanntes temporäres LockingPtr-Objekt erstellen und direkt verwenden:
unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}
Zurück zu den primitiven Typen
Wir haben gesehen, wie gut flüchtig Objekte vor unkontrolliertem Zugriff schützen und wie LockingPtr eine einfache und effektive Möglichkeit bietet, thread-sicheren Code zu schreiben. Kehren wir nun zu primitiven Typen zurück, die von flüchtig unterschiedlich behandelt werden.
Betrachten wir ein Beispiel, in dem mehrere Threads eine Variable vom Typ int gemeinsam nutzen.
class Counter {
public:
...
void Increment() { ++ctr_; }
void Decrement() { —ctr_; }
private:
int ctr_;
};
Wenn Increment und Decrement von verschiedenen Threads aufgerufen werden sollen, ist das obige Fragment fehlerhaft. Erstens muss ctr_ flüchtig sein. Zweitens ist sogar eine scheinbar atomare Operation wie ++ ctr_ eine dreistufige Operation. Der Speicher selbst hat keine arithmetischen Fähigkeiten. Beim Inkrementieren einer Variablen muss der Prozessor:
- Liest diese Variable in einem Register
- Erhöht den Wert im Register
- Schreibt das Ergebnis zurück in den Speicher
Diese dreistufige Operation wird als RMW (Read-Modify-Write) bezeichnet. Während des Modify-Teils einer RMW-Operation geben die meisten Prozessoren den Speicherbus frei, um anderen Prozessoren Zugriff auf den Speicher zu gewähren.
Wenn zu diesem Zeitpunkt ein anderer Prozessor eine RMW-Operation für dieselbe Variable ausführt, haben wir eine Race-Bedingung: Der zweite Schreibvorgang überschreibt den Effekt des ersten.
Um dies zu vermeiden, können Sie sich erneut auf LockingPtr verlassen:
class Counter {
public:
...
void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
volatile int ctr_;
Mutex mtx_;
};
Jetzt ist der Code korrekt, aber seine Qualität ist im Vergleich zum SyncBuf-Code minderwertig. Warum? Denn mit Counter warnt Sie der Compiler nicht, wenn Sie versehentlich direkt auf ctr_ zugreifen (ohne es zu sperren). Der Compiler kompiliert ++ ctr_, wenn ctr_ flüchtig ist, obwohl der generierte Code einfach falsch ist. Der Compiler ist nicht mehr Ihr Verbündeter, und nur Ihre Aufmerksamkeit kann Ihnen helfen, Rennbedingungen zu vermeiden.
Was solltest du dann tun? Kapseln Sie einfach die primitiven Daten, die Sie in übergeordneten Strukturen verwenden, und verwenden Sie flüchtige Daten mit diesen Strukturen. Paradoxerweise ist es schlimmer, flüchtig direkt mit integrierten Funktionen zu verwenden, obwohl dies ursprünglich die Verwendungsabsicht von flüchtig war!
flüchtige Mitgliedsfunktionen
Bisher hatten wir Klassen, in denen flüchtige Datenelemente zusammengefasst sind. Denken wir nun daran, Klassen zu entwerfen, die wiederum Teil größerer Objekte sind und von Threads gemeinsam genutzt werden. Hier können flüchtige Elementfunktionen eine große Hilfe sein.
Beim Entwerfen Ihrer Klasse qualifizieren Sie nur die Elementfunktionen, die threadsicher sind, flüchtig. Sie müssen davon ausgehen, dass Code von außen die flüchtigen Funktionen jederzeit von jedem Code aus aufruft. Vergessen Sie nicht: flüchtig entspricht freiem Multithread-Code und keinem kritischen Abschnitt; Nichtflüchtig entspricht einem Single-Thread-Szenario oder einem kritischen Abschnitt.
Sie definieren beispielsweise ein Klassen-Widget, das eine Operation in zwei Varianten implementiert - eine thread-sichere und eine schnelle, ungeschützte.
class Widget {
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
Beachten Sie die Verwendung von Überladung. Jetzt kann der Benutzer von Widget Operation mit einer einheitlichen Syntax aufrufen, entweder für flüchtige Objekte und zur Gewährleistung der Thread-Sicherheit oder für normale Objekte und zur Erzielung der Geschwindigkeit. Der Benutzer muss vorsichtig sein, wenn er die freigegebenen Widget-Objekte als flüchtig definiert.
Bei der Implementierung einer flüchtigen Elementfunktion besteht die erste Operation normalerweise darin, diese mit einem LockingPtr zu sperren. Dann wird die Arbeit mit dem nichtflüchtigen Geschwister erledigt:
void Widget::Operation() volatile {
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation(); // invokes the non-volatile function
}
Zusammenfassung
Wenn Sie Multithread-Programme schreiben, können Sie volatile zu Ihrem Vorteil nutzen. Sie müssen die folgenden Regeln einhalten:
- Definieren Sie alle freigegebenen Objekte als flüchtig.
- Verwenden Sie flüchtig nicht direkt mit primitiven Typen.
- Verwenden Sie beim Definieren gemeinsam genutzter Klassen flüchtige Elementfunktionen, um die Thread-Sicherheit auszudrücken.
Wenn Sie dies tun und die einfache generische Komponente LockingPtr verwenden, können Sie threadsicheren Code schreiben und sich weniger um die Rennbedingungen kümmern, da der Compiler sich um Sie kümmert und fleißig auf die Stellen hinweist, an denen Sie falsch liegen.
Einige Projekte, an denen ich beteiligt war, haben Volatile und LockingPtr mit großer Wirkung eingesetzt. Der Code ist sauber und verständlich. Ich erinnere mich an ein paar Deadlocks, aber ich bevorzuge Deadlocks gegenüber Rennbedingungen, weil sie so viel einfacher zu debuggen sind. Es gab praktisch keine Probleme im Zusammenhang mit den Rennbedingungen. Aber dann weißt du es nie.
Danksagung
Vielen Dank an James Kanze und Sorin Jianu, die mit aufschlussreichen Ideen geholfen haben.
Andrei Alexandrescu ist Entwicklungsleiter bei RealNetworks Inc. (www.realnetworks.com) in Seattle, WA, und Autor des renommierten Buches Modern C ++ Design. Er kann unter www.moderncppdesign.com kontaktiert werden. Andrei ist auch einer der vorgestellten Ausbilder des C ++ - Seminars (www.gotw.ca/cpp_seminar).
Dieser Artikel ist vielleicht etwas veraltet, gibt aber einen guten Einblick in eine hervorragende Verwendung des flüchtigen Modifikators bei der Verwendung von Multithread-Programmierung, um Ereignisse asynchron zu halten, während der Compiler die Rennbedingungen für uns überprüft. Dies beantwortet möglicherweise nicht direkt die ursprüngliche Frage des OP zum Erstellen eines Speicherzauns, aber ich entscheide mich, dies als Antwort für andere zu veröffentlichen, um eine hervorragende Referenz für eine gute Verwendung von Volatile bei der Arbeit mit Multithread-Anwendungen zu finden.